Files
ss-tools/research/DWH-14943 — UUID-authoritative импорт Dashboard и Dataset с безопасным reparenting.md
busya 977f3d6d75 chore: checkpoint working tree onto master
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
2026-08-18 12:20:42 +03:00

43 KiB
Raw Blame History

DWH-14943 — UUID-authoritative импорт Dashboard и Dataset с безопасным reparenting

1. Цель задачи

Исправить импорт Dashboard и Dataset в Apache Superset так, чтобы:

  1. UUID был единственным идентификатором сущности при определении, какую существующую строку нужно обновлять.

  2. Изменение natural key при overwrite=true было штатной операцией.

  3. Для Dataset было разрешено свободно менять:

    • database;
    • database_id;
    • database_uuid через стандартный mapping импортера;
    • catalog;
    • schema;
    • table_name.
  4. Типовой сценарий разработки:

    DEV database / DEV schema
            ↓
    TEST database / TEST schema
            ↓
    PROD database / PROD schema
    

    не должен восприниматься как конфликт, если UUID Dataset остаётся прежним.

  5. При этом импорт не должен позволять одному UUID захватить объект, принадлежащий другому UUID, только потому что совпал slug или (database, catalog, schema, table_name).

  6. 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:

  1. получает все unique constraints;
  2. строит для них условия;
  3. объединяет их через OR;
  4. выполняет .one_or_none();
  5. если строка найдена — обновляет её;
  6. если нет — создаёт новую.

Фактическая логика:

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, его можно оставить для совместимости, но:

  1. он должен быть максимально локализован;
  2. он не должен срабатывать в нормальном UUID-authoritative flow;
  3. нельзя считать возврат existing успешным reparenting, если incoming изменения фактически не были применены;
  4. добавить логирование, позволяющее диагностировать этот 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 существенно уменьшается.

Агент должен:

  1. проверить все его usages;
  2. если он больше нигде не нужен — удалить вместе с тестами;
  3. если нужен для других importers — оставить;
  4. не использовать его как основной механизм 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.

Сначала нужно было бы:

  1. провести inventory duplicates;
  2. определить PostgreSQL/MySQL NULL semantics;
  3. решить migration strategy;
  4. очистить 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=true idempotent.
  • 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.

В отчёте агент должен перечислить:

  1. какие файлы изменены;
  2. какой именно новый механизм authoritative matching реализован;
  3. какие legacy paths сохранены;
  4. какие tests добавлены;
  5. результаты запуска тестов;
  6. отдельно подтвердить прохождение:
    • DEV → PROD reparent;
    • new UUID + occupied NK;
    • existing UUID + foreign destination;
    • repeated idempotent overwrite.