Systematic rename of all semantic anchors (#region, [DEF], @RELATION) across 1400+ files — backend Python, frontend Svelte/TS, specs, docs: - Flat anchors become Namespace.Module.Entity - @RELATION references updated to match new anchor paths - Zero business logic changes
14 KiB
[DEF:Doc.Adr.ADR0019:ADR]
@STATUS ACCEPTED
@BRIEF Импорт дашбордов Superset: механизм трансформации UUID БД, cross-filter patching, password injection, и разделение dry-run/execute.
@RELATION BINDS_TO -> [Plugin.Migration.MigrationPlugin]
@RELATION BINDS_TO -> [Core.MigrationEngine]
@RELATION DEPENDS_ON -> [Doc.Adr.ADR0003:ADR]
@RELATION DEPENDS_ON -> [Doc.Adr.ADR0016:ADR]
@RATIONALE Механизм миграции дашбордов Superset между окружениями — ключевой компонент системы. Принятые решения о трансформации ZIP-архивов, обработке паролей БД, cross-filter patching, и разделении dry-run/execute сформировались через несколько итераций отладки production-сбоев. Этот ADR фиксирует финальную архитектуру и отклонённые альтернативы.
@REJECTED strip_databases=True — вызывает каскадный сбой (пустой database_ids → пропуск всех datasets → пропуск всех charts → update_id_refs KeyError → error 1010).
@REJECTED Передача add_log_callback из плагина в TaskManager.await_input() — менеджер сам управляет логированием через self._add_log; внешняя передача ломает сигнатуру метода.
@REJECTED Создание MigrationEngine без mapping_service в dry-run — лишает cross-filter patching возможности работать.
Контекст
superset-tools мигрирует дашборды между окружениями Superset (dev → preprod → prod).
Процесс: экспорт дашборда как ZIP-архива из источника → трансформация содержимого
(ZIP-архив содержит YAML-файлы datasets, databases, dashboards) → импорт в целевой Superset.
Ключевые вызовы:
- Безопасность: дашборд не должен импортироваться с исходными (source) подключениями к БД — это привело бы к утечке данных между окружениями.
- Целостность: имена файлов БД в архиве могут содержать точки (например,
Dev_Clickhouse_Node_1.yaml), что усложняет парсинг имён при обработке ошибок. - Отказоустойчивость: при отсутствии пароля БД в целевом окружении система должна запросить его у пользователя и повторить импорт, а не падать фатально.
- Pre-flight анализ: dry-run должен вычислять diff и риски без мутации целевого окружения, но с полноценной трансформацией архивов, включая cross-filter patching.
Decision 1: UUID-трансформация БД вместо удаления databases/ из архива
Как работает механизм
Superset идентифицирует базы данных по UUID. При импорте архива Superset
вызывает import_database() для каждого YAML-файла в databases/. Если UUID
совпадает с существующей БД в целевом окружении, Superset пропускает создание
и заполняет database_ids — благодаря этому datasets и charts корректно
привязываются к существующим БД без запроса пароля.
MigrationEngine._transform_database_yaml() заменяет UUID исходной БД на UUID
целевой БД согласно переданному db_mapping. Файлы БД, для которых нет маппинга,
остаются без изменений (логируется keeping as-is).
Почему не strip_databases=True
При strip_databases=True из архива удаляется вся директория databases/.
Superset при импорте не находит ни одной БД → database_ids остаётся пустым →
все datasets пропускаются (нет связи с БД) → все charts пропускаются (нет datasets) →
update_id_refs(chart_ids={}, dataset_info={}) падает с KeyError →
GENERIC_COMMAND_ERROR 1010.
Решение: strip_databases=False всегда. UUID-трансформация — единственный
корректный путь.
Обработка отсутствующего пароля
Если целевая БД существует по UUID, но для неё не сохранён пароль, Superset
возвращает ошибку: "Must provide a password for the database". Плагин
перехватывает эту ошибку и запускает поток password injection (см. Decision 3).
Decision 2: Cross-filter patching через IdMappingService
Проблема
Дашборды Superset могут содержать cross-filter ссылки (в json_metadata), которые
ссылаются на chart_id других дашбордов. При миграции chart_id меняются (целевой
Superset назначает новые ID). Без патчинга cross-filters ссылаются на несуществующие
или чужие charts.
Решение
MigrationEngine._patch_dashboard_metadata() заменяет source chart_id на target
chart_id во всех dashboard YAML-файлах архива. Для этого требуется:
self.mapping_service(реализуетIdMappingService) — выполняет batch-запросы для получения target ID по source UUID.target_env_id— идентификатор целевого окружения для поиска в mapping-таблицах.
Управляется флагом fix_cross_filters (по умолчанию True).
Исправление dry-run (2026-07-16)
В MigrationDryRunService.run() движок создавался без mapping_service:
engine = MigrationEngine() # ← mapping_service=None
Из-за этого cross-filter patching молча пропускался в dry-run, производя неполный pre-flight анализ. Исправлено:
engine = MigrationEngine(mapping_service=IdMappingService(db))
Decision 3: Password injection flow (await_input → wait_for_input → retry)
Проблема
При импорте дашборда может потребоваться пароль БД, который не хранится в системе. Плагин должен запросить пароль у пользователя через UI, дождаться ввода, и повторить импорт с паролем — без падения всей задачи.
Решение
Поток password injection реализован в MigrationPlugin.execute():
1. export_dashboard(dash_id) — экспорт из источника
2. transform_zip(...) — трансформация UUID + cross-filters
3. import_dashboard(...) — импорт в цель
↓ ошибка "Must provide a password"
4. tm.await_input(task_id, { — пауза задачи, статус AWAITING_INPUT
type: "database_password",
databases: [db_name],
error_message: "..."
})
5. tm.wait_for_input(task_id) — блокировка до ответа пользователя
6. task.params["passwords"] — пользователь ввёл пароли через UI
7. import_dashboard(..., passwords=...) — повторный импорт с паролями
8. task.params.pop("passwords") — очистка паролей из памяти
Парсинг имени БД из ошибки — два regex:
r"databases/([^.]+)\.yaml"— для форматаdatabases/Dev_Clickhouse_Node_1.yaml(извлекаетDev_Clickhouse_Node_1— до первой точки после/)r"database '([^']+)'"— для форматаdatabase 'PostgreSQL'- fallback:
"unknown"— если ни один regex не сработал
Исправление бага add_log_callback (2026-07-16)
Плагин передавал add_log_callback=add_log в tm.await_input(). Однако
TaskManager.await_input() принимает только (task_id, input_request) —
менеджер сам передаёт add_log_callback=self._add_log в lifecycle.await_input().
Внешняя передача ломала сигнатуру → TypeError → фатальный сбой задачи.
Исправление: убран add_log_callback из вызова плагина. Строка add_log = context._logger._add_log if context else None удалена как мёртвый код.
Decision 4: Разделение dry-run и execute
Архитектура
| Компонент | Назначение | Мутация цели |
|---|---|---|
MigrationDryRunService |
Pre-flight: export → transform → extract objects → diff → risk score | ❌ read-only |
MigrationPlugin.execute() |
Полный цикл: export → transform → import → ID sync | ✅ запись |
MigrationDryRunService использует MigrationArchiveParser для извлечения
объектов (dashboards, charts, datasets) из трансформированного ZIP, сравнивает
их с целевым окружением через _build_target_signatures(), вычисляет diff
(create/update/delete) и риски.
MigrationPlugin.execute() выполняет реальный импорт с обработкой ошибок,
password injection, повторными попытками после wait_for_resolution, и
финальной синхронизацией ID-маппингов через IdMappingService.sync_environment().
Почему не объединено
Объединение dry-run и execute в один код-путь создало бы риск случайной мутации целевого окружения при pre-flight анализе. Разделение гарантирует, что dry-run никогда не выполняет запись.
Decision 5: Парсинг имён YAML-файлов БД
Regex для путей БД в ошибках Superset извлекает databases/<file>.yaml.
UI показывает short name (basename без .yaml); при retry ключи нормализуются
обратно в path format.
Отклонённая альтернатива: r"databases/([^.]+)\.yaml" alone as password map
key — Superset load_configs matches full path after remove_root, not short names.
Decision 6: Per-dashboard isolation + structured errors + password keys
Batch isolation
Ошибка одного дашборда (export / transform / import / password-retry) не
прерывает цикл. Запись добавляется в failed_dashboards, остальные дашборды
продолжают обрабатываться. Итог: status=PARTIAL_SUCCESS при любых failures.
Исключения не покидают per-dashboard handler (кроме глобальных: env resolve, client init).
Password-retry выполняется внутри lifetime temp ZIP (не после выхода из
create_temp_file), иначе архив уже удалён.
Structured Superset errors
format_superset_import_error() разбирает SIP-40 payload:
{"errors":[{"message":"...","error_type":"GENERIC_COMMAND_ERROR","extra":{...}}]}
failed_dashboards[] содержит: error, error_detail?, error_type?,
issue_codes?, phase, id, title — для UI-сводки (имена + тексты Superset).
Password keys
Superset API: {"databases/MyDatabase.yaml": "password"}.
UI/short names нормализуются через normalize_superset_passwords() перед
import_dashboard(..., passwords=...).
@REJECTED Abort entire migration task on one dashboard import error.
@REJECTED Password map keys as short DB names without databases/*.yaml prefix.
Последствия
-
UUID-трансформация обязательна. Каждый вызов
transform_zip()должен получать актуальныйdb_mapping. Пустой mapping допустим только когдаreplace_db_config=False. -
Cross-filter patching требует mapping_service. И в dry-run, и в execute
MigrationEngineдолжен создаваться сmapping_service. Без него cross-filter ссылки останутся несогласованными. -
await_input не принимает add_log_callback извне. Любой плагин, вызывающий
tm.await_input(), должен передавать только(task_id, input_request). Логирование обрабатывается менеджером внутренне. -
Пароли не персистятся.
task.params["passwords"]существуют только в памяти во время выполнения задачи и удаляются сразу после успешного импорта или failed password-retry. -
Dry-run иммутабелен.
MigrationDryRunServiceникогда не вызывает методы, мутирующие целевой Superset. Это проверяемо: единственные вызовы к target — read-only (get_dashboards,get_datasets,get_databases). -
Per-dashboard isolation. Сбой одного дашборда →
failed_dashboards+ продолжение batch; задача не падает fatal из-за import error. -
Password keys = archive paths. Перед retry всегда
normalize_superset_passwords.