fix(superset): stabilize bootstrap after clean reset

- make superset-init run via dedicated init service\n- tolerate missing dm views during early metadata refresh\n- add clickhouse dependency for init service\n- document clean-reset behavior and re-init flow
This commit is contained in:
2026-02-11 09:48:56 +03:00
parent 7819af8688
commit ec6111d260
5 changed files with 58 additions and 9 deletions
+3 -1
View File
@@ -69,7 +69,9 @@ recover-monitoring:
# Инициализация Superset (подключение к ClickHouse + датасеты) # Инициализация Superset (подключение к ClickHouse + датасеты)
superset-init: superset-init:
$(COMPOSE) exec -T superset bash -c "python /app/superset_init/init_superset.py" $(COMPOSE) up -d postgres-metadata clickhouse
$(COMPOSE) up --abort-on-container-exit --exit-code-from superset-init superset-init
$(COMPOSE) up -d --no-deps superset
# Создание дашборда с чартами # Создание дашборда с чартами
superset-dashboard: superset-dashboard:
+3 -2
View File
@@ -75,7 +75,7 @@ docker compose exec -T clickhouse clickhouse-client --user=default --password=12
| Prometheus | http://localhost:9090 | Метрики | — | | Prometheus | http://localhost:9090 | Метрики | — |
| Grafana | http://localhost:3000 | Визуализация метрик | admin/admin | | Grafana | http://localhost:3000 | Визуализация метрик | admin/admin |
Superset: после `make up` готовый дашборд доступен по адресу http://localhost:8088/superset/dashboard/1/ Superset: UI доступен после `make up` по адресу http://localhost:8088, дашборд — http://localhost:8088/superset/dashboard/1/
--- ---
@@ -280,7 +280,7 @@ make superset-dashboard
``` ```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Superset UI │────▶│ PostgreSQL │───▶│ ClickHouse │ │ Superset UI │────▶│ PostgreSQL │───▶│ ClickHouse │
│ (localhost) │ │ (metadata) │ │ (данные) │ │ (localhost) │ │ (metadata) │ │ (данные) │
│ :8088 │ │ dashboards, │ │ dm.v_* VIEW │ │ :8088 │ │ dashboards, │ │ dm.v_* VIEW │
└─────────────────┘ │ datasets, charts│ └─────────────────┘ └─────────────────┘ │ datasets, charts│ └─────────────────┘
@@ -324,6 +324,7 @@ make superset-dashboard
- Airflow (ClickHouseOperator) ходит в ClickHouse по native TCP (порт `9000` внутри сети Docker). - Airflow (ClickHouseOperator) ходит в ClickHouse по native TCP (порт `9000` внутри сети Docker).
- Superset (clickhouse-connect) ходит по HTTP (порт `8123` внутри сети Docker). - Superset (clickhouse-connect) ходит по HTTP (порт `8123` внутри сети Docker).
- **Superset**: дашборд не появился сразу — подождите 30-60 секунд после `make up`, затем проверьте `curl http://localhost:8088/health`. - **Superset**: дашборд не появился сразу — подождите 30-60 секунд после `make up`, затем проверьте `curl http://localhost:8088/health`.
- **Superset**: после `make clean` витрины `dm.*` ещё не созданы, поэтому чарты могут быть пустыми до запуска `ddl_init -> kafka_load -> etl_pipeline`; после этого выполните `make superset-init`.
- **Superset**: при полном сбросе (`docker compose down -v`) метаданные Superset пропадут т.к. используется общая PostgreSQL. Для чистого перезапуска Superset удалите только БД `superset` в PostgreSQL и перезапустите контейнеры. - **Superset**: при полном сбросе (`docker compose down -v`) метаданные Superset пропадут т.к. используется общая PostgreSQL. Для чистого перезапуска Superset удалите только БД `superset` в PostgreSQL и перезапустите контейнеры.
--- ---
+2
View File
@@ -180,6 +180,8 @@ services:
depends_on: depends_on:
postgres-metadata: postgres-metadata:
condition: service_healthy condition: service_healthy
clickhouse:
condition: service_started
prometheus: prometheus:
image: prom/prometheus:v2.53.4 image: prom/prometheus:v2.53.4
+2
View File
@@ -12,6 +12,7 @@
- `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse) - `make ddl` (применяет SQL из `sql/ddl/00_databases.sql` и `sql/ddl/*/*.sql` в ClickHouse)
- `make data` (пересоздаёт топики и заливает небольшой срез данных в Kafka; полный режим — `FULL=1 make data`) - `make data` (пересоздаёт топики и заливает небольшой срез данных в Kafka; полный режим — `FULL=1 make data`)
- `make transform` (запускает batch-процесс ODS -> DDS -> DM) - `make transform` (запускает batch-процесс ODS -> DDS -> DM)
- `make superset-init` (повторная инициализация Superset: подключение к ClickHouse, датасеты, дашборд)
- `docker compose ps` - `docker compose ps`
- `docker compose logs -f --tail=200 <service>` - `docker compose logs -f --tail=200 <service>`
- `docker compose down` (сохраняет named volumes, включая `clickhouse-data`) - `docker compose down` (сохраняет named volumes, включая `clickhouse-data`)
@@ -302,4 +303,5 @@ curl -s -X POST -u admin:admin http://localhost:3000/api/admin/provisioning/aler
docker compose up -d --force-recreate superset-init superset docker compose up -d --force-recreate superset-init superset
``` ```
- После `docker compose down -v` нужно повторно прогнать: `ddl_init` -> `kafka_load` -> `etl_pipeline`. - После `docker compose down -v` нужно повторно прогнать: `ddl_init` -> `kafka_load` -> `etl_pipeline`.
- После `make clean`/`down -v` Superset стартует, но витрины `dm.*` ещё пустые или отсутствуют до прогона ETL; после `ddl_init` -> `kafka_load` -> `etl_pipeline` выполнить `make superset-init`.
- Для демо по умолчанию использовать малый срез данных; полный прогон делать осознанно. - Для демо по умолчанию использовать малый срез данных; полный прогон делать осознанно.
+42
View File
@@ -29,6 +29,7 @@ import os
import logging import logging
import subprocess import subprocess
from urllib.parse import quote_plus from urllib.parse import quote_plus
from sqlalchemy.exc import NoSuchTableError
# Настройка логирования # Настройка логирования
logging.basicConfig( logging.basicConfig(
@@ -47,6 +48,27 @@ CLICKHOUSE_PORT = os.getenv('CLICKHOUSE_HTTP_PORT', '8123')
CLICKHOUSE_DATABASE = os.getenv('CLICKHOUSE_DATABASE', 'default') CLICKHOUSE_DATABASE = os.getenv('CLICKHOUSE_DATABASE', 'default')
def is_expected_missing_table_error(exc: Exception, schema_name: str, table_name: str) -> bool:
"""Проверяет, что ошибка связана с отсутствием таблицы/вьюхи в ClickHouse."""
if isinstance(exc, NoSuchTableError):
return True
text = str(exc).strip().lower()
full_table_name = f"{schema_name}.{table_name}".lower()
known_markers = (
"doesn't exist",
"does not exist",
"unknown table",
"no such table",
"code: 60",
)
if text == full_table_name:
return True
return full_table_name in text and any(marker in text for marker in known_markers)
def build_clickhouse_uri() -> str: def build_clickhouse_uri() -> str:
"""Собирает URI подключения к ClickHouse для Superset.""" """Собирает URI подключения к ClickHouse для Superset."""
user = quote_plus(CLICKHOUSE_USER) user = quote_plus(CLICKHOUSE_USER)
@@ -143,6 +165,17 @@ def import_datasets():
"from superset.extensions import db", "from superset.extensions import db",
"from superset.models.core import Database", "from superset.models.core import Database",
"from superset.connectors.sqla.models import SqlaTable", "from superset.connectors.sqla.models import SqlaTable",
"from sqlalchemy.exc import NoSuchTableError",
"",
"def is_expected_missing_table_error(exc, schema_name, table_name):",
" text = str(exc).strip().lower()",
" full_table_name = f'{schema_name}.{table_name}'.lower()",
" known_markers = ('doesn\\'t exist', 'does not exist', 'unknown table', 'no such table', 'code: 60')",
" if isinstance(exc, NoSuchTableError):",
" return True",
" if text == full_table_name:",
" return True",
" return full_table_name in text and any(marker in text for marker in known_markers)",
"", "",
"# Получаем базу данных", "# Получаем базу данных",
"database = db.session.query(Database).filter_by(database_name='clickhouse_dwh').first()", "database = db.session.query(Database).filter_by(database_name='clickhouse_dwh').first()",
@@ -179,6 +212,10 @@ def import_datasets():
" else:", " else:",
f" print('Dataset {table_name} already exists')", f" print('Dataset {table_name} already exists')",
" except Exception as e:", " except Exception as e:",
f" if is_expected_missing_table_error(e, {schema_name!r}, {table_name!r}):",
f" print('WARNING: metadata not refreshed for {table_name}: source table/view not found yet')",
" db.session.rollback()",
" else:",
f" print(f'ERROR: failed to refresh {table_name}: {{e}}')", f" print(f'ERROR: failed to refresh {table_name}: {{e}}')",
" db.session.rollback()", " db.session.rollback()",
" errors += 1", " errors += 1",
@@ -200,6 +237,11 @@ def import_datasets():
f" print('Created dataset: {table_name}')", f" print('Created dataset: {table_name}')",
" imported += 1", " imported += 1",
" except Exception as e:", " except Exception as e:",
f" if is_expected_missing_table_error(e, {schema_name!r}, {table_name!r}):",
" db.session.commit()",
f" print('WARNING: created dataset {table_name} without metadata: source table/view not found yet')",
" imported += 1",
" else:",
f" print(f'ERROR: failed to create {table_name}: {{e}}')", f" print(f'ERROR: failed to create {table_name}: {{e}}')",
" db.session.rollback()", " db.session.rollback()",
" errors += 1", " errors += 1",