Carried over from 042-dashboard-scenario-registry: - dashboard/migration backend changes + tests (dataset_key_sync) - specs updates; drop generated doxygen artifacts - research notes, integration artifacts, session log
43 KiB
DWH-14943 — UUID-authoritative импорт Dashboard и Dataset с безопасным reparenting
1. Цель задачи
Исправить импорт Dashboard и Dataset в Apache Superset так, чтобы:
-
UUID был единственным идентификатором сущности при определении, какую существующую строку нужно обновлять.
-
Изменение natural key при
overwrite=trueбыло штатной операцией. -
Для Dataset было разрешено свободно менять:
database;database_id;database_uuidчерез стандартный mapping импортера;catalog;schema;table_name.
-
Типовой сценарий разработки:
DEV database / DEV schema ↓ TEST database / TEST schema ↓ PROD database / PROD schemaне должен восприниматься как конфликт, если UUID Dataset остаётся прежним.
-
При этом импорт не должен позволять одному UUID захватить объект, принадлежащий другому UUID, только потому что совпал
slugили(database, catalog, schema, table_name). -
Natural key должен использоваться:
- НЕ для определения identity;
- а исключительно для проверки, что целевое состояние не принадлежит другой сущности.
2. Главный архитектурный принцип
Разделить два понятия:
Identity
Для Dashboard:
dashboard.uuid
Для Dataset:
dataset.uuid
UUID отвечает на вопрос:
Какую существующую сущность мы обновляем?
Никакие slug, database, schema, table_name, catalog не должны участвовать в выборе строки для UPDATE top-level Dashboard/Dataset.
Destination / natural key
Dashboard:
slug
Dataset:
database_id
catalog
schema
table_name
Natural key отвечает только на вопрос:
Можно ли присвоить выбранной по UUID сущности такое новое состояние, или оно уже принадлежит другой сущности?
То есть изменение natural key само по себе разрешено.
Ошибка должна возникать только если новое значение natural key уже принадлежит другой строке / другому UUID.
3. Почему текущий механизм недостаточен
В текущей кодовой базе top-level helper сначала самостоятельно ищет существующий Dashboard/Dataset по UUID:
existing = (
db.session.query(...)
.filter_by(uuid=config["uuid"])
.first()
)
Это уже есть и для Dashboard, и для Dataset.
Однако затем вызывается общий:
ImportExportMixin.import_from_dict(...)
который сам выполняет второй lookup.
Этот lookup строится не только по UUID.
import_from_dict:
- получает все unique constraints;
- строит для них условия;
- объединяет их через
OR; - выполняет
.one_or_none(); - если строка найдена — обновляет её;
- если нет — создаёт новую.
Фактическая логика:
filters.append(or_(*ucs))
obj = db.session.query(cls).filter(and_(*filters)).one_or_none()
Это значит, что UUID lookup в importer helper сейчас не является окончательным выбором target row.
4. Критическая проблема, которую нужно устранить
Dashboard
У Dashboard уникальны:
uuid
slug
dashboard_title unique не является.
Допустим, в БД:
id = 10
uuid = AAA
slug = fi0080
Импортируется:
uuid = BBB
slug = fi0080
Текущий generic lookup может фактически выполнить:
WHERE uuid = 'BBB'
OR slug = 'fi0080'
и найти строку AAA.
Такой импорт не должен молча превращать существующий Dashboard AAA в BBB.
Правильное поведение:
incoming UUID BBB не существует
slug fi0080 принадлежит UUID AAA
→ ImportFailedError
→ существующий Dashboard не изменяется
Dataset
Для Dataset используется логический natural key:
database_id
catalog
schema
table_name
Он объявлен через UniqueConstraint и используется import_from_dict для matching.
При этом в данном форке этот constraint указан как логический для importer и не должен рассматриваться как надёжная физическая гарантия metadata DB.
Сценарий:
existing:
uuid = AAA
database = PROD
schema = dm
table = sales
incoming:
uuid = BBB
database = PROD
schema = dm
table = sales
не должен приводить к тому, что строка AAA будет найдена по natural key и получит UUID BBB.
Правильный результат:
FAIL:
target database/catalog/schema/table already belongs
to dataset UUID AAA
Обе строки должны остаться неизменными.
5. Основной требуемый контракт
5.1 Dashboard
Case A — UUID отсутствует, slug свободен
UUID not found
slug not owned
Результат:
INSERT нового Dashboard
Case B — UUID существует, overwrite=false
Оставить существующий контракт command/API.
На уровне helper возможен ранний return existing, но normal V1 command заранее запрещает overwrite существующего UUID без overwrite=true. Это поведение уже реализовано в _prevent_overwrite_existing_model.
Не менять этот контракт в рамках DWH-14943 без необходимости.
Case C — UUID существует, overwrite=true, новый slug свободен
Было:
uuid = AAA
slug = fi0080-dev
Стало:
uuid = AAA
slug = fi0080
Результат:
UPDATE той же строки
id остаётся тем же
uuid остаётся AAA
slug меняется
Case D — UUID существует, overwrite=true, новый slug принадлежит этой же строке
Обычный idempotent UPDATE.
Case E — UUID существует, overwrite=true, новый slug принадлежит другому UUID
Например:
id=10 UUID=AAA slug=old
id=20 UUID=BBB slug=prod
incoming:
UUID=AAA
slug=prod
Результат:
FAIL
Нельзя изменять ни AAA, ни BBB.
Сообщение должно содержать как минимум:
- имя/slug импортируемого Dashboard;
- incoming UUID;
- конфликтующий UUID;
- конфликтующий slug.
Case F — новый UUID, slug принадлежит другому UUID
Результат:
FAIL
Не обновлять существующий Dashboard по slug.
6. Контракт Dataset
Для Dataset изменение destination является штатным.
Case A — тот же UUID, DEV → PROD
Было:
uuid = AAA
database = DEV
schema = dev_dm
table = sales
Импорт:
uuid = AAA
database = PROD
schema = dm
table = sales
overwrite = true
Результат:
UPDATE той же строки
Обязательно:
dataset.id BEFORE == dataset.id AFTER
dataset.uuid BEFORE == dataset.uuid AFTER
Меняются:
database_id
catalog
schema
table_name
Case B — тот же UUID, одновременно меняются database + schema + table
Разрешено.
Пример:
DEV:
database = dev_ch
schema = sandbox
table = sales_v2_dev
PROD:
database = prod_ch
schema = dm
table = sales
При overwrite=true это обычный UPDATE того же Dataset.
Case C — тот же UUID, target destination уже принадлежит другому UUID
БД:
id=10
UUID=AAA
DEV / sandbox / sales
id=20
UUID=BBB
PROD / dm / sales
Импорт:
UUID=AAA
PROD / dm / sales
overwrite=true
Результат:
FAIL destination collision
Нельзя:
- merge Dataset;
- менять UUID
BBB; - удалять
BBB; - молча возвращать
AAAкак будто импорт успешно выполнен.
Обе сущности должны остаться неизменными.
Case D — новый UUID, destination уже принадлежит другому UUID
БД:
UUID=AAA
PROD / dm / sales
Incoming:
UUID=BBB
PROD / dm / sales
Результат:
FAIL
Нельзя матчить BBB на AAA по natural key.
Case E — новый UUID + свободный destination
Результат:
INSERT
7. Изменения, которые нужно сделать в коде
7.1 superset/models/helpers.py
Проблема
ImportExportMixin.import_from_dict() самостоятельно выбирает объект через все unique constraints.
Это поведение полезно для legacy importer и дочерних сущностей, поэтому не нужно глобально ломать его семантику.
Необходимо добавить способ явно передать уже разрешённую target ORM entity либо включить UUID-authoritative lookup для top-level imports.
Предпочтительный вариант
Добавить optional argument, например:
existing_obj: Optional[Any] = None
или аналогичное понятное имя:
target_obj
Пример сигнатуры:
@classmethod
def import_from_dict(
cls,
dict_rep,
parent=None,
recursive=True,
sync=None,
allow_reparenting=False,
existing_obj=None,
):
Логика:
if existing_obj is not None:
obj = existing_obj
is_new_obj = False
else:
# существующий legacy matching
# parent filters
# unique constraints
# one_or_none()
После выбора obj остальная существующая логика обновления полей и recursive import должна работать как раньше.
Важно
Не передавать existing_obj рекурсивно детям автоматически.
Columns, metrics и другие children должны продолжать использовать текущую существующую механику matching, если для них отдельно не требуется изменение.
Задача DWH-14943 касается authoritative identity top-level Dashboard и Dataset.
7.2 Альтернативный допустимый вариант
Можно реализовать специальный параметр:
match_by_uuid_only=True
Тогда внутри import_from_dict:
if match_by_uuid_only:
obj = (
db.session.query(cls)
.filter(cls.uuid == dict_rep["uuid"])
.one_or_none()
)
else:
# legacy matching
Однако вариант с передачей existing_obj предпочтительнее:
- UUID lookup уже выполнен helper'ом;
- не выполняется второй SQL query;
- невозможно получить разные результаты между двумя lookup;
- helper остаётся владельцем import contract;
- проще тестировать.
8. superset/commands/dashboard/importers/v1/utils.py
Функция:
import_dashboard(...)
Текущий helper уже ищет existing Dashboard по UUID.
Нужно сделать этот lookup authoritative.
Алгоритм
После:
existing = (
db.session.query(Dashboard)
.filter_by(uuid=config["uuid"])
.first()
)
и после существующих permission checks:
Если existing есть и overwrite=true
Выполнить destination ownership validation для incoming slug.
Пример:
incoming_slug = config.get("slug")
if incoming_slug:
slug_owner = (
db.session.query(Dashboard)
.filter(Dashboard.slug == incoming_slug)
.one_or_none()
)
if slug_owner is not None and slug_owner.id != existing.id:
raise ImportFailedError(...)
После этого вызвать:
Dashboard.import_from_dict(
config,
recursive=False,
existing_obj=existing,
)
Таким образом target выбирается по UUID, а не по slug.
Если existing отсутствует
Проверить, что incoming slug не принадлежит существующему Dashboard.
Если принадлежит:
FAIL
Если свободен:
Dashboard.import_from_dict(
config,
recursive=False,
)
При этом для новой сущности желательно также не позволять generic importer матчить её на чужой slug.
Поэтому безопаснее использовать UUID-only mode и для create path.
Например:
Dashboard.import_from_dict(
config,
recursive=False,
match_by_uuid_only=True,
)
если выбрана реализация через режим.
Если используется existing_obj, нужно предусмотреть отдельный параметр, запрещающий natural-key lookup для new top-level object.
Например:
lookup_by_unique_constraints=False
или сделать отдельную обёртку.
Требование
После фикса должно быть невозможно:
new incoming UUID
+
existing slug
→ UPDATE existing row
9. superset/commands/dataset/importers/v1/utils.py
Функция:
import_dataset(...)
Текущий helper уже ищет existing Dataset по UUID.
Этот lookup должен стать authoritative.
9.1 Оставить
Обязательно оставить:
allow_reparenting=bool(overwrite)
в соответствующем import path.
Смысл:
при overwrite=true Dataset разрешено менять parent Database.
Текущий код уже передаёт этот параметр.
Но после введения authoritative existing_obj сам выбор top-level объекта уже не должен зависеть от parent filters.
10. Dataset destination ownership validation
Добавить helper, чтобы не размножать NULL-aware SQL.
Например:
def find_dataset_natural_key_owner(
database_id: int,
catalog: Optional[str],
schema: Optional[str],
table_name: str,
) -> Optional[SqlaTable]:
или более нейтральное название.
Natural key:
database_id
catalog
schema
table_name
Очень важно: корректная обработка NULL
Текущий generic import_from_dict исключает элементы unique constraint, у которых значение None.
Для explicit ownership validation так делать нельзя.
Нужно различать:
schema IS NULL
и:
schema не участвует в сравнении
Это разные условия.
Использовать эквивалент:
if catalog is None:
query = query.filter(SqlaTable.catalog.is_(None))
else:
query = query.filter(SqlaTable.catalog == catalog)
if schema is None:
query = query.filter(SqlaTable.schema.is_(None))
else:
query = query.filter(SqlaTable.schema == schema)
И:
query.filter(
SqlaTable.database_id == database_id,
SqlaTable.table_name == table_name,
)
11. Dataset algorithm
Псевдокод должен быть концептуально таким:
existing = find_dataset_by_uuid(config["uuid"])
# existing permissions / overwrite logic оставить
target_owner = find_dataset_natural_key_owner(
database_id=config["database_id"],
catalog=config.get("catalog"),
schema=config.get("schema"),
table_name=config["table_name"],
)
if existing is not None:
if target_owner is not None and target_owner.id != existing.id:
raise ImportFailedError(destination_conflict(...))
dataset = SqlaTable.import_from_dict(
config,
recursive=True,
sync=sync,
allow_reparenting=True,
existing_obj=existing,
)
else:
if target_owner is not None:
raise ImportFailedError(destination_conflict(...))
dataset = SqlaTable.import_from_dict(
config,
recursive=True,
sync=sync,
# create UUID-only / no NK matching
)
Конкретная реализация API import_from_dict может отличаться, но итоговая семантика должна быть именно такой.
12. Database mapping в import command НЕ ломать
В ZIP export/import Dataset ссылается на:
database_uuid
Command перед вызовом import_dataset() разрешает его в локальный:
database_id
Это уже реализовано для dashboard import.
И для отдельного dataset import.
Эту механику не менять.
Именно resolved database_id должен использоваться при destination ownership check.
13. Поддерживаемый DEV → PROD сценарий
Должен работать следующий workflow.
Исходный экспорт:
uuid: DATASET_UUID
database_uuid: DEV_DATABASE_UUID
schema: developer_schema
table_name: fact_sales
Перед PROD import конфигурация / deployment mapping приводит его к:
uuid: DATASET_UUID
database_uuid: PROD_DATABASE_UUID
schema: dm
table_name: fact_sales
Importer:
PROD_DATABASE_UUID
↓
resolved local database_id
↓
find Dataset by DATASET_UUID
↓
destination ownership validation
↓
UPDATE existing Dataset
После импорта:
dataset.id — тот же
dataset.uuid — тот же
database_id — новый
schema — новая
catalog — может быть новый
table_name — может быть новый
14. MultipleResultsFound
Не использовать MultipleResultsFound как основную бизнес-логику top-level import после фикса.
При UUID-authoritative matching top-level Dashboard/Dataset обычная ситуация:
UUID row + NK row
не должна превращаться в generic .one_or_none() по:
UUID OR natural key
и поэтому не должна давать MultipleResultsFound.
Legacy Dataset fallback
Если в коде сейчас существует специальный fallback для исторических Dataset с NULL schema, его можно оставить для совместимости, но:
- он должен быть максимально локализован;
- он не должен срабатывать в нормальном UUID-authoritative flow;
- нельзя считать возврат
existingуспешным reparenting, если incoming изменения фактически не были применены; - добавить логирование, позволяющее диагностировать этот legacy path.
Если после новой архитектуры fallback становится unreachable — это отдельно зафиксировать тестом и решить, можно ли удалить его.
Не удалять без анализа существующих prod edge cases.
15. IntegrityError
Общий:
except IntegrityError
оставить.
Но изменить его роль.
IntegrityError — это:
last-resort DB safety net
а не основной механизм определения:
- UUID conflict;
- slug ownership conflict;
- dataset destination collision.
Основные известные конфликты должны обнаруживаться детерминированно до mutation.
Generic formatter
Можно оставить/упростить общий formatter:
format_integrity_error(...)
Сообщение должно содержать:
- тип сущности;
- имя;
- UUID;
- безопасное описание DB error.
Не нужно пытаться по тексту SQL exception определять бизнес-смысл, если конфликт можно определить до import_from_dict.
16. is_uuid_unique_violation
Сейчас имеется helper is_uuid_unique_violation. Его реализация анализирует DB exception / текст ошибки.
После authoritative UUID lookup необходимость этого helper для Dashboard/Dataset существенно уменьшается.
Агент должен:
- проверить все его usages;
- если он больше нигде не нужен — удалить вместе с тестами;
- если нужен для других importers — оставить;
- не использовать его как основной механизм DWH-14943.
Не удалять blindly.
17. Ошибки Dashboard должны говорить про slug, а не title
Текущая модель:
dashboard_title = Column(...)
slug = Column(..., unique=True)
dashboard_title не является unique.
Поэтому все сообщения вида:
different natural key (title)
title conflicts
в контексте uniqueness Dashboard необходимо исправить.
Natural-key conflict Dashboard — это:
slug conflict
18. Ошибки Dataset
Для destination collision сообщение должно быть конкретным.
Пример семантики:
Dataset 'sales' (uuid: AAA) cannot be moved to
database=<...>, catalog=<...>, schema=dm, table=sales:
this destination is already used by dataset uuid BBB.
Желательно вывести:
- incoming Dataset UUID;
- target owner UUID;
- database;
- catalog;
- schema;
- table_name.
Не выводить connection URI или credentials.
19. Atomicity
Все conflict checks и mutation должны оставаться внутри существующей transaction boundary import command.
ImportModelsCommand.run() уже обёрнут в @transaction().
Не делать:
commit
→ следующий Dataset
→ ошибка
внутри helper.
При conflict весь импорт должен вести себя согласно текущей transaction semantics Superset.
20. Race condition
Pre-check ownership не заменяет DB safety полностью.
Между:
check destination
и:
UPDATE/INSERT
теоретически другая транзакция может создать конфликт.
Поэтому:
except IntegrityError
оставляется как fallback.
Для Dashboard физический unique slug дополнительно защищает race.
Для Dataset физическая защита natural key может отсутствовать, поэтому agent должен отдельно отметить это ограничение.
В рамках DWH-14943 не требуется автоматически добавлять новый physical UNIQUE constraint Dataset без отдельного анализа миграции и существующих дублей.
21. Не добавлять physical Dataset UNIQUE constraint в этой задаче автоматически
Не создавать migration:
UNIQUE(database_id, catalog, schema, table_name)
как часть DWH-14943 без отдельного решения.
Причина:
в существующих prod metadata могут быть historical duplicates, особенно вокруг NULL catalog/schema.
Сначала нужно было бы:
- провести inventory duplicates;
- определить PostgreSQL/MySQL NULL semantics;
- решить migration strategy;
- очистить legacy rows.
Это отдельная задача.
В DWH-14943 достаточно deterministic ownership check на уровне importer.
22. Unit tests: Dashboard
Файл:
tests/unit_tests/commands/dashboard/importers/v1/import_test.py
Добавить/обновить минимум следующие тесты.
D1. New UUID + free slug
EXPECT INSERT
Проверить:
new id
incoming UUID
incoming slug
D2. Existing UUID + overwrite=true + same slug
EXPECT UPDATE same row
Проверить:
after.id == before.id
after.uuid == before.uuid
D3. Existing UUID + overwrite=true + changed free slug
AAA / old-slug
→
AAA / new-slug
EXPECT success.
Проверить PK.
D4. Existing UUID + target slug owned by another UUID
AAA / old
BBB / target
import AAA / target
EXPECT:
ImportFailedError
После ошибки:
AAA unchanged
BBB unchanged
D5. New UUID + existing slug
AAA / target
import BBB / target
EXPECT error.
Критически проверить:
AAA.uuid остался AAA
Это regression test против natural-key takeover.
D6. Idempotent repeated overwrite
Импортировать один и тот же Dashboard несколько раз с overwrite=true.
EXPECT:
same id
same UUID
1 row
23. Unit tests: Dataset
Файл:
tests/unit_tests/datasets/commands/importers/v1/import_test.py
Добавить минимум следующие сценарии.
DS1. Existing UUID: database change
AAA:
DB1/schema1/table1
→
AAA:
DB2/schema1/table1
overwrite=true.
EXPECT:
after.id == before.id
after.uuid == before.uuid
after.database_id == db2.id
DS2. Existing UUID: schema change
DB1/schema_dev/table1
→
DB1/schema_prod/table1
EXPECT same PK.
DS3. Existing UUID: database + schema change
Основной production case:
DEV / sandbox / sales
→
PROD / dm / sales
EXPECT:
same id
same UUID
new database_id
new schema
DS4. Existing UUID: database + schema + table_name change
EXPECT success.
DS5. Existing UUID → destination owned by another UUID
AAA = DEV/sandbox/sales
BBB = PROD/dm/sales
incoming AAA = PROD/dm/sales
EXPECT conflict.
После ошибки:
AAA unchanged
BBB unchanged
DS6. New UUID → destination owned by another UUID
AAA = PROD/dm/sales
incoming BBB = PROD/dm/sales
EXPECT conflict.
Проверить:
AAA.uuid не изменился
AAA.id не изменился
BBB не появился
Это один из самых важных regression tests.
DS7. New UUID + free destination
EXPECT INSERT.
DS8. schema=None
Existing:
AAA / DB1 / catalog=None / schema=None / sales
Incoming same UUID:
DB2 / catalog=None / schema=dm / sales
EXPECT корректный UPDATE.
DS9. schema concrete → None
Проверить обратное направление.
DS10. catalog None → concrete
Проверить.
DS11. catalog concrete → None
Проверить.
DS12. Exact NULL ownership
Создать:
AAA:
DB1 / catalog=None / schema=None / sales
BBB:
DB1 / catalog=None / schema=dm / sales
Ownership lookup для:
catalog=None
schema=None
должен вернуть только AAA.
Он не должен считать BBB совпадением только потому, что None был выброшен из фильтра.
DS13. Repeat import
Один и тот же PROD Dataset импортировать дважды с overwrite=true.
EXPECT:
same id
same UUID
same destination
1 row
24. Не мокать ключевые regression scenarios
Существующие тесты части UUID conflict behavior мокают:
SqlaTable.import_from_dict
Dashboard.import_from_dict
и искусственно выбрасывают IntegrityError.
Для новых ключевых тестов это недостаточно.
Сценарии:
new UUID + occupied NK
existing UUID + occupied target NK
reparent database/schema
должны по возможности использовать настоящую test DB / SQLite session и реальные ORM rows.
Нужно тестировать actual row ownership.
Mock-тесты formatter/errors можно оставить как дополнительные.
25. Обязательный invariant всех UPDATE тестов
Не ограничиваться проверкой UUID.
В каждом update/reparent test:
original_id = dataset.id
original_uuid = dataset.uuid
# import
assert result.id == original_id
assert result.uuid == original_uuid
И дополнительно:
assert session.query(SqlaTable).filter_by(uuid=original_uuid).count() == 1
Для Dashboard аналогично.
Это доказывает, что:
UPDATE произошёл над правильной ORM строкой
а не:
- INSERT;
- takeover другой строки;
- UUID replacement;
- delete/create.
26. E2E acceptance test DEV → PROD
Добавить integration/acceptance сценарий максимально близкий к реальному ZIP import.
Исходное состояние
Dataset:
id = X
uuid = DATASET_UUID
database = DEV_DB
schema = dev_schema
table = fact_sales
Импортируемый archive
Тот же:
uuid = DATASET_UUID
но:
database_uuid = PROD_DB_UUID
schema = dm
table_name = fact_sales
Command должен стандартно разрешить:
PROD_DB_UUID → prod_database.id
После overwrite import
Проверить:
id == X
uuid == DATASET_UUID
database_id == PROD_DB.id
schema == dm
table_name == fact_sales
Количество Dataset с данным UUID:
1
Повторный импорт того же архива
Должен быть idempotent.
Снова:
same id
same UUID
same destination
no duplicate rows
27. Второй E2E acceptance — destination collision
Перед импортом создать:
AAA = DEV / sandbox / sales
BBB = PROD / dm / sales
Попытаться импортировать:
AAA → PROD / dm / sales
overwrite=true
Результат:
ImportFailedError
После rollback:
AAA всё ещё DEV/sandbox/sales
BBB всё ещё PROD/dm/sales
AAA.uuid unchanged
BBB.uuid unchanged
28. Проверить permissions behavior
Не сломать существующую логику:
- пользователь без create permissions;
- пользователь без overwrite permissions;
- owner Dataset;
- admin;
- Dashboard owner;
ignore_permissions.
Existing permission checks должны выполняться до mutation.
29. Проверить recursive Dataset import
Dataset импортируется:
recursive=True
с columns и metrics.
После изменения top-level matching проверить:
- columns продолжают импортироваться;
- metrics продолжают импортироваться;
sync=["columns", "metrics"]при overwrite работает;- удаление отсутствующих children не сломано;
- UUID-authoritative top-level режим случайно не применяется ко всем children.
30. Что НЕ делать
Не делать следующие упрощения.
Не считать изменение database конфликтом
Неверно:
database_id changed → conflict
Database change должен быть разрешён при:
same Dataset UUID + overwrite=true
Не считать изменение schema конфликтом
Неверно:
schema changed → conflict
Разрешено.
Не считать изменение table_name конфликтом само по себе
Разрешено, если target destination не принадлежит другому Dataset.
Не использовать natural key как identity
Нельзя:
UUID not found
NK found
→ update NK row
Не полагаться только на IntegrityError
Особенно для Dataset.
Не менять UUID существующей сущности из-за natural-key match
Это запрещённый результат.
Не создавать новый physical UniqueConstraint для Dataset без отдельной migration-задачи
31. Документация
Переписать:
USER_GUIDE_IMPORT_UUID_CONFLICT_FIX.md
Основная формулировка:
UUID является стабильной идентичностью импортируемого Dashboard/Dataset. Изменения database/schema/table/slug при overwrite обновляют тот же объект. Natural key используется для защиты от назначения destination, уже принадлежащего другому UUID.
Объяснить Dataset reparenting
Привести пример:
DEV:
database = CH_DEV
schema = sandbox
uuid = AAA
PROD:
database = CH_PROD
schema = dm
uuid = AAA
При:
overwrite=true
Dataset будет обновлён на месте.
Его internal DB primary key сохраняется.
Объяснить collision
Например:
AAA → wants PROD/dm/sales
BBB → already owns PROD/dm/sales
Importer остановит операцию и покажет оба UUID.
32. Logging
Добавить/оставить полезный debug/info logging.
Для reparent:
Dataset UUID AAA:
database_id 10 → 20
catalog x → y
schema dev → dm
table old → sales
Без секретных данных.
Для conflict:
incoming UUID AAA attempted destination owned by UUID BBB
Это особенно важно для диагностики deployment dev → prod.
33. Проверить текущие вспомогательные функции
Файл:
superset/commands/importers/v1/utils.py
Проверить:
get_import_uuid;find_by_uuid;format_integrity_error;is_uuid_unique_violation.
Удалять только реально ставший dead code.
Не проводить несвязанный рефакторинг importer framework.
34. Ожидаемый итоговый алгоритм Dashboard
Концептуально:
resolve incoming UUID
│
▼
find Dashboard by UUID
│
├── exists ── overwrite/permission checks
│ │
│ ▼
│ check incoming slug owner
│ │
│ ┌────────┴────────┐
│ │ │
│ free/self other row
│ │ │
│ ▼ ▼
│ UPDATE exact UUID FAIL
│ row
│
└── absent
│
▼
check slug owner
│
┌─────┴─────┐
│ │
free occupied
│ │
▼ ▼
INSERT FAIL
35. Ожидаемый итоговый алгоритм Dataset
resolve database_uuid → local database_id
│
▼
resolve incoming UUID
│
▼
find Dataset by UUID
│
┌─────────┴─────────┐
│ │
exists absent
│ │
▼ ▼
overwrite + perms target NK lookup
│ │
▼ ┌────┴─────┐
target NK lookup free occupied
│ │ │
┌─────┴──────┐ ▼ ▼
│ │ INSERT FAIL
free/self other
│ │
▼ ▼
UPDATE FAIL
exact UUID
row
36. Итоговые acceptance criteria
Задача считается выполненной только если выполняются все условия.
- Dashboard existing UUID при
overwrite=trueобновляет ту же DB row. - Изменение Dashboard slug разрешено, если новый slug свободен.
- Dashboard не может захватить чужую строку по slug.
- Dataset existing UUID при
overwrite=trueобновляет ту же DB row. - Dataset может менять database.
- Dataset может менять schema.
- Dataset может менять catalog.
- Dataset может менять table_name.
- Dataset может одновременно менять database + schema + table.
- При reparent сохраняется Dataset
id. - При reparent сохраняется Dataset UUID.
- Новый UUID не может захватить существующий Dataset по natural key.
- Существующий UUID не может переехать на destination, принадлежащий другому UUID.
NULL catalog/schemaсравниваются корректно через NULL-aware ownership lookup.- Повторный import с
overwrite=trueidempotent. - Columns и metrics продолжают корректно импортироваться.
- Permission checks не изменены.
IntegrityErrorостаётся fallback.- Нормальные destination collision обнаруживаются до mutation.
- Ошибки Dashboard говорят про
slug, а неdashboard_title. - Ошибки collision содержат incoming UUID и owner UUID.
- DEV → PROD E2E reparent test проходит.
- Destination collision E2E test проходит с rollback и без изменения обеих строк.
37. Итоговый результат, который должен получить агент
После выполнения изменения импорт должен обладать следующей семантикой:
UUID = identity
database/schema/table/slug = mutable attributes
natural key = destination ownership constraint
overwrite=true = разрешение изменить mutable attributes
Типовой рабочий процесс:
Dataset AAA
DEV DB / dev schema
↓ export/import
Dataset AAA
TEST DB / test schema
↓ export/import
Dataset AAA
PROD DB / prod schema
должен проходить без необходимости менять UUID Dataset.
На каждом окружении импорт обновляет ту же логическую сущность, сохраняя её UUID и локальный DB primary key.
При этом сценарий:
Dataset AAA
пытается занять
PROD/dm/sales
но PROD/dm/sales уже принадлежит Dataset BBB
должен завершаться явной ошибкой и rollback:
AAA unchanged
BBB unchanged
То есть итоговая реализация должна обеспечивать одновременно две вещи:
свободный reparenting одного UUID между DEV/TEST/PROD и строгую защиту от перезаписи объекта другого UUID.
38. Перед коммитом
Запустить как минимум:
pytest tests/unit_tests/commands/dashboard/importers/v1/import_test.py
pytest tests/unit_tests/datasets/commands/importers/v1/import_test.py
pytest tests/unit_tests/commands/importers/v1/utils_test.py
Плюс все непосредственно затронутые тесты ImportExportMixin, если они существуют.
Если изменение superset/models/helpers.py влияет на общий importer framework — запустить более широкий набор import/export tests.
В отчёте агент должен перечислить:
- какие файлы изменены;
- какой именно новый механизм authoritative matching реализован;
- какие legacy paths сохранены;
- какие tests добавлены;
- результаты запуска тестов;
- отдельно подтвердить прохождение:
- DEV → PROD reparent;
- new UUID + occupied NK;
- existing UUID + foreign destination;
- repeated idempotent overwrite.