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
244 lines
14 KiB
Markdown
244 lines
14 KiB
Markdown
# [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-файлах архива. Для этого требуется:
|
||
|
||
1. `self.mapping_service` (реализует `IdMappingService`) — выполняет batch-запросы
|
||
для получения target ID по source UUID.
|
||
2. `target_env_id` — идентификатор целевого окружения для поиска в mapping-таблицах.
|
||
|
||
Управляется флагом `fix_cross_filters` (по умолчанию `True`).
|
||
|
||
### Исправление dry-run (2026-07-16)
|
||
|
||
В `MigrationDryRunService.run()` движок создавался без `mapping_service`:
|
||
```python
|
||
engine = MigrationEngine() # ← mapping_service=None
|
||
```
|
||
Из-за этого cross-filter patching молча пропускался в dry-run, производя неполный
|
||
pre-flight анализ. Исправлено:
|
||
```python
|
||
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:
|
||
|
||
```json
|
||
{"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.
|
||
|
||
---
|
||
|
||
## Последствия
|
||
|
||
1. **UUID-трансформация обязательна.** Каждый вызов `transform_zip()` должен
|
||
получать актуальный `db_mapping`. Пустой mapping допустим только когда
|
||
`replace_db_config=False`.
|
||
|
||
2. **Cross-filter patching требует mapping_service.** И в dry-run, и в execute
|
||
`MigrationEngine` должен создаваться с `mapping_service`. Без него
|
||
cross-filter ссылки останутся несогласованными.
|
||
|
||
3. **await_input не принимает add_log_callback извне.** Любой плагин, вызывающий
|
||
`tm.await_input()`, должен передавать только `(task_id, input_request)`.
|
||
Логирование обрабатывается менеджером внутренне.
|
||
|
||
4. **Пароли не персистятся.** `task.params["passwords"]` существуют только в
|
||
памяти во время выполнения задачи и удаляются сразу после успешного импорта
|
||
или failed password-retry.
|
||
|
||
5. **Dry-run иммутабелен.** `MigrationDryRunService` никогда не вызывает методы,
|
||
мутирующие целевой Superset. Это проверяемо: единственные вызовы к target —
|
||
read-only (`get_dashboards`, `get_datasets`, `get_databases`).
|
||
|
||
6. **Per-dashboard isolation.** Сбой одного дашборда → `failed_dashboards` +
|
||
продолжение batch; задача не падает fatal из-за import error.
|
||
|
||
7. **Password keys = archive paths.** Перед retry всегда
|
||
`normalize_superset_passwords`.
|
||
|
||
# [/DEF:Doc.Adr.ADR0019:ADR]
|