From c2c4401a166d49e1f05051319fb90a6c7810a672 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 28 Aug 2026 05:05:44 -0400 Subject: [PATCH 1/6] =?UTF-8?q?feat(airflow):=20=D0=BF=D1=83=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=20=D0=BC=D0=B8=D1=80=D0=B0=20=D0=BE=D0=B1=D1=8A=D1=8F?= =?UTF-8?q?=D1=81=D0=BD=D1=8F=D0=B5=D1=82=20=D0=BE=D1=81=D1=8C=20=D0=B4?= =?UTF-8?q?=D0=BD=D0=B5=D0=B9=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC=D0=B5=20?= =?UTF-8?q?=D0=B8=20=D0=B6=D1=83=D1=80=D0=BD=D0=B0=D0=BB=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - оператор принимал служебную logical_date Airflow за дату мира (#111), а документацию читают реже, чем форму запуска. - Что: - у параметра days появилась подсказка description_md, а в докстринги работников добавлена фраза: день определяет world_position, служебные поля Airflow к оси мира отношения не имеют. - remember_played пишет в журнал сыгранные дни и новую позицию. - README дополнен абзацем про служебные поля за «Advanced Options»; из etl.md сняты устаревшие стыки на закрытые #7 и #2. - Проверка: - make lint, make config-test; ручной прогон world_next_day зелёный, позиция 9 -> 10, в журнале «сыграны дни 9–9, позиция теперь 10». Co-Authored-By: Claude Fable 5 --- README.md | 6 ++++++ dags/world_control.py | 28 +++++++++++++++++++++++++--- docs/architecture/etl.md | 6 +++--- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7a3e676..4d68f95 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,12 @@ uv run --project generator python -m clickstream_generator batch \ `0` — очистка закончена, а начальная генерация ещё не завершена. Работники начинают играть только с позиции `8` и больше. +Форма запуска работника спрашивает только своё — сколько дней играть. +Служебные поля Airflow в ней — «Logical date» и «Run ID» за створкой +«Advanced Options» — к оси мира отношения не имеют: какой день пойдёт первым, +смотрите в `world_position`, а какой сыгран — в журнале шага +`remember_played`. + Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы целиком — [ADR 0009](docs/adr/0009-world-control.md). diff --git a/dags/world_control.py b/dags/world_control.py index e991c08..92a44e7 100644 --- a/dags/world_control.py +++ b/dags/world_control.py @@ -28,6 +28,7 @@ from __future__ import annotations import datetime +import logging import os from airflow.exceptions import AirflowException @@ -141,7 +142,19 @@ def _ingest_orders() -> TriggerDagRunOperator: tags=TAGS, params={ "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). Служебные поля Airflow — Logical date, Run " + "ID — к оси мира отношения не имеют." + ), ) }, ) @@ -149,14 +162,20 @@ def world_next_day(): """Прожить следующие дни пачкой, без пауз. Запускается руками. День по умолчанию один, но разгон вперёд идёт одним - нажимом, а не десятью: сколько дней играть — параметр запуска. + нажимом, а не десятью: сколько дней играть — параметр запуска. Какие + именно дни, знает переменная `world_position`, а не форма: логическая + дата прогона к оси мира отношения не имеет. """ @task def remember_played(first_day: int) -> None: """Позиция ставится по сыгранным дням и только по успеху.""" 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() played = _generator( @@ -196,12 +215,15 @@ def world_live_day(): Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре реальные минуты, и суточная волна разворачивается на глазах. Запускается руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. + Какой день пойдёт, знает переменная `world_position`: логическая дата + прогона к оси мира отношения не имеет. """ @task def remember_played(first_day: int) -> None: """Позиция ставится по сыгранному дню и только по успеху.""" Variable.set(WORLD_POSITION, str(first_day + 1)) + logging.info("сыгран день %s, позиция теперь %s", first_day, first_day + 1) first_day = first_unplayed_day() played = _generator("play_day", ["live", "--day", PLAYED_DAY]) diff --git a/docs/architecture/etl.md b/docs/architecture/etl.md index 2d755e7..295bf09 100644 --- a/docs/architecture/etl.md +++ b/docs/architecture/etl.md @@ -137,9 +137,9 @@ ODS и ещё не собранный в DDS, затем при необходи ## Не входит - Партиционная переобработка — переиграть произвольный прошлый день или - диапазон, включая форму необязательных параметров того же дага, — этап 5 - (#7); туда же стык #111 про дату модельного мира и `logical_date`. -- Инкрементальный ETL вместо пересборки — техдолг #2. + диапазон, включая форму необязательных параметров того же дага. +- Инкрементальный ETL вместо пересборки — отклонён (#2, wontfix); учебная + сторона темы — идея лабы #149. - Состав витрин `dm_transform` и проверок `dq_check` — [дока слоя DM](dm.md). From 17995d4c5e2f28c7b9cdab333c95aa88a5d38948 Mon Sep 17 00:00:00 2001 From: Dmitry Dementev Date: Fri, 28 Aug 2026 05:14:51 -0400 Subject: [PATCH 2/6] =?UTF-8?q?fix(airflow):=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B8=20=D0=BF=D1=83?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=B0=D0=BD=D1=8B=20=D0=B6=D0=B8=D0=B2=D1=8B=D0=BC=20?= =?UTF-8?q?=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - владелец на ревью PR #175 отметил кальку в подсказках; читать их будет менти, и язык здесь — часть продукта. - Что: - переписаны подсказка формы days, докстринги работников, строки журнала remember_played, абзац README и пункт в etl.md; смысл не менялся. - Проверка: - make lint, make config-test. Co-Authored-By: Claude Fable 5 --- README.md | 9 ++++----- dags/world_control.py | 24 ++++++++++++------------ docs/architecture/etl.md | 4 ++-- 3 files changed, 18 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 4d68f95..1885271 100644 --- a/README.md +++ b/README.md @@ -262,11 +262,10 @@ uv run --project generator python -m clickstream_generator batch \ `0` — очистка закончена, а начальная генерация ещё не завершена. Работники начинают играть только с позиции `8` и больше. -Форма запуска работника спрашивает только своё — сколько дней играть. -Служебные поля Airflow в ней — «Logical date» и «Run ID» за створкой -«Advanced Options» — к оси мира отношения не имеют: какой день пойдёт первым, -смотрите в `world_position`, а какой сыгран — в журнале шага -`remember_played`. +В форме запуска можно выбрать только число дней. Первый день работник берет из +`world_position`. Поля «Logical date» и «Run ID» спрятаны в «Advanced Options». +Они нужны самому Airflow и на выбор дня не влияют. Сыгранные дни и новую +позицию работник пишет в журнал шага `remember_played`. Выключатель создаётся на паузе. Снимите — мир поедет сам; поставите обратно — встанет на границе модельных суток, доиграв начатый день. Форма пульта и доводы diff --git a/dags/world_control.py b/dags/world_control.py index 92a44e7..7574731 100644 --- a/dags/world_control.py +++ b/dags/world_control.py @@ -148,12 +148,12 @@ def _ingest_orders() -> TriggerDagRunOperator: maximum=7, title="Сколько дней прожить", # description_md рендерится подсказкой прямо в форме запуска - # (params.rst, Airflow 3.3) — объяснение стоит там, где путают. + # (params.rst, Airflow 3.3), чтобы правило было видно сразу. description_md=( - "Какие именно дни — решает не форма: первый несыгранный " - "день хранит переменная `world_position` (Admin → " - "Variables). Служебные поля Airflow — Logical date, Run " - "ID — к оси мира отношения не имеют." + "Форма задает только число дней. Номер первого несыгранного " + "дня работник берет из переменной `world_position` (Admin → " + "Variables). Поля Logical date и Run ID нужны самому Airflow " + "и на выбор дня не влияют." ), ) }, @@ -162,9 +162,9 @@ def world_next_day(): """Прожить следующие дни пачкой, без пауз. Запускается руками. День по умолчанию один, но разгон вперёд идёт одним - нажимом, а не десятью: сколько дней играть — параметр запуска. Какие - именно дни, знает переменная `world_position`, а не форма: логическая - дата прогона к оси мира отношения не имеет. + нажимом, а не десятью: сколько дней играть — параметр запуска. Первый + день работник берет из `world_position`. Логическая дата прогона на выбор + дня не влияет. """ @task @@ -174,7 +174,7 @@ def world_next_day(): position = first_day + days Variable.set(WORLD_POSITION, str(position)) logging.info( - "сыграны дни %s–%s, позиция теперь %s", first_day, position - 1, position + "дни %s–%s сыграны; новая позиция — %s", first_day, position - 1, position ) first_day = first_unplayed_day() @@ -215,15 +215,15 @@ def world_live_day(): Ускорение ×60: модельные сутки укладываются примерно в двадцать четыре реальные минуты, и суточная волна разворачивается на глазах. Запускается руками; чтобы мир жил так день за днём сам, есть выключатель `world_live`. - Какой день пойдёт, знает переменная `world_position`: логическая дата - прогона к оси мира отношения не имеет. + Следующий день работник берет из `world_position`. Логическая дата прогона + на выбор дня не влияет. """ @task def remember_played(first_day: int) -> None: """Позиция ставится по сыгранному дню и только по успеху.""" Variable.set(WORLD_POSITION, str(first_day + 1)) - logging.info("сыгран день %s, позиция теперь %s", first_day, first_day + 1) + logging.info("сыгран день %s; новая позиция — %s", first_day, first_day + 1) first_day = first_unplayed_day() played = _generator("play_day", ["live", "--day", PLAYED_DAY]) diff --git a/docs/architecture/etl.md b/docs/architecture/etl.md index 295bf09..ceff7ff 100644 --- a/docs/architecture/etl.md +++ b/docs/architecture/etl.md @@ -138,8 +138,8 @@ ODS и ещё не собранный в DDS, затем при необходи - Партиционная переобработка — переиграть произвольный прошлый день или диапазон, включая форму необязательных параметров того же дага. -- Инкрементальный ETL вместо пересборки — отклонён (#2, wontfix); учебная - сторона темы — идея лабы #149. +- Инкрементальный ETL вместо пересборки отклонен (#2, wontfix). Идея отдельной + лабы на эту тему — #149. - Состав витрин `dm_transform` и проверок `dq_check` — [дока слоя DM](dm.md). From e422e6d1fafc38a69b298e68e950a5f4be70eb13 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Fri, 28 Aug 2026 12:31:48 +0300 Subject: [PATCH 3/6] =?UTF-8?q?fix(grafana):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B7=D0=B0=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=B7=D0=BA=D0=B0=20=D0=BF=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA?= =?UTF-8?q?=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - make up падал с HTTP 451 при загрузке плагина из каталога Grafana. - Что: - закреплённый выпуск плагина загружается из официального GitHub Releases. - версия плагина вынесена в один аргумент сборки, причина выбора описана в справочнике мониторинга. - Проверка: - docker build --progress=plain -t clickstream-grafana:local infra/grafana. - docker run --rm --entrypoint grafana clickstream-grafana:local cli --pluginsDir /usr/share/grafana/plugins plugins ls. - make config-test. --- docs/architecture/monitoring/README.md | 6 ++++-- infra/grafana/Dockerfile | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/architecture/monitoring/README.md b/docs/architecture/monitoring/README.md index 69a806d..baa6f19 100644 --- a/docs/architecture/monitoring/README.md +++ b/docs/architecture/monitoring/README.md @@ -21,8 +21,10 @@ ClickHouse, два подготовленных источника данных, | `grafana` | `clickstream-grafana:local` | интерфейс мониторинга; собирается из `infra/grafana/`, порт стенда `23000` | | `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 diff --git a/infra/grafana/Dockerfile b/infra/grafana/Dockerfile index d7600f1..265f0c2 100644 --- a/infra/grafana/Dockerfile +++ b/infra/grafana/Dockerfile @@ -6,6 +6,8 @@ FROM grafana/grafana:13.1.1 USER root ENV GF_PATHS_PLUGINS=/usr/share/grafana/plugins \ GF_PLUGINS_PREINSTALL_DISABLED=true +ARG CLICKHOUSE_PLUGIN_VERSION=4.20.0 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 From cc836fc3c5f4bf6b27c8a19f25d6204a30be57d8 Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Fri, 28 Aug 2026 12:31:36 +0300 Subject: [PATCH 4/6] =?UTF-8?q?fix(superset):=20=D1=81=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B1=D0=BE=D1=87=D0=B5=D0=B3=D0=BE=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=83=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - холодный запуск второго приложения Superset давал ложный красный результат исправного стенда. - Что: - тяжёлые `create_app()` и `superset test-db` заменены проверкой сохранённого подключения через REST API. - добавлены проверка схемы `dm`, безопасная диагностика и краткая запись приёмки с замерами. - Проверка: - `make config-test`. - `make smoke`: 21/21 за 25 секунд. - три `make check-services` после перезапуска Superset: 7/7 за 71, 67 и 70 секунд; финальный прогон — 7/7 за 70 секунд. - контрольный неверный UUID: 5/1, красной стала проверка метаданных. --- docs/architecture/testing.md | 32 +++++++++++++--- scripts/stand-services.sh | 74 +++++++++++++++++++++++++----------- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/docs/architecture/testing.md b/docs/architecture/testing.md index a53666f..9030594 100644 --- a/docs/architecture/testing.md +++ b/docs/architecture/testing.md @@ -34,17 +34,18 @@ спрашивают его отдельно. Стенд нужен трём целям из восьми. Цена — замер, см. «Что проверено». -`make config-test`, `make smoke` и `make check-services` перемерены 21 августа -2026 года, `make check-clickhouse` — 19 августа, корневой `make lint` — -9 августа, `make test` — 7 августа; остальные стоят с замера 6 августа. +`make smoke` и `make check-services` перемерены 28 августа 2026 года, +`make config-test` — 21 августа, `make check-clickhouse` — 19 августа, +корневой `make lint` — 9 августа, `make test` — 7 августа; остальные стоят с +замера 6 августа. | Цель | Откуда | Что утверждает | Стенд | Цена | |---|---|---|---|---| | `make config-test` | корень | Compose разбирается, файлы DAG и дашборды Grafana синтаксически целы, в diff нет пробельных ошибок. О работоспособности не говорит ничего | не нужен | 1 с | | `make lint` | корень | Код стенда — `dags/` и `infra/superset/` — отформатирован и проходит ruff | не нужен | 0,4 с | -| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 17 с | +| `make smoke` | корень | Стенд **собран**: службы живы, порты отвечают, подключения настроены друг на друга. Вширь и по касательной к каждой службе. Единственная цель, которая здесь правда смоук | нужен | 25 с | | `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 typecheck` | `generator/` | Типы генератора сходятся (ty) | не нужен | 0,5 с | | `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 минут. На чистом прикладном мире `world_initialize` сам довел позицию до `8`, дождался `etl_pipeline` и оставил заполненными DDS и обе витрины. После пересборки прошли diff --git a/scripts/stand-services.sh b/scripts/stand-services.sh index 205f627..9fc129c 100755 --- a/scripts/stand-services.sh +++ b/scripts/stand-services.sh @@ -237,35 +237,42 @@ check_airflow_probes() { } check_superset() { - local clickhouse_password + local access_token + local available_schemas + local clickhouse_databases local config + local connection_count + local connection_details + local connection_id + local database_list local login local metadata_tables local password local port - local metadata_engine - local stored_config - local stored_uri + local schemas + local stored_safe_uri local stored_uuid local superset_password local superset_user + local superset_url local user config="$(compose config --format json 2>/dev/null || true)" user="$(jq -r '.services.superset.environment.SUPERSET_ADMIN_USER // 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_password="$(jq -r '.services["postgres-metadata"].environment.SUPERSET_METADATA_PASSWORD // empty' <<<"$config")" port="$(published_port superset 8088)" + superset_url="http://127.0.0.1:${port}" login="$(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, provider: "db", refresh: true}')" \ - "http://127.0.0.1:${port}/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 && \ - jq -e '.access_token | length > 0' >/dev/null 2>&1 <<<"$login"; then + "${superset_url}/api/v1/security/login" 2>/dev/null || true)" + access_token="$(jq -r '.access_token // empty' <<<"$login")" + if curl -sf --max-time 10 "${superset_url}/health" >/dev/null 2>&1 && \ + [[ -n "$access_token" ]]; then pass 'интерфейс Superset отвечает и принимает подготовленные учётные данные администратора' else fail 'интерфейс Superset не отвечает или не принимает учётные данные администратора' @@ -276,28 +283,49 @@ check_superset() { psql -U "$superset_user" -d superset -tAc \ "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'ab_user';" \ 2>/dev/null || true)" - stored_config="$(timeout 30s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ - exec -T superset python -c \ - "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)" \ - 2>/dev/null)" - metadata_engine="$(sed -n 's/^SMOKE_METADATA=//p' <<<"$stored_config" | tail -n 1)" - stored_uuid="$(sed -n 's/^SMOKE_UUID=//p' <<<"$stored_config" | tail -n 1)" - stored_uri="$(sed -n 's/^SMOKE_URI=//p' <<<"$stored_config" | tail -n 1)" + + # Superset уже собрал приложение. Его REST API читает ту же сохранённую + # модель Database без второго тяжёлого запуска Python. + database_list="$(curl -sf --max-time 10 \ + -H "Authorization: Bearer ${access_token}" \ + "${superset_url}/api/v1/database/" 2>/dev/null || true)" + 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' ]] && \ - [[ "$metadata_engine" == "postgresql|${superset_user}|postgres-metadata|5432|superset" ]] && \ [[ "$stored_uuid" == '4b8f2c6e-1d3a-4f5b-9c7d-2e8a1f0b3c5d' ]] && \ - [[ "$stored_uri" == "clickhousedb://bi:${clickhouse_password}@clickhouse-02:8123/default" ]]; then - pass 'метаданные Superset живут в Postgres, подготовленное подключение указывает на clickhouse-02' + [[ "$stored_safe_uri" == 'clickhousedb://bi@clickhouse-02:8123/default' ]]; then + pass 'Postgres для метаданных Superset подготовлен, подключение указывает на clickhouse-02' 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 fi - if printf 'n\n' | timeout 60s "${COMPOSE_CMD[@]}" --project-directory "$ROOT_DIR" \ - exec -T superset superset test-db "$stored_uri" >/dev/null 2>&1; then - pass 'Superset успешно проверил извлечённое из метаданных подключение ClickHouse' + schemas="$(curl -sf --max-time 10 \ + -H "Authorization: Bearer ${access_token}" \ + "${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 - fail 'Superset не смог проверить извлечённое из метаданных подключение ClickHouse' + fail "сохранённое подключение Superset не видит схему витрин dm: схемы=${available_schemas:-нет}" fi } From 1f203c974d643f6460aa58430014e5984b302aef Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Fri, 28 Aug 2026 12:53:33 +0300 Subject: [PATCH 5/6] =?UTF-8?q?fix(compose):=20=D1=83=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B0=20=D0=B3=D0=BE=D0=BD=D0=BA=D0=B0?= =?UTF-8?q?=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B8=D1=85=20=D0=BE=D0=B1=D1=80=D0=B0=D0=B7=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - make up иногда падал, когда несколько служб параллельно экспортировали один тег образа. - Что: - сборка образов Airflow и Superset закреплена за службами инициализации. - зависимые службы только используют готовый образ, решение описано в ADR 0001. - Проверка: - make config-test. - docker compose --progress plain build superset superset-init airflow-scheduler airflow-init. - два запуска make up прошли экспорт трёх уникальных тегов без image already exists. --- compose.yaml | 11 +++++++---- docs/adr/0001-stand-services.md | 7 +++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/compose.yaml b/compose.yaml index 60a07de..73940ef 100644 --- a/compose.yaml +++ b/compose.yaml @@ -50,8 +50,6 @@ x-world-starting-days: &world-starting-days "8" x-airflow-common: &airflow-common image: clickstream-airflow:local - build: - context: ./infra/airflow environment: AIRFLOW__CORE__EXECUTOR: LocalExecutor AIRFLOW__CORE__PARALLELISM: "4" @@ -105,8 +103,6 @@ x-generator-common: &generator-common x-superset-common: &superset-common image: clickstream-superset:local - build: - context: ./infra/superset environment: SUPERSET_CONFIG_PATH: /app/pythonpath/superset_config.py SUPERSET_SECRET_KEY: ${SUPERSET_SECRET_KEY:?Скопируйте .env.example в .env} @@ -257,6 +253,10 @@ services: airflow-init: <<: *airflow-common + # Общий образ собирает один владелец. Остальные службы зависят от init и + # не запускают параллельный экспорт того же тега через Buildx. + build: + context: ./infra/airflow user: "0:0" restart: "no" depends_on: @@ -340,6 +340,9 @@ services: superset-init: <<: *superset-common + # Superset использует тот же образ после успешной подготовки. + build: + context: ./infra/superset restart: "no" healthcheck: disable: true diff --git a/docs/adr/0001-stand-services.md b/docs/adr/0001-stand-services.md index 3a92b5c..7fdc90c 100644 --- a/docs/adr/0001-stand-services.md +++ b/docs/adr/0001-stand-services.md @@ -16,6 +16,13 @@ возвращает единицу — поведение проверено при исполнении #37. Зависимость включает результат подготовки в общий граф ожидания. +Каждый общий локальный образ собирает одна служба: `airflow-init` владеет +сборкой образа Airflow, `superset-init` — образа Superset. Зависимые процессы +только используют готовый образ. `docker compose up --build` собирает образы +до запуска контейнеров; повторять `build` у каждого процесса не нужно, а +Buildx 0.30.1 при параллельном экспорте одного тега иногда отвечает +`image already exists`. + Для входа выбран SimpleAuthManager. У него нет команды создания пользователя, поэтому `airflow-init` записывает пароль администратора из окружения в JSON-файл отдельного тома `airflow_auth`. Новый именованный том принадлежит From cd4f9c55f591a6b1a36beba8dce4cb0a4e8fb6ad Mon Sep 17 00:00:00 2001 From: Dmitry Dementiev Date: Fri, 28 Aug 2026 13:03:47 +0300 Subject: [PATCH 6/6] =?UTF-8?q?docs(compose):=20=D0=BE=D0=B1=D1=8A=D1=8F?= =?UTF-8?q?=D1=81=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=B9=20=D1=81=D0=B1=D0=BE=D1=80=D0=BA=D0=B8=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D0=B0=D1=89=D0=B5=D0=BD=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Зачем: - ADR и комментарии повторяли устройство Compose и говорили о сборке безлично. - Что: - в Compose оставлен один указатель на причину решения. - ADR хранит правило общей сборки и объясняет гонку Buildx. - Проверка: - make config-test. - git diff --check. --- compose.yaml | 5 ++--- docs/adr/0001-stand-services.md | 10 +++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/compose.yaml b/compose.yaml index 73940ef..885699d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -48,6 +48,8 @@ x-kafka-target: &kafka-target x-world-starting-days: &world-starting-days "8" +# Общие образы Airflow и Superset собирают службы инициализации. Причина +# отдельного build: ADR 0001. x-airflow-common: &airflow-common image: clickstream-airflow:local environment: @@ -253,8 +255,6 @@ services: airflow-init: <<: *airflow-common - # Общий образ собирает один владелец. Остальные службы зависят от init и - # не запускают параллельный экспорт того же тега через Buildx. build: context: ./infra/airflow user: "0:0" @@ -340,7 +340,6 @@ services: superset-init: <<: *superset-common - # Superset использует тот же образ после успешной подготовки. build: context: ./infra/superset restart: "no" diff --git a/docs/adr/0001-stand-services.md b/docs/adr/0001-stand-services.md index 7fdc90c..9282dc0 100644 --- a/docs/adr/0001-stand-services.md +++ b/docs/adr/0001-stand-services.md @@ -16,11 +16,11 @@ возвращает единицу — поведение проверено при исполнении #37. Зависимость включает результат подготовки в общий граф ожидания. -Каждый общий локальный образ собирает одна служба: `airflow-init` владеет -сборкой образа Airflow, `superset-init` — образа Superset. Зависимые процессы -только используют готовый образ. `docker compose up --build` собирает образы -до запуска контейнеров; повторять `build` у каждого процесса не нужно, а -Buildx 0.30.1 при параллельном экспорте одного тега иногда отвечает +У каждого общего локального образа один блок `build`. Он находится у службы +инициализации. Остальные службы зависят от неё и используют тот же `image`. +`docker compose up --build` завершает сборку до запуска контейнеров, поэтому +одного блока достаточно. В Buildx 0.30.1 одинаковые блоки у нескольких служб +запускают параллельный экспорт одного тега. Иногда сборка завершается ошибкой `image already exists`. Для входа выбран SimpleAuthManager. У него нет команды создания пользователя,