diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java index e5507bee3ad0..92b487325e8a 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/bootstrap/TestSuiteBootstrap.java @@ -106,7 +106,57 @@ public class TestSuiteBootstrap implements LauncherSessionListener { private static final Integer ELASTIC_BATCH_SIZE = 10; private static final IndexMappingLanguage ELASTIC_SEARCH_INDEX_MAPPING_LANGUAGE = IndexMappingLanguage.EN; - private static final String ELASTIC_SEARCH_CLUSTER_ALIAS = "openmetadata"; + + /** + * Pattern allowed for {@code -DclusterAlias} overrides — must be a valid OpenSearch / + * Elasticsearch index name prefix (lowercase alphanumeric, underscore, or hyphen; must start + * with a letter or digit; max 63 chars). + * + *

Declared before {@link #ELASTIC_SEARCH_CLUSTER_ALIAS} on purpose: static fields + * initialize in declaration order, and {@link #resolveClusterAlias()} reads this pattern. If + * this declaration moved below, override validation would NPE on the only path that uses it. + */ + private static final java.util.regex.Pattern CLUSTER_ALIAS_PATTERN = + java.util.regex.Pattern.compile("[a-z0-9][a-z0-9_\\-]{0,62}"); + + /** + * Cluster alias used as the prefix for all search indices in this test session. + * + *

The OpenSearch / Elasticsearch testcontainer is shared across the entire JUnit launcher + * session (single static container, see {@link #SEARCH_CONTAINER}). When tests run in parallel + * (the {@code parallel-tests} profile sets {@code junit.jupiter.execution.parallel.enabled=true} + * and {@code reuseForks=true} keeps everything in one JVM), every test reads and writes against + * the same set of indices. {@link org.openmetadata.it.util.TestNamespace} only isolates entity + * FQNs in the database — it does not isolate documents in the search index. + * + *

To prevent cross-test pollution between concurrent CI runs that share the cluster, the alias + * is randomized per session by default so each session writes to its own {@code _*} + * indices. Set {@code -DclusterAlias=openmetadata} (or any fixed value matching {@link + * #CLUSTER_ALIAS_PATTERN}) to pin the alias for reproducible debugging. + */ + private static final String ELASTIC_SEARCH_CLUSTER_ALIAS = resolveClusterAlias(); + + private static String resolveClusterAlias() { + String override = System.getProperty("clusterAlias"); + if (override == null || override.isBlank()) { + return "omtest_" + java.util.UUID.randomUUID().toString().replace("-", "").substring(0, 8); + } + String normalized = override.trim().toLowerCase(java.util.Locale.ROOT); + if (!CLUSTER_ALIAS_PATTERN.matcher(normalized).matches()) { + throw new IllegalArgumentException( + "Invalid -DclusterAlias='" + + override + + "'. Must match " + + CLUSTER_ALIAS_PATTERN.pattern() + + " (lowercase alphanumeric, underscore, or hyphen; must start with a letter or" + + " digit; max 63 chars) so it forms a valid OpenSearch/Elasticsearch index prefix."); + } + return normalized; + } + + public static String getClusterAlias() { + return ELASTIC_SEARCH_CLUSTER_ALIAS; + } // Default images (can be overridden by system properties) private static final String DEFAULT_POSTGRES_IMAGE = "postgres:15"; @@ -164,6 +214,7 @@ public void launcherSessionOpened(LauncherSession session) { LOG.info("=== TestSuiteBootstrap: Starting test infrastructure ==="); LOG.info("Database type: {}", databaseType); LOG.info("Search type: {}", searchType); + LOG.info("Search cluster alias: {}", ELASTIC_SEARCH_CLUSTER_ALIAS); LOG.info("RDF enabled: {}", rdfEnabled); LOG.info("Cache provider: {}", cacheProvider); boolean k8sEnabled = isK8sTestsRequested(); 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 065f9df593e0..f9e000601ac1 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 @@ -5312,12 +5312,20 @@ void deleteTagAndCheckRelationshipsInSearch(TestNamespace ns) throws Exception { } /** - * Search for a specific entity by ID. - * Subclasses should override to use entity-specific search. + * Search for a specific entity by ID. Uses the {@code id.keyword} subfield so the lookup is an + * exact-match term query: the {@code id} field is mapped as {@code text} on every entity index + * and the analyzer tokenises UUIDs on dashes/digits, which makes a plain {@code id:} + * query a tokenised match that ranks-and-trims under {@code size=1} and races other docs with + * overlapping hex tokens. Subclasses should override to use entity-specific search. */ protected String searchForEntity(String entityId) throws Exception { OpenMetadataClient client = SdkClients.adminClient(); - return client.search().query("id:" + entityId).index(getSearchIndexName()).size(1).execute(); + return client + .search() + .query("id.keyword:" + entityId) + .index(getSearchIndexName()) + .size(1) + .execute(); } protected String searchEntities() throws Exception { 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 5a046a51688f..8278057023e4 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 @@ -853,7 +853,7 @@ private Map getAllDataProductsWithAssetsCount() throws Exceptio private List getEntityReferencesFromSearchIndex( UUID entityId, String indexName, String fieldName) throws Exception { - String query = "id:" + entityId.toString(); + String query = "id.keyword:" + entityId.toString(); String searchResponse = SdkClients.adminClient().search().query(query).index(indexName).execute(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DomainResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DomainResourceIT.java index c7c5cd4a90d9..c347582e7523 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DomainResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/DomainResourceIT.java @@ -474,7 +474,7 @@ private void verifyDomainInSearch(String expectedFqn, String domainId) { String searchResponse = client .search() - .query("id:" + domainId) + .query("id.keyword:" + domainId) .index("domain_search_index") .size(1) .execute(); @@ -897,7 +897,7 @@ void test_renameDomainDoesNotMatchSimilarNames(TestNamespace ns) throws Exceptio String searchResponse = client .search() - .query("id:" + child.getId()) + .query("id.keyword:" + child.getId()) .index("domain_search_index") .size(1) .execute(); @@ -1090,7 +1090,7 @@ void test_renameDomainDoesNotAffectSimilarPrefixDomains(TestNamespace ns) throws String searchResponse = client .search() - .query("id:" + child.getId()) + .query("id.keyword:" + child.getId()) .index("domain_search_index") .size(1) .execute(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java index 7c6107efe2ef..a258085ba262 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryOntologyExportIT.java @@ -427,12 +427,16 @@ private HttpResponse exportGlossaryRaw( default -> TURTLE_CONTENT_TYPE; }; + // RDF/XML serialization in Jena is significantly slower than Turtle/N-Triples/JSON-LD + // (O(N^2)-ish QName resolution) and contends with Quartz/WorkflowEventConsumer daemon + // threads that @Isolated does not stop. Observed ~69s server time in CI for what is + // typically a sub-second call; 180s gives headroom without masking real hangs. HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .header("Authorization", "Bearer " + token) .header("Accept", acceptHeader) - .timeout(Duration.ofSeconds(60)) + .timeout(Duration.ofSeconds(180)) .GET() .build(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermResourceIT.java index 1d6a8108c9b6..0e1e80932dd4 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/GlossaryTermResourceIT.java @@ -1085,40 +1085,43 @@ void patch_addDeleteReviewers(TestNamespace ns) { @Test void patch_addDeleteReferences(TestNamespace ns) { - OpenMetadataClient client = SdkClients.adminClient(); Glossary glossary = getOrCreateGlossary(ns); - // Create term without references CreateGlossaryTerm request = new CreateGlossaryTerm() .withName(ns.prefix("term_references")) .withGlossary(glossary.getFullyQualifiedName()) .withDescription("Term for reference patch test"); GlossaryTerm term = createEntity(request); + String termId = term.getId().toString(); - // Add reference + // Refresh local state before each patch so the JSON diff does not accidentally include an + // entityStatus transition driven by the async GlossaryTermApprovalWorkflow (which can move + // the server-side status to IN_REVIEW between calls and would otherwise trip the reviewer + // check in EntityRepository.checkUpdatedByReviewer). org.openmetadata.schema.api.data.TermReference ref1 = new org.openmetadata.schema.api.data.TermReference() .withName("reference1") .withEndpoint(java.net.URI.create("http://reference1.example.com")); + term = getEntity(termId); term.setReferences(List.of(ref1)); - GlossaryTerm updated = patchEntity(term.getId().toString(), term); + GlossaryTerm updated = patchEntity(termId, term); assertNotNull(updated.getReferences()); assertEquals(1, updated.getReferences().size()); - // Add another reference org.openmetadata.schema.api.data.TermReference ref2 = new org.openmetadata.schema.api.data.TermReference() .withName("reference2") .withEndpoint(java.net.URI.create("http://reference2.example.com")); + updated = getEntity(termId); updated.setReferences(List.of(ref1, ref2)); - GlossaryTerm updated2 = patchEntity(updated.getId().toString(), updated); + GlossaryTerm updated2 = patchEntity(termId, updated); assertNotNull(updated2.getReferences()); assertEquals(2, updated2.getReferences().size()); - // Remove a reference + updated2 = getEntity(termId); updated2.setReferences(List.of(ref2)); - GlossaryTerm updated3 = patchEntity(updated2.getId().toString(), updated2); + GlossaryTerm updated3 = patchEntity(termId, updated2); assertNotNull(updated3.getReferences()); assertEquals(1, updated3.getReferences().size()); } @@ -1400,7 +1403,7 @@ void test_glossaryTermStatusTransitionUpdatesSearchIndex(TestNamespace ns) { String response = client .search() - .query("id:" + updated.getId()) + .query("id.keyword:" + updated.getId()) .index("glossary_term_search_index") .size(5) .execute(); @@ -2265,7 +2268,6 @@ void test_glossaryTermVersionIncrement(TestNamespace ns) { @Test void test_glossaryTermReviewersMultipleUpdates(TestNamespace ns) { - OpenMetadataClient client = SdkClients.adminClient(); Glossary glossary = getOrCreateGlossary(ns); CreateGlossaryTerm request = @@ -2274,20 +2276,28 @@ void test_glossaryTermReviewersMultipleUpdates(TestNamespace ns) { .withGlossary(glossary.getFullyQualifiedName()) .withDescription("Term for multiple reviewer updates"); GlossaryTerm term = createEntity(request); + String termId = term.getId().toString(); + // Refresh local state before each patch. The async GlossaryTermApprovalWorkflow promotes + // entityStatus DRAFT -> IN_REVIEW once reviewers exist; sending a stale local copy causes + // the JSON diff to include an entityStatus transition that trips + // EntityRepository.checkUpdatedByReviewer (admin is not in the reviewer list). + term = getEntity(termId); term.setReviewers(List.of(testUser1().getEntityReference())); - GlossaryTerm updated1 = patchEntity(term.getId().toString(), term); + GlossaryTerm updated1 = patchEntity(termId, term); assertNotNull(updated1.getReviewers()); assertEquals(1, updated1.getReviewers().size()); + updated1 = getEntity(termId); updated1.setReviewers( List.of(testUser1().getEntityReference(), testUser2().getEntityReference())); - GlossaryTerm updated2 = patchEntity(updated1.getId().toString(), updated1); + GlossaryTerm updated2 = patchEntity(termId, updated1); assertNotNull(updated2.getReviewers()); assertTrue(updated2.getReviewers().size() >= 2); + updated2 = getEntity(termId); updated2.setReviewers(List.of(testUser2().getEntityReference())); - GlossaryTerm updated3 = patchEntity(updated2.getId().toString(), updated2); + GlossaryTerm updated3 = patchEntity(termId, updated2); assertNotNull(updated3.getReviewers()); assertEquals(1, updated3.getReviewers().size()); } @@ -3096,7 +3106,7 @@ void test_glossaryTermSearchIndexUpdatedWhenGlossaryOwnerChanges(TestNamespace n String response = client .search() - .query("id:" + termId) + .query("id.keyword:" + termId) .index("glossary_term_search_index") .size(1) .execute(); @@ -3151,7 +3161,7 @@ void test_glossaryTermSearchIndexUpdatedWhenGlossaryReviewerChanges(TestNamespac String response = client .search() - .query("id:" + termId) + .query("id.keyword:" + termId) .index("glossary_term_search_index") .size(1) .execute(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndexTemplateIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndexTemplateIT.java index b4d7d892e29a..1bf631318f4a 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndexTemplateIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/IndexTemplateIT.java @@ -31,7 +31,7 @@ public class IndexTemplateIT { private static final ObjectMapper MAPPER = new ObjectMapper(); - private static final String CLUSTER_ALIAS = "openmetadata"; + private static final String CLUSTER_ALIAS = TestSuiteBootstrap.getClusterAlias(); @Test void testIndexTemplatesExist(TestNamespace ns) throws Exception { diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MultiDomainHasDomainIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MultiDomainHasDomainIT.java index 39c77389af44..9da929b2c825 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MultiDomainHasDomainIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/MultiDomainHasDomainIT.java @@ -452,7 +452,12 @@ private void restoreSearchAccessControl(OpenMetadataClient adminClient, boolean private void assertSearchReturnsTable( OpenMetadataClient client, String tableId, String failureMessage) throws Exception { String searchResponse = - client.search().query("id:" + tableId).index("table_search_index").size(1).execute(); + client + .search() + .query("id.keyword:" + tableId) + .index("table_search_index") + .size(1) + .execute(); assertTrue( searchResponse.contains("\"id\":\"" + tableId + "\""), failureMessage + ". Response: " + searchResponse); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OrphanedIndexCleanerScopedCleanupIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OrphanedIndexCleanerScopedCleanupIT.java index 65f69275df7f..971c90821004 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OrphanedIndexCleanerScopedCleanupIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/OrphanedIndexCleanerScopedCleanupIT.java @@ -48,7 +48,7 @@ @TestMethodOrder(OrderAnnotation.class) public class OrphanedIndexCleanerScopedCleanupIT { - private static final String CLUSTER_ALIAS = "openmetadata"; + private static final String CLUSTER_ALIAS = TestSuiteBootstrap.getClusterAlias(); private static final String FOREIGN_PREFIX = "foreigntenant_it_orphans"; private static final String OUR_PREFIX = CLUSTER_ALIAS + "_it_orphans"; diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexFieldLimitIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexFieldLimitIT.java index d45e539aefc6..bb92deefe3d5 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexFieldLimitIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/SearchIndexFieldLimitIT.java @@ -65,8 +65,10 @@ public class SearchIndexFieldLimitIT { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String TABLE_TYPE_NAME = "table"; private static final int NUM_CUSTOM_PROPERTIES = 50; - // Index name with cluster alias prefix (from TestSuiteBootstrap.ELASTIC_SEARCH_CLUSTER_ALIAS) - private static final String TABLE_INDEX = "openmetadata_table_search_index"; + // Index name uses the cluster alias resolved by TestSuiteBootstrap (randomized per session by + // default; pin with -DclusterAlias=... for reproducible debugging). + private static final String TABLE_INDEX = + org.openmetadata.it.bootstrap.TestSuiteBootstrap.getClusterAlias() + "_table_search_index"; private static Type STRING_TYPE; private static Type INTEGER_TYPE; 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 bf725c071b5b..248b46b5fed1 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 @@ -3760,7 +3760,7 @@ void get_entityWithoutDescriptionFromSearch(TestNamespace ns) throws Exception { String searchResponse = client .search() - .query("id:" + tableWithDesc.getId()) + .query("id.keyword:" + tableWithDesc.getId()) .index("table_search_index") .size(1) .execute(); @@ -3843,7 +3843,7 @@ void test_searchTableColumns_comprehensive(TestNamespace ns) throws Exception { String searchResponse = client .search() - .query("id:" + table.getId()) + .query("id.keyword:" + table.getId()) .index("table_search_index") .size(1) .execute(); @@ -4002,7 +4002,7 @@ void test_multipleDomainInheritance(TestNamespace ns) throws Exception { String searchResponse = client .search() - .query("id:" + tableId) + .query("id.keyword:" + tableId) .index("table_search_index") .size(1) .execute(); @@ -4586,11 +4586,13 @@ protected Table getEntityIncludeDeleted(String id) { // =================================================================== /** - * Get the full Elasticsearch index name with cluster alias prefix. - * In test environment, cluster alias is "openmetadata" so table index is "openmetadata_table_search_index" + * Get the full Elasticsearch index name with cluster alias prefix. The alias is randomized per + * JUnit session by default in {@link org.openmetadata.it.bootstrap.TestSuiteBootstrap}; it can + * be pinned via {@code -DclusterAlias=...} for reproducible debugging. */ private String getTableSearchIndexName() { - return "openmetadata_table_search_index"; + return org.openmetadata.it.bootstrap.TestSuiteBootstrap.getClusterAlias() + + "_table_search_index"; } // =================================================================== @@ -5478,7 +5480,7 @@ void test_ownerPropagationToSearchIndex(TestNamespace ns) { String response = client .search() - .query("id:" + tableId) + .query("id.keyword:" + tableId) .index("table_search_index") .size(1) .execute(); @@ -5553,7 +5555,7 @@ void test_domainPropagationToSearchIndex(TestNamespace ns) { String response = client .search() - .query("id:" + tableId) + .query("id.keyword:" + tableId) .index("table_search_index") .size(1) .execute(); @@ -5625,7 +5627,7 @@ void test_displayNamePropagationToSearchIndex(TestNamespace ns) { String response = client .search() - .query("id:" + tableId) + .query("id.keyword:" + tableId) .index("table_search_index") .size(1) .execute(); @@ -5685,7 +5687,7 @@ void test_ownerRemovalPropagationToSearchIndex(TestNamespace ns) { String response = client .search() - .query("id:" + tableId) + .query("id.keyword:" + tableId) .index("table_search_index") .size(1) .execute(); @@ -5711,7 +5713,7 @@ void test_ownerRemovalPropagationToSearchIndex(TestNamespace ns) { String response = client .search() - .query("id:" + tableId) + .query("id.keyword:" + tableId) .index("table_search_index") .size(1) .execute(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TagResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TagResourceIT.java index 5a3feafcd2c1..1642ac5987be 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TagResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TagResourceIT.java @@ -1166,7 +1166,12 @@ private void awaitTagSearchDomains( .untilAsserted( () -> { String response = - client.search().query("id:" + tagId).index("tag_search_index").size(5).execute(); + client + .search() + .query("id.keyword:" + tagId) + .index("tag_search_index") + .size(5) + .execute(); JsonNode root = mapper.readTree(response); JsonNode hits = root.path("hits").path("hits"); assertTrue( @@ -1781,7 +1786,12 @@ void test_ownerPropagationFromClassificationToTagSearchIndex(TestNamespace ns) { .untilAsserted( () -> { String response = - client.search().query("id:" + tagId).index("tag_search_index").size(1).execute(); + client + .search() + .query("id.keyword:" + tagId) + .index("tag_search_index") + .size(1) + .execute(); JsonNode root = mapper.readTree(response); JsonNode hits = root.path("hits").path("hits"); assertTrue(hits.isArray() && !hits.isEmpty(), "Tag should be in tag_search_index"); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseResourceIT.java index 16afa9c8bbbd..9d22e6459963 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestCaseResourceIT.java @@ -3935,7 +3935,7 @@ void test_testCaseSearchIndexUpdatedWhenTableTagChanges(TestNamespace ns) { String searchResponse = client .search() - .query("id:" + testCaseId) + .query("id.keyword:" + testCaseId) .index("test_case_search_index") .size(1) .execute(); @@ -3981,7 +3981,7 @@ void test_testCaseSearchIndexUpdatedWhenTableOwnerChanges(TestNamespace ns) { String searchResponse = client .search() - .query("id:" + testCaseId) + .query("id.keyword:" + testCaseId) .index("test_case_search_index") .size(1) .execute(); @@ -4034,7 +4034,7 @@ void test_testCaseSearchIndexUpdatedWhenTableDomainChanges(TestNamespace ns) { String searchResponse = client .search() - .query("id:" + testCaseId) + .query("id.keyword:" + testCaseId) .index("test_case_search_index") .size(1) .execute(); @@ -4095,7 +4095,7 @@ void test_testCaseSearchIndexUpdatedWhenTableDataProductChanges(TestNamespace ns String searchResponse = client .search() - .query("id:" + testCaseId) + .query("id.keyword:" + testCaseId) .index("test_case_search_index") .size(1) .execute(); diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestSuiteResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestSuiteResourceIT.java index f4f6efd7f719..4e8c344445d0 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestSuiteResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/TestSuiteResourceIT.java @@ -1139,7 +1139,8 @@ private void putPipelineStatus( } private String getTestSuiteSearchIndexName() { - return "openmetadata_test_suite_search_index"; + return org.openmetadata.it.bootstrap.TestSuiteBootstrap.getClusterAlias() + + "_test_suite_search_index"; } private void refreshTestSuiteSearchIndex(Rest5Client searchClient) throws Exception { diff --git a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserResourceIT.java b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserResourceIT.java index 5ee41cef029b..c94899a7bc13 100644 --- a/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserResourceIT.java +++ b/openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/UserResourceIT.java @@ -2173,45 +2173,50 @@ void testUserContextCachePerformance(TestNamespace ns) throws HttpResponseExcept SubjectCache.invalidateAll(); - // Warm up JVM (exclude from measurements) - for (int i = 0; i < 3; i++) { + // Warm up JVM (exclude from measurements). More iterations than before so JIT has had a + // chance to compile the SubjectContext.getSubjectContext path before we measure anything. + for (int i = 0; i < 20; i++) { SubjectContext.getSubjectContext(userName); } - SubjectCache.invalidateAll(); - // Test 1: Cache Miss (First call - should be slower) - long cacheMissStartTime = System.nanoTime(); - SubjectContext context1 = SubjectContext.getSubjectContext(userName); - double cacheMissTime = (System.nanoTime() - cacheMissStartTime) / 1_000_000.0; - assertNotNull(context1); - assertEquals(userName, context1.user().getName()); + // Test 1: Cache Miss (multiple samples — invalidate before each) + // A single nanoTime sample at sub-millisecond scale is dominated by GC, JIT, and OS + // scheduling jitter, which produced flaky -100%+ "improvement" failures. Take the median + // across N runs to suppress that noise. + int sampleCount = 7; + List cacheMissTimes = new ArrayList<>(sampleCount); + for (int i = 0; i < sampleCount; i++) { + SubjectCache.invalidateAll(); + long start = System.nanoTime(); + SubjectContext miss = SubjectContext.getSubjectContext(userName); + cacheMissTimes.add((System.nanoTime() - start) / 1_000_000.0); + assertNotNull(miss); + assertEquals(userName, miss.user().getName()); + } - // Test 2: Cache Hit (Multiple subsequent calls - should be much faster) + // Test 2: Cache Hit (many samples, no invalidate) List cacheHitTimes = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - long cacheHitStartTime = System.nanoTime(); - SubjectContext context = SubjectContext.getSubjectContext(userName); - double cacheHitTime = (System.nanoTime() - cacheHitStartTime) / 1_000_000.0; - - cacheHitTimes.add(cacheHitTime); - assertNotNull(context); - assertEquals(userName, context.user().getName()); + for (int i = 0; i < 50; i++) { + long start = System.nanoTime(); + SubjectContext hit = SubjectContext.getSubjectContext(userName); + cacheHitTimes.add((System.nanoTime() - start) / 1_000_000.0); + assertNotNull(hit); + assertEquals(userName, hit.user().getName()); } - // Calculate cache hit performance statistics + double medianMiss = median(cacheMissTimes); + double medianHit = median(cacheHitTimes); double avgCacheHitTime = cacheHitTimes.stream().mapToDouble(Double::doubleValue).average().orElse(0.0); - // Performance assertions - double performanceImprovement = - cacheMissTime > 0 ? ((cacheMissTime - avgCacheHitTime) / cacheMissTime) * 100 : 0.0; - - // Assert significant performance improvement + // Sanity: cache hit median should be at least as fast as cache miss median. We don't assert + // a percentage improvement — at sub-millisecond scale it's not statistically meaningful and + // produces flaky failures. The absolute regression bound below catches real regressions. assertTrue( - performanceImprovement > 30.0, + medianHit <= medianMiss, String.format( - "Expected >30%% improvement, got %.1f%% (%.3fms -> %.3fms)", - performanceImprovement, cacheMissTime, avgCacheHitTime)); + "Cache hit should not be slower than miss at the median (miss=%.3fms hit=%.3fms)", + medianMiss, medianHit)); assertTrue( avgCacheHitTime < 200, String.format("Cache hits should be <200ms, got %.3fms", avgCacheHitTime)); @@ -2286,6 +2291,13 @@ void testUserContextCachePerformance(TestNamespace ns) throws HttpResponseExcept deleteEntity(testUser.getId().toString()); } + private static double median(List values) { + List sorted = values.stream().sorted().toList(); + int n = sorted.size(); + if (n == 0) return 0.0; + return n % 2 == 1 ? sorted.get(n / 2) : (sorted.get(n / 2 - 1) + sorted.get(n / 2)) / 2.0; + } + // =================================================================== // VERSION HISTORY SUPPORT // =================================================================== 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 51bdbaa78794..ae2fce72c090 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 @@ -809,7 +809,25 @@ protected void setInheritedFields(T entity, Fields fields) { EntityReference parentRef = getParentReference(entity); EntityInterface parent = getCachedInheritanceParent(parentRef, inheritableFields); if (parent == null) { - parent = getParentEntity(entity, inheritableFields); + try { + parent = getParentEntity(entity, inheritableFields); + } catch (EntityNotFoundException e) { + // The bulk inheritance path (Entity.getEntitiesForInheritance → repo.find(ids, include)) + // is missing-safe and falls back to this single-entity path for entries the bulk lookup + // missed. The fallback then calls Entity.getEntity which throws on a missing parent. + // That happens legitimately when a parent has been hard-deleted while a child's main + // row or version snapshot still references it — e.g. listEntityHistoryByTimestamp + // reading from entity_extension snapshots whose embedded parent ref outlives the parent. + // Treat it as "no inheritance applies" instead of failing the whole list/history + // response — same pattern as Entity.getEntityOrNull and getFromEntityRef which already + // swallow this exception elsewhere for the same scenario. + LOG.debug( + "Parent entity not found for {}:{} during inheritance; skipping inheritance: {}", + entityType, + entity.getId(), + e.getMessage()); + parent = null; + } cacheInheritanceParent(parentRef, inheritableFields, parent); } if (parent != null) { diff --git a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java index 80e77a90a3b4..2b7ee7940c3f 100644 --- a/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java +++ b/openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/GlossaryTermRepository.java @@ -1864,12 +1864,27 @@ private void fetchAndSetParentOrGlossary(List terms, Fields fields .relationshipDAO() .findFromBatch(entityIds, Relationship.CONTAINS.ordinal(), entityType, entityType); + // Resolve parent refs in bulk so a missing parent (hard-deleted while the relationship row + // is still present, or a stale snapshot row in entity_extension whose parent has since been + // removed) doesn't throw EntityNotFoundException and 404 the entire response. The + // single-id Entity.getEntityReferenceById throws; getEntityReferencesByIds returns only the + // parents that still exist. + Set parentIds = + parentRecords.stream() + .map(rec -> UUID.fromString(rec.getFromId())) + .collect(Collectors.toSet()); + Map parentRefById = + parentIds.isEmpty() + ? Collections.emptyMap() + : Entity.getEntityReferencesByIds(GLOSSARY_TERM, new ArrayList<>(parentIds), ALL) + .stream() + .collect(Collectors.toMap(ref -> ref.getId().toString(), ref -> ref)); Map parentMap = new HashMap<>(); for (CollectionDAO.EntityRelationshipObject rec : parentRecords) { - parentMap.put( - UUID.fromString(rec.getToId()), - Entity.getEntityReferenceById( - rec.getFromEntity(), UUID.fromString(rec.getFromId()), ALL)); + EntityReference parentRef = parentRefById.get(rec.getFromId()); + if (parentRef != null) { + parentMap.put(UUID.fromString(rec.getToId()), parentRef); + } } // Set parent references on GlossaryTerms @@ -1914,13 +1929,23 @@ private void fetchAndSetParentOrGlossary(List terms, Fields fields allRecords.addAll(hasRecords); allRecords.addAll(containsRecords); - // Map to entity ID -> glossary reference + // Same orphan-safe pattern as the parent lookup above: resolve glossary refs via the bulk + // helper that returns only existing entities, so a missing glossary doesn't 404 the response. + Set glossaryIds = + allRecords.stream() + .map(rec -> UUID.fromString(rec.getFromId())) + .collect(Collectors.toSet()); + Map glossaryRefById = + glossaryIds.isEmpty() + ? Collections.emptyMap() + : Entity.getEntityReferencesByIds(GLOSSARY, new ArrayList<>(glossaryIds), ALL).stream() + .collect(Collectors.toMap(ref -> ref.getId().toString(), ref -> ref)); Map glossaryMap = new HashMap<>(); for (CollectionDAO.EntityRelationshipObject rec : allRecords) { - glossaryMap.put( - UUID.fromString(rec.getToId()), - Entity.getEntityReferenceById( - rec.getFromEntity(), UUID.fromString(rec.getFromId()), ALL)); + EntityReference glossaryRef = glossaryRefById.get(rec.getFromId()); + if (glossaryRef != null) { + glossaryMap.put(UUID.fromString(rec.getToId()), glossaryRef); + } } for (GlossaryTerm term : terms) { diff --git a/openmetadata-spec/src/main/resources/elasticsearch/en/tag_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/en/tag_index_mapping.json index d42f307ae37e..5be695fc0e89 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/en/tag_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/en/tag_index_mapping.json @@ -294,6 +294,17 @@ } } }, + "displayName": { + "type": "text", + "analyzer": "om_analyzer", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer", + "ignore_above": 256 + } + } + }, "fullyQualifiedName": { "type": "text" }, diff --git a/openmetadata-spec/src/main/resources/elasticsearch/jp/tag_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/jp/tag_index_mapping.json index e9d180584a2e..127dd8a07f80 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/jp/tag_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/jp/tag_index_mapping.json @@ -183,6 +183,17 @@ } } }, + "displayName": { + "type": "text", + "analyzer": "om_analyzer", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer", + "ignore_above": 256 + } + } + }, "fullyQualifiedName": { "type": "text" }, diff --git a/openmetadata-spec/src/main/resources/elasticsearch/ru/tag_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/ru/tag_index_mapping.json index 877ee0777cb2..69a92ea1ee94 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/ru/tag_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/ru/tag_index_mapping.json @@ -307,6 +307,17 @@ } } }, + "displayName": { + "type": "text", + "analyzer": "om_analyzer", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer", + "ignore_above": 256 + } + } + }, "fullyQualifiedName": { "type": "text" }, diff --git a/openmetadata-spec/src/main/resources/elasticsearch/zh/tag_index_mapping.json b/openmetadata-spec/src/main/resources/elasticsearch/zh/tag_index_mapping.json index cbb68e2d20a7..6456e5e078fb 100644 --- a/openmetadata-spec/src/main/resources/elasticsearch/zh/tag_index_mapping.json +++ b/openmetadata-spec/src/main/resources/elasticsearch/zh/tag_index_mapping.json @@ -189,6 +189,17 @@ } } }, + "displayName": { + "type": "text", + "analyzer": "om_analyzer", + "fields": { + "keyword": { + "type": "keyword", + "normalizer": "lowercase_normalizer", + "ignore_above": 256 + } + } + }, "fullyQualifiedName": { "type": "text" },