Merge branch 'main' into 48-generator-guide

This commit is contained in:
ddadmin committed 2026-08-28 06:06:19 -04:00
commit 74061aa499
9 files changed
+131 -41

No files matched your search

+5
View File
@@ -262,6 +262,11 @@ uv run --project generator python -m clickstream_generator batch \
`0` — очистка закончена, а начальная генерация ещё не завершена. Работники `0` — очистка закончена, а начальная генерация ещё не завершена. Работники
начинают играть только с позиции `8` и больше. начинают играть только с позиции `8` и больше.
В форме запуска можно выбрать только число дней. Первый день работник берет из
`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options».
Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую
позицию работник пишет в журнал шага `remember_played`.
Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно —
встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы
целиком — [ADR 0009](docs/adr/0009-world-control.md). целиком — [ADR 0009](docs/adr/0009-world-control.md).
+6 -4
View File
@@ -48,10 +48,10 @@ x-kafka-target: &kafka-target
x-world-starting-days: &world-starting-days "8" x-world-starting-days: &world-starting-days "8"
# Общие образы Airflow и Superset собирают службы инициализации. Причина
# отдельного build: ADR 0001.
x-airflow-common: &airflow-common x-airflow-common: &airflow-common
image: clickstream-airflow:local image: clickstream-airflow:local
build:
context: ./infra/airflow
environment: environment:
AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__CORE__EXECUTOR: LocalExecutor
AIRFLOW__CORE__PARALLELISM: "4" AIRFLOW__CORE__PARALLELISM: "4"
@@ -105,8 +105,6 @@ x-generator-common: &generator-common
x-superset-common: &superset-common x-superset-common: &superset-common
image: clickstream-superset:local image: clickstream-superset:local
build:
context: ./infra/superset
environment: environment:
SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py
SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env} SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env}
@@ -257,6 +255,8 @@ services:
airflow-init: airflow-init:
<<: *airflow-common <<: *airflow-common
build:
context: ./infra/airflow
user: "0:0" user: "0:0"
restart: "no" restart: "no"
depends_on: depends_on:
@@ -340,6 +340,8 @@ services:
superset-init: superset-init:
<<: *superset-common <<: *superset-common
build:
context: ./infra/superset
restart: "no" restart: "no"
healthcheck: healthcheck:
disable: true disable: true
+25 -3
View File
@@ -28,6 +28,7 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
import os import os
from airflow.exceptions import AirflowException from airflow.exceptions import AirflowException
@@ -141,7 +142,19 @@ def _ingest_orders() -> TriggerDagRunOperator:
tags=TAGS, tags=TAGS,
params={ params={
"days": Param( "days": Param(
1, type="integer", minimum=1, maximum=7, title="Сколько дней прожить" 1,
type="integer",
minimum=1,
maximum=7,
title="Сколько дней прожить",
# description_md рендерится подсказкой прямо в форме запуска
# (params.rst, Airflow 3.3), чтобы правило было видно сразу.
description_md=(
"Форма задает только число дней. Номер первого несыгранного "
"дня работник берет из переменной `world_position` (Admin → "
"Variables). Поля Logical date и Run ID нужны самому Airflow "
"и на выбор дня не влияют."
),
) )
}, },
) )
@@ -149,14 +162,20 @@ def world_next_day():
"""Прожить следующие дни пачкой, без пауз. """Прожить следующие дни пачкой, без пауз.
Запускается руками. День по умолчанию один, но разгон вперёд идёт одним Запускается руками. День по умолчанию один, но разгон вперёд идёт одним
нажимом, а не десятью: сколько дней играть — параметр запуска. нажимом, а не десятью: сколько дней играть — параметр запуска. Первый
день работник берет из `world_position`. Логическая дата прогона на выбор
дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранным дням и только по успеху.""" """Позиция ставится по сыгранным дням и только по успеху."""
days = get_current_context()["params"]["days"] days = get_current_context()["params"]["days"]
Variable.set(WORLD_POSITION, str(first_day + days)) position = first_day + days
Variable.set(WORLD_POSITION, str(position))
logging.info(
"дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position
)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator( played = _generator(
@@ -196,12 +215,15 @@ def world_live_day():
Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре
реальные минуты, и суточная волна разворачивается на глазах. Запускается реальные минуты, и суточная волна разворачивается на глазах. Запускается
руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`.
Следующий день работник берет из `world_position`. Логическая дата прогона
на выбор дня не влияет.
""" """
@task @task
def remember_played(first_day: int) -> None: def remember_played(first_day: int) -> None:
"""Позиция ставится по сыгранному дню и только по успеху.""" """Позиция ставится по сыгранному дню и только по успеху."""
Variable.set(WORLD_POSITION, str(first_day + 1)) Variable.set(WORLD_POSITION, str(first_day + 1))
logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1)
first_day = first_unplayed_day() first_day = first_unplayed_day()
played = _generator("play_day", ["live", "--day", PLAYED_DAY]) played = _generator("play_day", ["live", "--day", PLAYED_DAY])
+7
View File
@@ -16,6 +16,13 @@
возвращает единицу — поведение проверено при исполнении #37. Зависимость возвращает единицу — поведение проверено при исполнении #37. Зависимость
включает результат подготовки в общий граф ожидания. включает результат подготовки в общий граф ожидания.
У каждого общего локального образа один блок `build`. Он находится у службы
инициализации. Остальные службы зависят от неё и используют тот же `image`.
`docker compose up --build` завершает сборку до запуска контейнеров, поэтому
одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб
запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой
`image already exists`.
Для входа выбран SimpleAuthManager. У него нет команды создания пользователя, Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,
поэтому `airflow-init` записывает пароль администратора из окружения в поэтому `airflow-init` записывает пароль администратора из окружения в
JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит
+3 -3
View File
@@ -137,9 +137,9 @@ ODS и ещё не собранный в DDS, затем при необходи
## Не входит ## Не входит
- Партиционная переобработка — переиграть произвольный прошлый день или - Партиционная переобработка — переиграть произвольный прошлый день или
диапазон, включая форму необязательных параметров того же дага, — этап 5 диапазон, включая форму необязательных параметров того же дага.
(#7); туда же стык #111 про дату модельного мира и `logical_date`. - Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной
- Инкрементальный ETL вместо пересборки — техдолг #2. лабы на эту тему — #149.
- Состав витрин `dm_transform` и проверок `dq_check` — - Состав витрин `dm_transform` и проверок `dq_check` —
[дока слоя DM](dm.md). [дока слоя DM](dm.md).
+4 -2
View File
@@ -21,8 +21,10 @@ ClickHouse, два подготовленных источника данных,
| `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` | | `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` |
| `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` | | `kafka-exporter` | `danielqsj/kafka-exporter:v1.9.0` | переводит состояние брокера в метрики Prometheus, порт `9308` |
Плагин ClickHouse 4.20.0 входит в образ Grafana. Фоновая установка плагинов Плагин ClickHouse 4.20.0 входит в образ Grafana. При сборке Grafana CLI берёт
при запуске отключена. закреплённый выпуск из официального репозитория на GitHub, а не из каталога
Grafana: прямой запрос к каталогу при проверке вернул HTTP 451. Фоновая
установка плагинов при запуске отключена.
## Кого опрашивает Prometheus ## Кого опрашивает Prometheus
+27 -5
View File
@@ -34,17 +34,18 @@
спрашивают его отдельно. спрашивают его отдельно.
Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено».
`make config-test`, `make smoke` и `make check-services` перемерены 21 августа `make smoke` и `make check-services` перемерены 28 августа 2026 года,
2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` — `make config-test` — 21 августа, `make check-clickhouse` — 19 августа,
9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа. корневой `make lint` — 9 августа, `make test` — 7 августа; остальные стоят с
замера 6 августа.
| Цель | Откуда | Что утверждает | Стенд | Цена | | Цель | Откуда | Что утверждает | Стенд | Цена |
|---|---|---|---|---| |---|---|---|---|---|
| `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | | `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с |
| `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с |
| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с | | `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с |
| `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с | | `make check-clickhouse` | корень | Всё, что спрашивают **у ClickHouse** и он отвечает сам: макросы, шарды, реплики, путь в keeper, ключ шардирования, очередь распределённых DDL, ответ словаря товаров, счёт событий стартового мира против описи | нужен | 24 с |
| `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 93 с | | `make check-services` | корень | **Службы работают**: DAG запускается и доходит, топик создаётся и удаляется, Superset логинится и ходит в базу | нужен | 71 с |
| `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с | | `make lint` | `generator/` | Код генератора отформатирован и проходит ruff | не нужен | 0,4 с |
| `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | | `make typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с |
| `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с | | `make test` | `generator/` | Генератор делает то, что обещает; схема события остаётся объявленным контрактом, а собранные из кода [описание выгрузки](../formats/clickstream-event.md) и [опись мира](../../data/world-inventory.json) — свежими | не нужен | 71 с |
@@ -237,6 +238,27 @@ Kafka → STG → ODS, и живёт он в `make check-clickhouse`.
## Что проверено ## Что проверено
**Приемка #172 28 августа 2026 года.** Холодный `create_app()` занимал
28,3-28,4 секунды, а чтение подключения после запуска - 0,07 секунды. Предел
30 секунд почти целиком уходил на запуск второго приложения. Увеличивать его
не стали: Superset не обещает постоянного времени `create_app()`.
После входа проверка делает три GET-запроса к уже работающему Superset: находит
`ClickHouse`, сверяет UUID и безопасный URI, затем требует схему `dm`. Так без
второго запуска проверяются сохраненное подключение и хранилище пароля. Форму
API сверили через Context7 и с исходным кодом Superset 6.1.0. `superset test-db`
отклонен, потому что тоже запускает приложение; прямой SQL к `dbs` не проверяет
соединение.
Проверка больше не подтверждает, что сам Superset использует Postgres для
метаданных. Короткий запрос к Postgres показывает лишь готовность базы, а REST
API - подключение к ClickHouse. Для `check-services` этого достаточно.
После перезапуска Superset три прогона прошли 7/7 за 71, 67 и 70 секунд.
`make config-test` прошел. `make smoke` дал 21/21 за 25 секунд; его код не
менялся, прежний замер был 17 секунд. Неверный UUID дал 5/1: красной стала
только проверка метаданных, пароль в вывод не попал.
**Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут. **Приемка #156 27 августа 2026 года.** `make rebuild-storage` занял 7 минут.
На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался
`etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли `etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли
+3 -1
View File
@@ -6,6 +6,8 @@ FROM grafana/grafana:13.1.1
USER root USER root
ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \ ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \
GF_PLUGINS_PREINSTALL_DISABLED=true GF_PLUGINS_PREINSTALL_DISABLED=true
ARG CLICKHOUSE_PLUGIN_VERSION=4.20.0
RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \ RUN grafana cli --pluginsDir "$GF_PATHS_PLUGINS" \
plugins install grafana-clickhouse-datasource 4.20.0 --pluginUrl "https://github.com/grafana/clickhouse-datasource/releases/download/v${CLICKHOUSE_PLUGIN_VERSION}/grafana-clickhouse-datasource-${CLICKHOUSE_PLUGIN_VERSION}.zip" \
plugins install grafana-clickhouse-datasource "$CLICKHOUSE_PLUGIN_VERSION"
USER grafana USER grafana
+51 -23
View File
@@ -237,35 +237,42 @@ check_airflow_probes() {
} }
check_superset() { check_superset() {
local clickhouse_password local access_token
local available_schemas
local clickhouse_databases
local config local config
local connection_count
local connection_details
local connection_id
local database_list
local login local login
local metadata_tables local metadata_tables
local password local password
local port local port
local metadata_engine local schemas
local stored_config local stored_safe_uri
local stored_uri
local stored_uuid local stored_uuid
local superset_password local superset_password
local superset_user local superset_user
local superset_url
local user local user
config="$(compose config --format json 2>/dev/null || true)" config="$(compose config --format json 2>/dev/null || true)"
user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")" user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // empty' <<<"$config")"
password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")" password="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_PASSWORD // empty' <<<"$config")"
clickhouse_password="$(jq -r '.services.superset.environment.CLICKHOUSE_BI_PASSWORD // empty' <<<"$config")"
superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")" superset_user="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_USER // empty' <<<"$config")"
superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")" superset_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")"
port="$(published_port superset 8088)" port="$(published_port superset 8088)"
superset_url="http://127.0.0.1:${port}"
login="$(curl -sf --max-time 10 -X POST \ login="$(curl -sf --max-time 10 -X POST \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-d "$(jq -cn --arg username "$user" --arg password "$password" \ -d "$(jq -cn --arg username "$user" --arg password "$password" \
'{username: $username, password: $password, provider: "db", refresh: true}')" \ '{username: $username, password: $password, provider: "db", refresh: true}')" \
"http://127.0.0.1:${port}/api/v1/security/login" 2>/dev/null || true)" "${superset_url}/api/v1/security/login" 2>/dev/null || true)"
if curl -sf --max-time 10 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && \ access_token="$(jq -r '.access_token // empty' <<<"$login")"
jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then if curl -sf --max-time 10 "${superset_url}/health" >/dev/null 2>&1 && \
[[ -n "$access_token" ]]; then
pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора' pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора'
else else
fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора' fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора'
@@ -276,28 +283,49 @@ check_superset() {
psql -U "$superset_user" -d superset -tAc \ psql -U "$superset_user" -d superset -tAc \
"SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \
2>/dev/null || true)" 2>/dev/null || true)"
stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \
exec -T superset python -c \ # Superset уже собрал приложение. Его REST API читает ту же сохранённую
"from superset.app import create_app; app = create_app(); app.app_context().push(); from superset.extensions import db; from superset.models.core import Database; url = db.engine.url; database = db.session.query(Database).filter_by(database_name='ClickHouse').one(); print('SMOKE_METADATA=' + '|'.join([url.get_backend_name(), url.username or '', url.host or '', str(url.port or ''), url.database or ''])); print('SMOKE_UUID=' + str(database.uuid)); print('SMOKE_URI=' + database.sqlalchemy_uri_decrypted)" \ # модель Database без второго тяжёлого запуска Python.
2>/dev/null)" database_list="$(curl -sf --max-time 10 \
metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)" -H "Authorization: Bearer ${access_token}" \
stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)" "${superset_url}/api/v1/database/" 2>/dev/null || true)"
stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)" clickhouse_databases="$(jq -c \
'[.result[]? | select(.database_name == "ClickHouse")]' \
<<<"$database_list")"
connection_count="$(jq -r 'length' <<<"$clickhouse_databases")"
connection_id="$(jq -r 'if length == 1 then .[0].id else empty end' \
<<<"$clickhouse_databases")"
stored_uuid="$(jq -r 'if length == 1 then .[0].uuid else empty end' \
<<<"$clickhouse_databases")"
connection_details=''
if [[ -n "$connection_id" ]]; then
connection_details="$(curl -sf --max-time 10 \
-H "Authorization: Bearer ${access_token}" \
"${superset_url}/api/v1/database/${connection_id}/connection" \
2>/dev/null || true)"
fi
stored_safe_uri="$(jq -r '.result.sqlalchemy_uri // empty' \
<<<"$connection_details")"
if [[ "$metadata_tables" == '1' ]] && \ if [[ "$metadata_tables" == '1' ]] && \
[[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \
[[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \ [[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \
[[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then [[ "$stored_safe_uri" == 'clickhousedb://bi@clickhouse-02:8123/default' ]]; then
pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02' pass 'Postgres для метаданных Superset подготовлен, подключение указывает на clickhouse-02'
else else
fail "Superset не подтвердил Postgres и подготовленное подключение bi к ноде 2: таблицы=${metadata_tables:-нет}, движок=${metadata_engine:-нет}, UUID=${stored_uuid:-нет}" fail "Postgres или подключение Superset не подготовлены: таблицы=${metadata_tables:-нет}, подключений=${connection_count:-нет}, UUID=${stored_uuid:-нет}, URI=${stored_safe_uri:-нет}"
return return
fi fi
if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ schemas="$(curl -sf --max-time 10 \
exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then -H "Authorization: Bearer ${access_token}" \
pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse' "${superset_url}/api/v1/database/${connection_id}/schemas/" \
2>/dev/null || true)"
available_schemas="$(jq -r '[.result[]?] | join(", ")' <<<"$schemas")"
if jq -e '.result | index("dm") != null' >/dev/null 2>&1 <<<"$schemas"; then
pass 'сохранённое подключение Superset видит схему витрин dm'
else else
fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse' fail "сохранённое подключение Superset не видит схему витрин dm: схемы=${available_schemas:-нет}"
fi fi
} }