From ed4ba16f09672c32a6f6ebc0b17bd42d544cb031 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 17 Jun 2026 11:22:01 -0700 Subject: [PATCH 1/5] Add data product domain deletion repro tests --- .../it/tests/DataProductResourceIT.java | 41 +++++++++ .../DataProductDomainMigration.spec.ts | 86 +++++++++++++++++++ 2 files changed, 127 insertions(+) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java index da4029431670..b1eb9aede9c3 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java @@ -1148,6 +1148,47 @@ void test_changeDataProductDomain_noAssets(TestNamespace ns) { assertEquals(domain2.getId(), fetched.getDomains().get(0).getId()); } + @Test + void test_changeDataProductDomain_thenDeleteOriginalDomain_preservesDataProduct( + TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + Domain domain1 = createTestDomain(ns, "domain_delete_original_1"); + Domain domain2 = createTestDomain(ns, "domain_delete_original_2"); + + DataProduct dataProduct = + createEntity( + new CreateDataProduct() + .withName(ns.prefix("dp_delete_original_domain")) + .withDescription("Data product moved before original domain deletion") + .withDomains(List.of(domain1.getFullyQualifiedName()))); + + dataProduct.setDomains(List.of(domain2.getEntityReference())); + DataProduct updated = patchEntity(dataProduct.getId().toString(), dataProduct); + assertEquals(1, updated.getDomains().size()); + assertEquals(domain2.getId(), updated.getDomains().getFirst().getId()); + + client + .domains() + .delete(domain1.getId().toString(), Map.of("hardDelete", "true", "recursive", "true")); + + DataProduct fetched = client.dataProducts().get(dataProduct.getId().toString(), "domains"); + assertFalse(Boolean.TRUE.equals(fetched.getDeleted())); + assertEquals(1, fetched.getDomains().size()); + assertEquals(domain2.getId(), fetched.getDomains().getFirst().getId()); + + ListResponse listed = + client + .dataProducts() + .list( + new ListParams() + .setFields("domains") + .withDomain(domain2.getFullyQualifiedName()) + .withLimit(100)); + assertTrue( + listed.getData().stream().anyMatch(dp -> dp.getId().equals(dataProduct.getId())), + "Moved data product must remain visible under its target domain after original domain deletion"); + } + @Test void test_changeDataProductDomain_withAssetMigration(TestNamespace ns) throws Exception { // Create two domains diff --git a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataProductDomainMigration.spec.ts b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataProductDomainMigration.spec.ts index d94f2601dba2..f73d8abf1d37 100644 --- a/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataProductDomainMigration.spec.ts +++ b/openmetadata-ui/src/main/resources/ui/playwright/e2e/Features/DataProductDomainMigration.spec.ts @@ -26,6 +26,8 @@ import { checkAssetsCount, goToAssetsTab, selectDataProduct, + selectDataProductFromTab, + selectDomain, verifyAssetsInDomain, } from '../../utils/domain'; import { waitForAllLoadersToDisappear } from '../../utils/entity'; @@ -323,4 +325,88 @@ test.describe('Data Product Domain Migration', () => { } await cleanupAfter(); }); + + test('Data product remains visible after moving domains and deleting the original domain', async ({ + page, + }) => { + test.slow(); + + const testId = uuid(); + const originalDomain = new Domain({ + name: `original_domain_${testId}`, + displayName: `Original Domain ${testId}`, + description: 'Original domain for data product visibility test', + domainType: 'Aggregate', + fullyQualifiedName: `original_domain_${testId}`, + }); + const vehicleDomain = new Domain({ + name: `vehicle_domain_${testId}`, + displayName: `Vehicle Domain ${testId}`, + description: 'Vehicle domain for data product visibility test', + domainType: 'Aggregate', + fullyQualifiedName: `vehicle_domain_${testId}`, + }); + const movedDataProduct = new DataProduct( + [originalDomain], + `dp_visibility_${testId}` + ); + const { apiContext, afterAction } = await getApiContext(page); + + try { + await originalDomain.create(apiContext); + await vehicleDomain.create(apiContext); + await movedDataProduct.create(apiContext); + + const patchResponse = await apiContext.patch( + `/api/v1/dataProducts/${movedDataProduct.responseData.id}`, + { + data: [ + { + op: 'replace', + path: '/domains', + value: [ + { + id: vehicleDomain.responseData.id, + type: 'domain', + }, + ], + }, + ], + headers: { + 'Content-Type': 'application/json-patch+json', + }, + } + ); + expect(patchResponse.ok()).toBeTruthy(); + + const deleteOriginalDomainResponse = await apiContext.delete( + `/api/v1/domains/${originalDomain.responseData.id}?recursive=true&hardDelete=true` + ); + expect(deleteOriginalDomainResponse.ok()).toBeTruthy(); + + await redirectToHomePage(page); + await sidebarClick(page, SidebarItem.DATA_PRODUCT); + await selectDataProduct(page, movedDataProduct.data); + + await expect(page.getByTestId('entity-header-name')).toContainText( + movedDataProduct.responseData.name + ); + await expect(page.getByTestId('domain-link').first()).toContainText( + vehicleDomain.data.displayName + ); + + await sidebarClick(page, SidebarItem.DOMAIN); + await selectDomain(page, vehicleDomain.data); + await selectDataProductFromTab(page, movedDataProduct.data); + + await expect(page.getByTestId('entity-header-name')).toContainText( + movedDataProduct.responseData.name + ); + } finally { + await movedDataProduct.delete(apiContext); + await originalDomain.delete(apiContext); + await vehicleDomain.delete(apiContext); + await afterAction(); + } + }); }); From 935c3543dbe9cac288851846837bcfec7ab85c2f Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 17 Jun 2026 13:55:38 -0700 Subject: [PATCH 2/5] Fix data product retention during domain deletes --- .../it/tests/DataProductResourceIT.java | 75 +++++++++ .../service/jdbi3/DataProductRepository.java | 29 ++++ .../service/jdbi3/DomainRepository.java | 150 ++++++++++++++++++ .../service/jdbi3/EntityRepository.java | 16 ++ 4 files changed, 270 insertions(+) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java index b1eb9aede9c3..7133b74eb34b 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java @@ -7,6 +7,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.it.bootstrap.SharedEntities.*; +import static org.openmetadata.service.Entity.DATA_PRODUCT; +import static org.openmetadata.service.Entity.DOMAIN; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; @@ -52,6 +54,7 @@ import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.EntityStatus; +import org.openmetadata.schema.type.Relationship; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.api.BulkAssets; import org.openmetadata.schema.type.api.BulkOperationResult; @@ -62,6 +65,7 @@ import org.openmetadata.sdk.models.ListParams; import org.openmetadata.sdk.models.ListResponse; import org.openmetadata.sdk.network.HttpMethod; +import org.openmetadata.service.Entity; /** * Integration tests for DataProduct entity operations. @@ -1189,6 +1193,77 @@ void test_changeDataProductDomain_thenDeleteOriginalDomain_preservesDataProduct( "Moved data product must remain visible under its target domain after original domain deletion"); } + @Test + void test_deleteOneDomainForMultiDomainDataProduct_preservesDataProductUntilLastDomain( + TestNamespace ns) { + OpenMetadataClient client = SdkClients.adminClient(); + Domain domain1 = createTestDomain(ns, "multi_domain_delete_1"); + Domain domain2 = createTestDomain(ns, "multi_domain_delete_2"); + + DataProduct dataProduct = + createEntity( + new CreateDataProduct() + .withName(ns.prefix("dp_multi_domain_delete")) + .withDescription("Data product shared by multiple domains") + .withDomains(List.of(domain1.getFullyQualifiedName()))); + attachDataProductToDomain(dataProduct, domain2); + + DataProduct sharedDataProduct = + client.dataProducts().get(dataProduct.getId().toString(), "domains"); + assertEquals(2, sharedDataProduct.getDomains().size()); + assertTrue( + sharedDataProduct.getDomains().stream() + .anyMatch(domain -> domain.getId().equals(domain1.getId()))); + assertTrue( + sharedDataProduct.getDomains().stream() + .anyMatch(domain -> domain.getId().equals(domain2.getId()))); + + client + .domains() + .delete(domain1.getId().toString(), Map.of("hardDelete", "true", "recursive", "true")); + + DataProduct fetched = client.dataProducts().get(dataProduct.getId().toString(), "domains"); + assertFalse(Boolean.TRUE.equals(fetched.getDeleted())); + assertEquals(1, fetched.getDomains().size()); + assertEquals(domain2.getId(), fetched.getDomains().getFirst().getId()); + + ListResponse listed = + client + .dataProducts() + .list( + new ListParams() + .setFields("domains") + .withDomain(domain2.getFullyQualifiedName()) + .withLimit(100)); + assertTrue( + listed.getData().stream().anyMatch(dp -> dp.getId().equals(dataProduct.getId())), + "Data product must remain visible under the surviving domain"); + + client + .domains() + .delete(domain2.getId().toString(), Map.of("hardDelete", "true", "recursive", "true")); + + assertThrows( + Exception.class, + () -> client.dataProducts().get(dataProduct.getId().toString(), "domains")); + } + + private void attachDataProductToDomain(DataProduct dataProduct, Domain domain) { + Entity.getEntityRepository(DATA_PRODUCT) + .addRelationship( + domain.getId(), dataProduct.getId(), DOMAIN, DATA_PRODUCT, Relationship.HAS); + Entity.getEntityRepository(DATA_PRODUCT) + .addRelationship( + domain.getId(), dataProduct.getId(), DOMAIN, DATA_PRODUCT, Relationship.CONTAINS); + List domains = + dataProduct.getDomains() == null + ? new ArrayList<>() + : new ArrayList<>(dataProduct.getDomains()); + domains.add(domain.getEntityReference()); + dataProduct.setDomains(domains); + Entity.getCollectionDAO().dataProductDAO().update(dataProduct); + } + @Test void test_changeDataProductDomain_withAssetMigration(TestNamespace ns) throws Exception { // Create two domains diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java index 63ac0b269157..603b33b49f87 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DataProductRepository.java @@ -866,6 +866,8 @@ private void updateDataProductDomains() { origDomains.stream().map(EntityReference::getFullyQualifiedName).toList(), updatedDomains.stream().map(EntityReference::getFullyQualifiedName).toList()); + updateDataProductDomainContainment(origDomains, updatedDomains); + List assetRecords = daoCollection .relationshipDAO() @@ -902,6 +904,33 @@ private void updateDataProductDomains() { } } + private void updateDataProductDomainContainment( + List oldDomains, List newDomains) { + List addedDomains = + diffLists( + newDomains, + oldDomains, + EntityReference::getId, + EntityReference::getId, + Function.identity()); + List removedDomains = + diffLists( + oldDomains, + newDomains, + EntityReference::getId, + EntityReference::getId, + Function.identity()); + + for (EntityReference domain : removedDomains) { + deleteRelationship( + domain.getId(), DOMAIN, updated.getId(), DATA_PRODUCT, Relationship.CONTAINS); + } + for (EntityReference domain : addedDomains) { + addRelationship( + domain.getId(), updated.getId(), DOMAIN, DATA_PRODUCT, Relationship.CONTAINS); + } + } + private void batchMigrateAssetDomains( List assetRecords, List oldDomains, diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java index ab4e90da9474..7b960cd9ac28 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java @@ -22,11 +22,14 @@ import static org.openmetadata.service.Entity.getEntityReferenceById; import static org.openmetadata.service.exception.CatalogExceptionMessage.entityNameAlreadyExists; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; @@ -34,6 +37,7 @@ import org.jdbi.v3.sqlobject.transaction.Transaction; import org.openmetadata.schema.EntityInterface; import org.openmetadata.schema.entity.data.EntityHierarchy; +import org.openmetadata.schema.entity.domains.DataProduct; import org.openmetadata.schema.entity.domains.Domain; import org.openmetadata.schema.type.ApiStatus; import org.openmetadata.schema.type.ChangeDescription; @@ -69,6 +73,7 @@ public class DomainRepository extends EntityRepository { private static final String UPDATE_FIELDS = "parent,children,experts"; private InheritedFieldEntitySearch inheritedFieldEntitySearch; + private final ThreadLocal> domainDeleteSubtree = new ThreadLocal<>(); public DomainRepository() { super( @@ -449,6 +454,151 @@ private void cleanupOldDomain(EntityReference ref, String fromEntity, Relationsh LineageUtil.removeDomainLineage(ref.getId(), ref.getType(), oldDomain); } + @Override + protected void deleteChildren(UUID id, boolean recursive, boolean hardDelete, String updatedBy) { + boolean rootDomainDelete = domainDeleteSubtree.get() == null; + if (rootDomainDelete) { + domainDeleteSubtree.set(collectDomainSubtreeIds(List.of(id))); + } + try { + super.deleteChildren(id, recursive, hardDelete, updatedBy); + } finally { + if (rootDomainDelete) { + domainDeleteSubtree.remove(); + } + } + } + + @Override + protected List filterChildrenForDeleteCascade( + UUID parentId, List children) { + List dataProductIds = + children.stream() + .filter(child -> DATA_PRODUCT.equals(child.getType())) + .map(child -> child.getId().toString()) + .distinct() + .toList(); + if (dataProductIds.isEmpty()) { + return children; + } + + Set retainedDataProductIds = + retainedSharedDataProductIds(dataProductIds, deletingDomainIds(List.of(parentId))); + if (retainedDataProductIds.isEmpty()) { + return children; + } + + return children.stream() + .filter( + child -> + !DATA_PRODUCT.equals(child.getType()) + || !retainedDataProductIds.contains(child.getId())) + .toList(); + } + + @Override + protected List filterChildrenForDeleteCascade( + List parents, List children) { + List dataProductIds = + children.stream() + .filter(this::isDomainDataProductContainment) + .map(CollectionDAO.EntityRelationshipObject::getToId) + .distinct() + .toList(); + if (dataProductIds.isEmpty()) { + return children; + } + + Set retainedDataProductIds = + retainedSharedDataProductIds( + dataProductIds, deletingDomainIds(parents.stream().map(Domain::getId).toList())); + if (retainedDataProductIds.isEmpty()) { + return children; + } + + return children.stream() + .filter( + child -> + !isDomainDataProductContainment(child) + || !retainedDataProductIds.contains(UUID.fromString(child.getToId()))) + .toList(); + } + + private boolean isDomainDataProductContainment(CollectionDAO.EntityRelationshipObject child) { + return Relationship.CONTAINS.ordinal() == child.getRelation() + && DOMAIN.equals(child.getFromEntity()) + && DATA_PRODUCT.equals(child.getToEntity()); + } + + private Set deletingDomainIds(List parentIds) { + Set deletingDomainIds = domainDeleteSubtree.get(); + return deletingDomainIds != null ? deletingDomainIds : collectDomainSubtreeIds(parentIds); + } + + private Set collectDomainSubtreeIds(List rootIds) { + Set domainIds = new HashSet<>(); + ArrayDeque queue = new ArrayDeque<>(rootIds); + while (!queue.isEmpty()) { + UUID domainId = queue.removeFirst(); + if (!domainIds.add(domainId)) { + continue; + } + daoCollection + .relationshipDAO() + .findTo(domainId, DOMAIN, Relationship.CONTAINS.ordinal(), DOMAIN) + .forEach(child -> queue.add(child.getId())); + } + return domainIds; + } + + private Set retainedSharedDataProductIds( + List dataProductIds, Set deletingDomainIds) { + Map> deletingParentsByDataProduct = new HashMap<>(); + Set retainedDataProductIds = new HashSet<>(); + + daoCollection + .relationshipDAO() + .findFromBatch(dataProductIds, Relationship.CONTAINS.ordinal(), DOMAIN, ALL) + .forEach( + relationship -> { + UUID dataProductId = UUID.fromString(relationship.getToId()); + UUID domainId = UUID.fromString(relationship.getFromId()); + if (deletingDomainIds.contains(domainId)) { + deletingParentsByDataProduct + .computeIfAbsent(dataProductId, id -> new ArrayList<>()) + .add(domainId); + } else { + retainedDataProductIds.add(dataProductId); + } + }); + + detachRetainedDataProductsFromDeletingDomains( + retainedDataProductIds, deletingParentsByDataProduct); + return retainedDataProductIds; + } + + private void detachRetainedDataProductsFromDeletingDomains( + Set retainedDataProductIds, Map> deletingParentsByDataProduct) { + if (retainedDataProductIds.isEmpty()) { + return; + } + + DataProductRepository dataProductRepository = + (DataProductRepository) Entity.getEntityRepository(DATA_PRODUCT); + for (UUID dataProductId : retainedDataProductIds) { + for (UUID domainId : listOrEmpty(deletingParentsByDataProduct.get(dataProductId))) { + deleteRelationship(domainId, DOMAIN, dataProductId, DATA_PRODUCT, Relationship.CONTAINS); + deleteRelationship(domainId, DOMAIN, dataProductId, DATA_PRODUCT, Relationship.HAS); + } + DataProduct dataProduct = dataProductRepository.find(dataProductId, ALL, false); + dataProduct.setDomains(dataProductRepository.getDomains(dataProduct, ALL)); + dataProductRepository.storeEntity(dataProduct, true); + if (searchRepository != null) { + searchRepository.updateEntityIndex(dataProduct); + } + } + } + private void cleanupDataProducts( UUID entityId, EntityReference ref, Relationship relationship, boolean isAdd) { List dataProducts = getDataProducts(ref.getId(), ref.getType()); diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 1bf1dfc3f7a2..418a1f27d842 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -4666,6 +4666,11 @@ protected void deleteChildren(UUID id, boolean recursive, boolean hardDelete, St if (!recursive) { throw new IllegalArgumentException(CatalogExceptionMessage.entityIsNotEmpty(entityType)); } + childrenRecords = filterChildrenForDeleteCascade(id, childrenRecords); + if (childrenRecords.isEmpty()) { + LOG.debug("No children to delete for {} {} after cascade filtering", entityType, id); + return; + } // Delete all the contained entities deleteChildren(childrenRecords, hardDelete, updatedBy); } @@ -6394,6 +6399,7 @@ private void dispatchToContainedChildren( relationships = daoCollection.relationshipDAO().findToBatchAllTypes(parentIds, SUBTREE_RELATIONS, ALL); } + relationships = filterChildrenForDeleteCascade(parents, relationships); if (relationships.isEmpty()) { return; } @@ -6416,6 +6422,16 @@ private void dispatchToContainedChildren( } } + protected List filterChildrenForDeleteCascade( + UUID parentId, List children) { + return children; + } + + protected List filterChildrenForDeleteCascade( + List parents, List children) { + return children; + } + /** * Hook called once per restored entity for repositories that have non-CONTAINS related * entities that need to be restored alongside the parent. Default: no-op. From 8a16f879ddf47a9793c970814da268551619e471 Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 17 Jun 2026 15:22:44 -0700 Subject: [PATCH 3/5] Address data product domain delete review --- .../it/tests/DataProductResourceIT.java | 32 +++--- .../service/jdbi3/DomainRepository.java | 65 +++++++----- .../service/jdbi3/EntityRepository.java | 100 +++++++++++------- 3 files changed, 115 insertions(+), 82 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java index 7133b74eb34b..b14d789668be 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java @@ -7,8 +7,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.openmetadata.it.bootstrap.SharedEntities.*; -import static org.openmetadata.service.Entity.DATA_PRODUCT; -import static org.openmetadata.service.Entity.DOMAIN; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; @@ -22,6 +20,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.api.parallel.ResourceLock; import org.openmetadata.it.bootstrap.SharedEntities; import org.openmetadata.it.factories.DashboardServiceTestFactory; import org.openmetadata.it.factories.MessagingServiceTestFactory; @@ -54,7 +53,6 @@ import org.openmetadata.schema.type.EntityHistory; import org.openmetadata.schema.type.EntityReference; import org.openmetadata.schema.type.EntityStatus; -import org.openmetadata.schema.type.Relationship; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.api.BulkAssets; import org.openmetadata.schema.type.api.BulkOperationResult; @@ -65,7 +63,6 @@ import org.openmetadata.sdk.models.ListParams; import org.openmetadata.sdk.models.ListResponse; import org.openmetadata.sdk.network.HttpMethod; -import org.openmetadata.service.Entity; /** * Integration tests for DataProduct entity operations. @@ -1194,6 +1191,7 @@ void test_changeDataProductDomain_thenDeleteOriginalDomain_preservesDataProduct( } @Test + @ResourceLock("MULTI_DOMAIN_RULE") void test_deleteOneDomainForMultiDomainDataProduct_preservesDataProductUntilLastDomain( TestNamespace ns) { OpenMetadataClient client = SdkClients.adminClient(); @@ -1206,7 +1204,7 @@ void test_deleteOneDomainForMultiDomainDataProduct_preservesDataProductUntilLast .withName(ns.prefix("dp_multi_domain_delete")) .withDescription("Data product shared by multiple domains") .withDomains(List.of(domain1.getFullyQualifiedName()))); - attachDataProductToDomain(dataProduct, domain2); + updateDataProductDomainsWithMultiDomainRuleDisabled(client, dataProduct, domain1, domain2); DataProduct sharedDataProduct = client.dataProducts().get(dataProduct.getId().toString(), "domains"); @@ -1248,20 +1246,16 @@ void test_deleteOneDomainForMultiDomainDataProduct_preservesDataProductUntilLast () -> client.dataProducts().get(dataProduct.getId().toString(), "domains")); } - private void attachDataProductToDomain(DataProduct dataProduct, Domain domain) { - Entity.getEntityRepository(DATA_PRODUCT) - .addRelationship( - domain.getId(), dataProduct.getId(), DOMAIN, DATA_PRODUCT, Relationship.HAS); - Entity.getEntityRepository(DATA_PRODUCT) - .addRelationship( - domain.getId(), dataProduct.getId(), DOMAIN, DATA_PRODUCT, Relationship.CONTAINS); - List domains = - dataProduct.getDomains() == null - ? new ArrayList<>() - : new ArrayList<>(dataProduct.getDomains()); - domains.add(domain.getEntityReference()); - dataProduct.setDomains(domains); - Entity.getCollectionDAO().dataProductDAO().update(dataProduct); + private DataProduct updateDataProductDomainsWithMultiDomainRuleDisabled( + OpenMetadataClient client, DataProduct dataProduct, Domain... domains) { + EntityRulesUtil.toggleMultiDomainRule(client, false); + try { + DataProduct update = client.dataProducts().get(dataProduct.getId().toString(), "domains"); + update.setDomains(List.of(domains).stream().map(Domain::getEntityReference).toList()); + return client.dataProducts().update(dataProduct.getId().toString(), update); + } finally { + EntityRulesUtil.toggleMultiDomainRule(client, true); + } } @Test diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java index 7b960cd9ac28..a41273fc0272 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DomainRepository.java @@ -73,7 +73,10 @@ public class DomainRepository extends EntityRepository { private static final String UPDATE_FIELDS = "parent,children,experts"; private InheritedFieldEntitySearch inheritedFieldEntitySearch; - private final ThreadLocal> domainDeleteSubtree = new ThreadLocal<>(); + private final ThreadLocal> domainHardDeleteSubtree = new ThreadLocal<>(); + + private record RetainedDataProductCascadePlan( + Set retainedDataProductIds, Map> deletingParentsByDataProduct) {} public DomainRepository() { super( @@ -456,21 +459,31 @@ private void cleanupOldDomain(EntityReference ref, String fromEntity, Relationsh @Override protected void deleteChildren(UUID id, boolean recursive, boolean hardDelete, String updatedBy) { - boolean rootDomainDelete = domainDeleteSubtree.get() == null; - if (rootDomainDelete) { - domainDeleteSubtree.set(collectDomainSubtreeIds(List.of(id))); + boolean rootDomainHardDelete = hardDelete && domainHardDeleteSubtree.get() == null; + if (rootDomainHardDelete) { + domainHardDeleteSubtree.set(collectDomainSubtreeIds(List.of(id))); } try { super.deleteChildren(id, recursive, hardDelete, updatedBy); } finally { - if (rootDomainDelete) { - domainDeleteSubtree.remove(); + if (rootDomainHardDelete) { + domainHardDeleteSubtree.remove(); } } } @Override - protected List filterChildrenForDeleteCascade( + protected Runnable enterBulkHardDeleteCascade(List domains) { + if (domainHardDeleteSubtree.get() != null) { + return () -> {}; + } + domainHardDeleteSubtree.set( + collectDomainSubtreeIds(domains.stream().map(Domain::getId).toList())); + return domainHardDeleteSubtree::remove; + } + + @Override + protected List prepareChildrenForHardDeleteCascade( UUID parentId, List children) { List dataProductIds = children.stream() @@ -482,22 +495,24 @@ protected List filterChildrenForDeleteCa return children; } - Set retainedDataProductIds = - retainedSharedDataProductIds(dataProductIds, deletingDomainIds(List.of(parentId))); - if (retainedDataProductIds.isEmpty()) { + RetainedDataProductCascadePlan plan = + retainedSharedDataProductCascadePlan(dataProductIds, currentDomainHardDeleteSubtree()); + if (plan.retainedDataProductIds().isEmpty()) { return children; } + detachRetainedDataProductsFromDeletingDomains( + plan.retainedDataProductIds(), plan.deletingParentsByDataProduct()); return children.stream() .filter( child -> !DATA_PRODUCT.equals(child.getType()) - || !retainedDataProductIds.contains(child.getId())) + || !plan.retainedDataProductIds().contains(child.getId())) .toList(); } @Override - protected List filterChildrenForDeleteCascade( + protected List prepareChildrenForHardDeleteCascade( List parents, List children) { List dataProductIds = children.stream() @@ -509,18 +524,19 @@ protected List filterChildrenForDeleteCa return children; } - Set retainedDataProductIds = - retainedSharedDataProductIds( - dataProductIds, deletingDomainIds(parents.stream().map(Domain::getId).toList())); - if (retainedDataProductIds.isEmpty()) { + RetainedDataProductCascadePlan plan = + retainedSharedDataProductCascadePlan(dataProductIds, currentDomainHardDeleteSubtree()); + if (plan.retainedDataProductIds().isEmpty()) { return children; } + detachRetainedDataProductsFromDeletingDomains( + plan.retainedDataProductIds(), plan.deletingParentsByDataProduct()); return children.stream() .filter( child -> !isDomainDataProductContainment(child) - || !retainedDataProductIds.contains(UUID.fromString(child.getToId()))) + || !plan.retainedDataProductIds().contains(UUID.fromString(child.getToId()))) .toList(); } @@ -530,9 +546,12 @@ private boolean isDomainDataProductContainment(CollectionDAO.EntityRelationshipO && DATA_PRODUCT.equals(child.getToEntity()); } - private Set deletingDomainIds(List parentIds) { - Set deletingDomainIds = domainDeleteSubtree.get(); - return deletingDomainIds != null ? deletingDomainIds : collectDomainSubtreeIds(parentIds); + private Set currentDomainHardDeleteSubtree() { + Set deletingDomainIds = domainHardDeleteSubtree.get(); + if (deletingDomainIds == null) { + throw new IllegalStateException("Domain hard-delete subtree context is not initialized"); + } + return deletingDomainIds; } private Set collectDomainSubtreeIds(List rootIds) { @@ -551,7 +570,7 @@ private Set collectDomainSubtreeIds(List rootIds) { return domainIds; } - private Set retainedSharedDataProductIds( + private RetainedDataProductCascadePlan retainedSharedDataProductCascadePlan( List dataProductIds, Set deletingDomainIds) { Map> deletingParentsByDataProduct = new HashMap<>(); Set retainedDataProductIds = new HashSet<>(); @@ -572,9 +591,7 @@ private Set retainedSharedDataProductIds( } }); - detachRetainedDataProductsFromDeletingDomains( - retainedDataProductIds, deletingParentsByDataProduct); - return retainedDataProductIds; + return new RetainedDataProductCascadePlan(retainedDataProductIds, deletingParentsByDataProduct); } private void detachRetainedDataProductsFromDeletingDomains( diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java index 418a1f27d842..862c72e8f602 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java @@ -4666,10 +4666,12 @@ protected void deleteChildren(UUID id, boolean recursive, boolean hardDelete, St if (!recursive) { throw new IllegalArgumentException(CatalogExceptionMessage.entityIsNotEmpty(entityType)); } - childrenRecords = filterChildrenForDeleteCascade(id, childrenRecords); - if (childrenRecords.isEmpty()) { - LOG.debug("No children to delete for {} {} after cascade filtering", entityType, id); - return; + if (hardDelete) { + childrenRecords = prepareChildrenForHardDeleteCascade(id, childrenRecords); + if (childrenRecords.isEmpty()) { + LOG.debug("No children to delete for {} {} after hard-delete preparation", entityType, id); + return; + } } // Delete all the contained entities deleteChildren(childrenRecords, hardDelete, updatedBy); @@ -6390,6 +6392,14 @@ private void runRestoreAdditionalChildren(List entities, String updatedBy) { */ private void dispatchToContainedChildren( List parents, String phaseName, BiConsumer, List> dispatcher) { + dispatchToContainedChildren(parents, phaseName, dispatcher, false); + } + + private void dispatchToContainedChildren( + List parents, + String phaseName, + BiConsumer, List> dispatcher, + boolean hardDelete) { List parentIds = new ArrayList<>(parents.size()); for (T parent : parents) { parentIds.add(parent.getId().toString()); @@ -6399,7 +6409,9 @@ private void dispatchToContainedChildren( relationships = daoCollection.relationshipDAO().findToBatchAllTypes(parentIds, SUBTREE_RELATIONS, ALL); } - relationships = filterChildrenForDeleteCascade(parents, relationships); + if (hardDelete) { + relationships = prepareChildrenForHardDeleteCascade(parents, relationships); + } if (relationships.isEmpty()) { return; } @@ -6422,16 +6434,20 @@ private void dispatchToContainedChildren( } } - protected List filterChildrenForDeleteCascade( + protected List prepareChildrenForHardDeleteCascade( UUID parentId, List children) { return children; } - protected List filterChildrenForDeleteCascade( + protected List prepareChildrenForHardDeleteCascade( List parents, List children) { return children; } + protected Runnable enterBulkHardDeleteCascade(List entities) { + return () -> {}; + } + /** * Hook called once per restored entity for repositories that have non-CONTAINS related * entities that need to be restored alongside the parent. Default: no-op. @@ -6585,38 +6601,44 @@ public final void bulkHardDeleteSubtree(List ids, String updatedBy) { if (entities.isEmpty()) { return; } - // Populate relation fields up front so the same subclass hooks the legacy - // Entity.deleteEntity path called against a fully-loaded entity (e.g., - // TestCaseRepository.updateTestSuite reading testCase.getTestSuite()) see the - // expected shape. bulkCleanupReferences wipes these relationship rows later, so - // hooks running after that point must remain null-safe. - populateRelationFields(entities); - for (T entity : entities) { - checkSystemEntityDeletion(entity); - preDelete(entity, updatedBy); - } - dispatchToContainedChildren( - entities, - "bulkHardDeleteFindChildren", - (childRepo, childIds) -> childRepo.bulkHardDeleteSubtree(childIds, updatedBy)); - bulkEntitySpecificCleanup(entities); - // Run BEFORE bulkCleanupReferences: hooks like DashboardRepository.cascadeChartCleanup - // walk HAS relationships to discover linked entities, and bulkCleanupReferences wipes - // those relationship rows. - runHardDeleteAdditionalChildren(entities, updatedBy); - bulkCleanupReferences(entities); - bulkDeleteEntityRows(entities); - bulkInvalidate(entities); - for (T entity : entities) { - postDelete(entity, true); - // Fire deleteFromSearch per-entity so cascade-deleted descendants are removed from - // Elasticsearch. The legacy per-entity Entity.deleteEntity path invoked this via - // delete()'s top-level dispatch — this bulk replacement is the only path that walks - // cascaded children now, so a missing call leaves stale ES docs that surface as - // duplicate results (e.g. Playwright Domains.spec.ts:533 found two "PW_DataProduct_ - // Sales" rows after a recursive Domain hard-delete because the DB row was gone but - // the search-index doc lingered). - deleteFromSearch(entity, true); + Runnable exitHardDeleteCascade = enterBulkHardDeleteCascade(entities); + try { + // Populate relation fields up front so the same subclass hooks the legacy + // Entity.deleteEntity path called against a fully-loaded entity (e.g., + // TestCaseRepository.updateTestSuite reading testCase.getTestSuite()) see the + // expected shape. bulkCleanupReferences wipes these relationship rows later, so + // hooks running after that point must remain null-safe. + populateRelationFields(entities); + for (T entity : entities) { + checkSystemEntityDeletion(entity); + preDelete(entity, updatedBy); + } + dispatchToContainedChildren( + entities, + "bulkHardDeleteFindChildren", + (childRepo, childIds) -> childRepo.bulkHardDeleteSubtree(childIds, updatedBy), + true); + bulkEntitySpecificCleanup(entities); + // Run BEFORE bulkCleanupReferences: hooks like DashboardRepository.cascadeChartCleanup + // walk HAS relationships to discover linked entities, and bulkCleanupReferences wipes + // those relationship rows. + runHardDeleteAdditionalChildren(entities, updatedBy); + bulkCleanupReferences(entities); + bulkDeleteEntityRows(entities); + bulkInvalidate(entities); + for (T entity : entities) { + postDelete(entity, true); + // Fire deleteFromSearch per-entity so cascade-deleted descendants are removed from + // Elasticsearch. The legacy per-entity Entity.deleteEntity path invoked this via + // delete()'s top-level dispatch — this bulk replacement is the only path that walks + // cascaded children now, so a missing call leaves stale ES docs that surface as + // duplicate results (e.g. Playwright Domains.spec.ts:533 found two "PW_DataProduct_ + // Sales" rows after a recursive Domain hard-delete because the DB row was gone but + // the search-index doc lingered). + deleteFromSearch(entity, true); + } + } finally { + exitHardDeleteCascade.run(); } } From 9eaa6d3c8060956255a9ab5569910756fe08e0ec Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 17 Jun 2026 16:43:39 -0700 Subject: [PATCH 4/5] Fix i18n sync --- .../resources/ui/src/locale/languages/sv-se.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json index 7a57553f15f8..e1bbe137cf8a 100644 --- a/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json +++ b/openmetadata-ui/src/main/resources/ui/src/locale/languages/sv-se.json @@ -2288,6 +2288,7 @@ "test-entity": "Testa {{entity}}", "test-level-lowercase": "testnivå", "test-library": "Testbibliotek", + "test-login": "Test Login", "test-platform-plural": "Testplattformar", "test-plural": "Tester", "test-plural-type": "{{type}}-tester", @@ -3462,7 +3463,18 @@ "special-character-not-allowed": "Specialtecken är inte tillåtna.", "sql-query-tooltip": "Frågor som returnerar en eller flera rader leder till att testet misslyckas.", "sso-configuration-directly-from-the-ui": "SSO-konfiguration direkt från användargränssnittet", + "sso-configuration-test-failed-description": "Validation failed. Please review your configuration and try again.", + "sso-configuration-test-failed-with-count": "Validation failed with {{count}} error(s). Review the highlighted fields below and try again.", + "sso-configuration-test-success": "Your SSO configuration is valid and reachable. You can save it when you're ready.", + "sso-new-config-save-warning": "Saving a new SSO configuration will sign you out and redirect you to the login page. Test your configuration first to avoid being locked out.", "sso-provider-not-supported": "SSO-leverantören {{provider}} stöds inte.", + "sso-test-login-description": "Sign in with your identity provider using these unsaved settings. This runs in a separate window and never changes your current session.", + "sso-test-login-error": "The sign-in could not be validated due to a server error. Please try again.", + "sso-test-login-failed": "Sign-in completed, but this configuration would reject the login.", + "sso-test-login-no-token": "No token was returned from the identity provider.", + "sso-test-login-popup-failed": "The test sign-in was cancelled or could not be completed. Please try again.", + "sso-test-login-success": "You signed in successfully as {{email}}. These settings are safe to save.", + "sso-test-login-waiting": "Complete the sign-in in the popup window to continue.", "stage-file-location-message": "Tillfälligt filnamn för att lagra frågeloggarna före bearbetning. Absolut filsökväg krävs.", "star-on-github-description": "Gillar du {{brandName}}? Dina GitHub-stjärnor hjälper communityn att växa sig starkare! Stjärnmärk oss idag, sprid kärleken och låt oss bygga framtiden för metadata tillsammans! 🚀", "starting-offset-description": "Den initiala offseten för händelseprenumerationen när den började bearbeta.", From 0ad0ba23de45eb3ada22712be9dde2c7510c0cec Mon Sep 17 00:00:00 2001 From: Sriharsha Chintalapani Date: Wed, 17 Jun 2026 16:57:31 -0700 Subject: [PATCH 5/5] Preserve multi-domain rule state in tests --- .../org/openmetadata/it/tests/DataProductResourceIT.java | 3 ++- .../openmetadata/it/tests/DatabaseServiceResourceIT.java | 5 +---- .../java/org/openmetadata/it/tests/TableResourceIT.java | 8 +++++--- .../java/org/openmetadata/it/util/EntityRulesUtil.java | 4 ++++ 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java index b14d789668be..d0268f7ba50f 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DataProductResourceIT.java @@ -1248,13 +1248,14 @@ void test_deleteOneDomainForMultiDomainDataProduct_preservesDataProductUntilLast private DataProduct updateDataProductDomainsWithMultiDomainRuleDisabled( OpenMetadataClient client, DataProduct dataProduct, Domain... domains) { + boolean originalRuleState = EntityRulesUtil.isMultiDomainRuleEnabled(client); EntityRulesUtil.toggleMultiDomainRule(client, false); try { DataProduct update = client.dataProducts().get(dataProduct.getId().toString(), "domains"); update.setDomains(List.of(domains).stream().map(Domain::getEntityReference).toList()); return client.dataProducts().update(dataProduct.getId().toString(), update); } finally { - EntityRulesUtil.toggleMultiDomainRule(client, true); + EntityRulesUtil.toggleMultiDomainRule(client, originalRuleState); } } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DatabaseServiceResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DatabaseServiceResourceIT.java index 5d48c141a479..d74ccd7c682e 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DatabaseServiceResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DatabaseServiceResourceIT.java @@ -855,11 +855,8 @@ private CsvImportResult importCsvRecursive(String entityName, String csvData, bo void test_csvImportEntityRuleValidation(TestNamespace ns) throws IOException, InterruptedException { - final String MULTI_DOMAIN_RULE = "Multiple Domains are not allowed"; - // Check if rule is currently enabled and store original state - boolean originalRuleState = - EntityRulesUtil.isRuleEnabled(SdkClients.adminClient(), MULTI_DOMAIN_RULE); + boolean originalRuleState = EntityRulesUtil.isMultiDomainRuleEnabled(SdkClients.adminClient()); try { // Enable the multi-domain rule for testing diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java index 795d19bd376a..97b88f78c222 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TableResourceIT.java @@ -3918,7 +3918,9 @@ void test_multipleDomainInheritance(TestNamespace ns) throws Exception { // Try to disable multi-domain rule for this test boolean rulesAvailable = false; + boolean originalRuleState = false; try { + originalRuleState = EntityRulesUtil.isMultiDomainRuleEnabled(client); EntityRulesUtil.toggleMultiDomainRule(client, false); rulesAvailable = true; } catch (Exception e) { @@ -4032,13 +4034,13 @@ void test_multipleDomainInheritance(TestNamespace ns) throws Exception { return searchResponse.contains("\"id\":\"" + tableId + "\""); }); } finally { - // Re-enable multi-domain rule after test (only if we successfully disabled it) + // Restore multi-domain rule after test (only if we successfully disabled it) if (rulesAvailable) { try { - EntityRulesUtil.toggleMultiDomainRule(client, true); + EntityRulesUtil.toggleMultiDomainRule(client, originalRuleState); } catch (Exception e) { // Ignore - test is finishing anyway - System.out.println("Could not re-enable multi-domain rule: " + e.getMessage()); + System.out.println("Could not restore multi-domain rule: " + e.getMessage()); } } } diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/util/EntityRulesUtil.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/util/EntityRulesUtil.java index 6b4fbe2ca8bd..2c9c22f2f888 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/util/EntityRulesUtil.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/util/EntityRulesUtil.java @@ -29,6 +29,10 @@ public static Settings toggleMultiDomainRule(OpenMetadataClient client, boolean return toggleRule(client, MULTI_DOMAIN_RULE_NAME, enableRule); } + public static boolean isMultiDomainRuleEnabled(OpenMetadataClient client) { + return isRuleEnabled(client, MULTI_DOMAIN_RULE_NAME); + } + /** * Toggle any entity rule by name. *