diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java index c784ae89c55e..9092e1318301 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/BaseEntityIT.java @@ -33,6 +33,7 @@ import org.openmetadata.it.util.TestNamespaceExtension; import org.openmetadata.it.util.UpdateType; import org.openmetadata.schema.EntityInterface; +import org.openmetadata.schema.ServiceEntityInterface; import org.openmetadata.schema.api.policies.CreatePolicy; import org.openmetadata.schema.api.teams.CreateRole; import org.openmetadata.schema.api.teams.CreateUser; @@ -45,7 +46,10 @@ import org.openmetadata.schema.entity.teams.User; import org.openmetadata.schema.type.ApiStatus; import org.openmetadata.schema.type.ChangeDescription; +import org.openmetadata.schema.type.EntityReference; +import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.MetadataOperation; +import org.openmetadata.schema.type.Relationship; import org.openmetadata.schema.type.TagLabel; import org.openmetadata.schema.type.api.BulkOperationResult; import org.openmetadata.schema.type.api.BulkResponse; @@ -58,6 +62,10 @@ import org.openmetadata.sdk.services.policies.PolicyService; import org.openmetadata.sdk.services.teams.RoleService; import org.openmetadata.sdk.services.teams.UserService; +import org.openmetadata.service.Entity; +import org.openmetadata.service.jdbi3.CollectionDAO; +import org.openmetadata.service.jdbi3.EntityDAO; +import org.openmetadata.service.jdbi3.EntityRepository; import org.openmetadata.service.util.TestUtils; /** @@ -1048,6 +1056,76 @@ void get_entityListWithInvalidPaginationCursors_4xx(TestNamespace ns) { "Listing with invalid before cursor should fail"); } + /** + * Regression for the flaky {@code Entity not found: } failure on the LIST path (seen + * in CI on {@code ContainerResourceIT}/{@code MlModelResourceIT}). A list resolves every row's + * parent service in bulk, in a statement separate from the relationship lookup; when a sibling + * test cascade-hard-deletes the service in between, the {@code CONTAINS} relationship row is still + * seen but the service entity row is already gone. A non-lenient bulk resolver then threw {@code + * EntityNotFoundException} and failed the whole list instead of the single affected row. + * + *

Runs for every entity directly contained by a service this test created (skips entities with + * no owned service parent). Reproduces the window deterministically: delete only the service + * entity row (the {@code CONTAINS} relationship survives) and evict it from the entity cache — + * exactly the state a concurrent cascade delete leaves mid-flight — then list scoped by the + * now-dangling service FQN. The list must succeed; the bulk resolver must tolerate the deleted + * service rather than fail the whole page. + */ + @Test + void list_toleratesConcurrentlyHardDeletedService(TestNamespace ns) { + T entity = createEntity(createMinimalRequest(ns)); + EntityReference serviceRef = findOwnedServiceParent(ns, entity); + Assumptions.assumeTrue( + serviceRef != null, + getEntityType() + " is not directly contained by a service it owns; tolerance test N/A"); + + String serviceType = serviceRef.getType(); + EntityDAO serviceDao = Entity.getEntityRepository(serviceType).getDao(); + int rowsRemoved = serviceDao.delete(serviceDao.getTableName(), serviceRef.getId()); + assertEquals(1, rowsRemoved, "Setup: should have removed exactly the service entity row"); + EntityRepository.invalidateCacheForEntity( + serviceType, serviceRef.getId(), serviceRef.getFullyQualifiedName()); + + org.openmetadata.sdk.models.ListParams params = new org.openmetadata.sdk.models.ListParams(); + params.setService(serviceRef.getFullyQualifiedName()); + params.setLimit(1000); + + org.openmetadata.sdk.models.ListResponse response = listEntities(params); + assertNotNull(response); + assertNotNull( + response.getData(), + "List must tolerate the concurrently hard-deleted service, not fail the whole page"); + } + + /** + * Find this entity's immediate {@code CONTAINS} parent that is a service AND was created by this + * test (tracked as a namespace root) so deleting it can never disturb a shared fixture. Returns + * {@code null} when the entity is not directly service-scoped or its parent service is not owned + * here. + */ + private EntityReference findOwnedServiceParent(TestNamespace ns, T entity) { + EntityReference serviceRef = null; + List parents = + Entity.getCollectionDAO() + .relationshipDAO() + .findFrom(entity.getId(), getEntityType(), Relationship.CONTAINS.ordinal()); + for (CollectionDAO.EntityRelationshipRecord parent : parents) { + boolean ownedByThisTest = + ns.trackedRoots().stream().anyMatch(root -> root.id().equals(parent.getId())); + if (ownedByThisTest && isServiceEntity(parent.getType(), parent.getId())) { + serviceRef = Entity.getEntityReferenceById(parent.getType(), parent.getId(), Include.ALL); + break; + } + } + return serviceRef; + } + + private boolean isServiceEntity(String entityType, UUID id) { + EntityInterface candidate = + Entity.getEntity(new EntityReference().withId(id).withType(entityType), "", Include.ALL); + return candidate instanceof ServiceEntityInterface; + } + @Test void get_entityWithInvalidFields_4xx(TestNamespace ns) { if (!supportsFieldsQueryParam) return; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java index b843e6773b39..dec7b035a231 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/Entity.java @@ -527,6 +527,30 @@ public static EntityReference getEntityReferenceById( return repository.getReference(id, include); } + /** + * Lenient variant of {@link #getEntityReferenceById(String, UUID, Include)} that returns {@code + * null} instead of throwing when the referenced entity was concurrently hard-deleted. A bulk read + * path resolves a relationship's target entity in a statement separate from the relationship + * lookup; if the target is hard-deleted in between (e.g. a sibling test cascade-deleting a parent + * service), the relationship row was seen but the entity row is already gone. Returning {@code + * null} there — rather than 500'ing the whole list with "Entity not found: <type> + * <id>" — mirrors {@link EntityRepository#getFromEntityRef}'s existing single-entity + * tolerance and the batch resolver {@link #getEntityReferencesByIds}, whose underlying + * {@code findEntitiesByIds} already omits concurrently-deleted rows. + */ + public static EntityReference getEntityReferenceByIdOrNull( + @NonNull String entityType, @NonNull UUID id, Include include) { + EntityReference reference; + try { + reference = getEntityReferenceById(entityType, id, include); + } catch (EntityNotFoundException e) { + LOG.debug( + "Skipping concurrently-deleted reference {} {}: {}", entityType, id, e.getMessage()); + reference = null; + } + return reference; + } + public static List getEntityReferencesByIds( @NonNull String entityType, @NonNull List ids, Include include) { // Check if this is a time-series entity diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java index 7ff7a0b54589..e01e22fa49d4 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/ContainerRepository.java @@ -8,7 +8,7 @@ import static org.openmetadata.service.Entity.FIELD_PARENT; import static org.openmetadata.service.Entity.FIELD_TAGS; import static org.openmetadata.service.Entity.STORAGE_SERVICE; -import static org.openmetadata.service.Entity.getEntityReferenceById; +import static org.openmetadata.service.Entity.getEntityReferenceByIdOrNull; import static org.openmetadata.service.resources.tags.TagLabelUtil.addDerivedTagsGracefully; import static org.openmetadata.service.resources.tags.TagLabelUtil.addDerivedTagsWithPreFetched; import static org.openmetadata.service.resources.tags.TagLabelUtil.batchFetchDerivedTags; @@ -187,9 +187,11 @@ private Map batchFetchParents(List containers) // Only consider container parents, not service parents if (CONTAINER.equals(record.getFromEntity())) { EntityReference parentRef = - getEntityReferenceById( + getEntityReferenceByIdOrNull( record.getFromEntity(), UUID.fromString(record.getFromId()), NON_DELETED); - parentsMap.put(containerId, parentRef); + if (parentRef != null) { + parentsMap.put(containerId, parentRef); + } } } @@ -226,8 +228,10 @@ private Map> batchFetchChildren(List cont for (CollectionDAO.EntityRelationshipObject record : records) { UUID parentId = UUID.fromString(record.getFromId()); EntityReference childRef = - getEntityReferenceById(CONTAINER, UUID.fromString(record.getToId()), NON_DELETED); - childrenMap.get(parentId).add(childRef); + getEntityReferenceByIdOrNull(CONTAINER, UUID.fromString(record.getToId()), NON_DELETED); + if (childRef != null) { + childrenMap.get(parentId).add(childRef); + } } return childrenMap; @@ -273,8 +277,10 @@ private Map batchFetchServices(List containers UUID serviceId = UUID.fromString(record.getFromId()); EntityReference serviceRef = serviceRefById.computeIfAbsent( - serviceId, id -> getEntityReferenceById(STORAGE_SERVICE, id, NON_DELETED)); - serviceMap.put(containerId, serviceRef); + serviceId, id -> getEntityReferenceByIdOrNull(STORAGE_SERVICE, id, NON_DELETED)); + if (serviceRef != null) { + serviceMap.put(containerId, serviceRef); + } } return serviceMap; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DirectoryRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DirectoryRepository.java index 99cbecaa13fb..92425c068a1e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DirectoryRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/DirectoryRepository.java @@ -27,6 +27,7 @@ import static org.openmetadata.service.Entity.FILE; import static org.openmetadata.service.Entity.SPREADSHEET; import static org.openmetadata.service.Entity.getEntityReferenceById; +import static org.openmetadata.service.Entity.getEntityReferenceByIdOrNull; import java.io.IOException; import java.util.ArrayList; @@ -281,8 +282,11 @@ private Map batchFetchFromByType( UUID fromId = UUID.fromString(record.getFromId()); EntityReference ref = refById.computeIfAbsent( - fromId, id -> getEntityReferenceById(fromEntityType, id, Include.NON_DELETED)); - resultMap.put(directoryId, ref); + fromId, + id -> getEntityReferenceByIdOrNull(fromEntityType, id, Include.NON_DELETED)); + if (ref != null) { + resultMap.put(directoryId, ref); + } } } return resultMap; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/IngestionPipelineRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/IngestionPipelineRepository.java index b9e83c29a0cd..a8a2ec1cd854 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/IngestionPipelineRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/IngestionPipelineRepository.java @@ -274,9 +274,11 @@ private Map batchFetchServices(List pi for (CollectionDAO.EntityRelationshipObject record : records) { UUID pipelineId = UUID.fromString(record.getToId()); EntityReference serviceRef = - Entity.getEntityReferenceById( + Entity.getEntityReferenceByIdOrNull( record.getFromEntity(), UUID.fromString(record.getFromId()), Include.NON_DELETED); - serviceMap.put(pipelineId, serviceRef); + if (serviceRef != null) { + serviceMap.put(pipelineId, serviceRef); + } } return serviceMap; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LLMModelRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LLMModelRepository.java index c3d9738afb98..5cf9174e8a6a 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LLMModelRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/LLMModelRepository.java @@ -104,9 +104,11 @@ private Map batchFetchServices(List llmModels) for (CollectionDAO.EntityRelationshipObject record : records) { UUID llmModelId = UUID.fromString(record.getToId()); EntityReference serviceRef = - Entity.getEntityReferenceById( + Entity.getEntityReferenceByIdOrNull( Entity.LLM_SERVICE, UUID.fromString(record.getFromId()), NON_DELETED); - serviceMap.put(llmModelId, serviceRef); + if (serviceRef != null) { + serviceMap.put(llmModelId, serviceRef); + } } return serviceMap; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java index b134ac2e6591..5925af55a0bc 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/MlModelRepository.java @@ -195,9 +195,11 @@ private Map batchFetchServices(List mlModels) { for (CollectionDAO.EntityRelationshipObject record : records) { UUID mlModelId = UUID.fromString(record.getToId()); EntityReference serviceRef = - Entity.getEntityReferenceById( + Entity.getEntityReferenceByIdOrNull( Entity.MLMODEL_SERVICE, UUID.fromString(record.getFromId()), NON_DELETED); - serviceMap.put(mlModelId, serviceRef); + if (serviceRef != null) { + serviceMap.put(mlModelId, serviceRef); + } } return serviceMap; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java index 8684fed27e82..1830b8d84fba 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/PipelineRepository.java @@ -972,9 +972,11 @@ private Map batchFetchServices(List pipelines) for (CollectionDAO.EntityRelationshipObject record : records) { UUID pipelineId = UUID.fromString(record.getToId()); EntityReference serviceRef = - Entity.getEntityReferenceById( + Entity.getEntityReferenceByIdOrNull( Entity.PIPELINE_SERVICE, UUID.fromString(record.getFromId()), NON_DELETED); - serviceMap.put(pipelineId, serviceRef); + if (serviceRef != null) { + serviceMap.put(pipelineId, serviceRef); + } } return serviceMap; diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java index d388487e11fa..f66cc7f020a9 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/SearchIndexRepository.java @@ -227,9 +227,11 @@ private Map batchFetchServices(List searchIn if (Entity.SEARCH_SERVICE.equals(record.getFromEntity())) { UUID searchIndexId = UUID.fromString(record.getToId()); EntityReference serviceRef = - Entity.getEntityReferenceById( + Entity.getEntityReferenceByIdOrNull( Entity.SEARCH_SERVICE, UUID.fromString(record.getFromId()), Include.NON_DELETED); - serviceMap.put(searchIndexId, serviceRef); + if (serviceRef != null) { + serviceMap.put(searchIndexId, serviceRef); + } } } diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java index 3768074fa9bf..ca1ae2ef6b6e 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/TopicRepository.java @@ -573,9 +573,11 @@ private Map batchFetchServices(List topics) { record -> { var topicId = UUID.fromString(record.getToId()); var serviceRef = - Entity.getEntityReferenceById( + Entity.getEntityReferenceByIdOrNull( Entity.MESSAGING_SERVICE, UUID.fromString(record.getFromId()), NON_DELETED); - serviceMap.put(topicId, serviceRef); + if (serviceRef != null) { + serviceMap.put(topicId, serviceRef); + } }); return serviceMap;