Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
a34f908
Entity Time Series
mohityadav766 May 12, 2026
786a158
test case results
mohityadav766 May 12, 2026
0775875
Add Playwright
mohityadav766 May 12, 2026
f49cb13
fix: declare 'summary' on TestSuiteIndex so reindex preserves lastRes…
mohityadav766 May 12, 2026
9674ce7
fix: skip soft-delete propagation to time-series child aliases
harshach May 12, 2026
5dcdf54
feat(reindex): ratio-based promotion policy on the distributed finalizer
harshach May 12, 2026
952122b
Update generated TypeScript types
github-actions[bot] May 12, 2026
6edcbe1
feat(search): typed capability registry + IndexUpdateScript abstraction
harshach May 12, 2026
0799663
Fix java style
harshach May 12, 2026
9ca9bc1
fix(search): live-indexing scripts mirror reindex tag/tier/cert separ…
harshach May 12, 2026
c11fb62
test(playwright): cross-entity live + reindex filter separation matrix
harshach May 12, 2026
059d7b5
test(playwright): glossary-rename cascade + Database/Schema matrix en…
harshach May 12, 2026
a575f53
review: address PR #28064 review comments
harshach May 12, 2026
b79d6ee
Update generated TypeScript types
github-actions[bot] May 12, 2026
707ba69
review: remove unused SOFT_DELETE_RESTORE_SCRIPT + isolate soft-delet…
harshach May 12, 2026
fd0222d
style: spotless reformat on TestCaseSoftDeleteSearchIT
harshach May 13, 2026
4a61650
fix(it): inline try/catch in TestCaseSoftDeleteSearchIT cleanup
harshach May 13, 2026
c0af4ba
Merge branch 'main' into fix-soft-delete-time-series-propagation
mohityadav766 May 13, 2026
f8c7293
review: address 5 open PR comments + bump IncidentManager poll timeout
harshach May 13, 2026
8d393aa
fix(playwright): CI failures in SearchSeparation + ServiceEntityVersi…
harshach May 13, 2026
f232452
fix(playwright): correct fullyQualifiedName query + relocate suite fa…
mohityadav766 May 14, 2026
09c3381
Merge branch 'main' into fix-soft-delete-time-series-propagation
mohityadav766 May 14, 2026
9ae37a5
docs(playwright): update README import path after suite move
mohityadav766 May 14, 2026
d4acd8d
fix(search): guard TAG_RESEPARATION_SCRIPT writes behind containsKey(…
mohityadav766 May 14, 2026
73501ed
Merge branch 'main' into fix-soft-delete-time-series-propagation
mohityadav766 May 14, 2026
1c4f020
Merge remote-tracking branch 'origin/fix-soft-delete-time-series-prop…
mohityadav766 May 14, 2026
9274e60
review: address PR review-4284133687 comments
harshach May 14, 2026
ccb4a0d
review: replace test.slow() in hook with explicit describe/hook timeouts
harshach May 14, 2026
6660555
Merge branch 'main' into fix-soft-delete-time-series-propagation
harshach May 14, 2026
f010cfb
fix(search): guard TAG_RESEPARATION_SCRIPT tier write behind null check
harshach May 15, 2026
b5e1fa8
fix(search): wildcard "?*" instead of term(field,"") for non-empty check
harshach May 15, 2026
a82fda0
Merge branch 'main' into fix-soft-delete-time-series-propagation
harshach May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Copyright 2026 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.it.tests;

import static org.awaitility.Awaitility.await;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
import org.openmetadata.it.bootstrap.SharedEntities;
import org.openmetadata.it.util.SdkClients;
import org.openmetadata.schema.api.data.CreateDatabase;
import org.openmetadata.schema.api.data.CreateDatabaseSchema;
import org.openmetadata.schema.api.data.CreateTable;
import org.openmetadata.schema.api.tests.CreateTestCase;
import org.openmetadata.schema.api.tests.CreateTestCaseResolutionStatus;
import org.openmetadata.schema.entity.data.Database;
import org.openmetadata.schema.entity.data.DatabaseSchema;
import org.openmetadata.schema.entity.data.Table;
import org.openmetadata.schema.tests.TestCase;
import org.openmetadata.schema.tests.type.Severity;
import org.openmetadata.schema.tests.type.TestCaseResolutionStatus;
import org.openmetadata.schema.tests.type.TestCaseResolutionStatusTypes;
import org.openmetadata.schema.type.Column;
import org.openmetadata.schema.type.ColumnDataType;
import org.openmetadata.sdk.client.OpenMetadataClient;
import org.openmetadata.sdk.models.ListParams;
import org.openmetadata.sdk.models.ListResponse;

/**
* Regression for the live-indexing soft-delete propagation bug. {@code SOFT_DELETE_RESTORE_SCRIPT}
* was stamping a top-level {@code deleted} field onto child docs of every alias listed in the
* parent's {@code indexMapping}. For {@code testCase}, two of those children
* ({@code testCaseResolutionStatus}, {@code testCaseResult}) are time-series indexes whose
* Java schemas declare no {@code deleted} field. The poisoned doc broke Jackson on read and
* the Incident Manager UI surfaced an "Unrecognized field 'deleted'" toast.
*
* <p>This test exercises the end-to-end path: create a TC + result + incident, soft-delete the
* TC, and confirm that (a) the resolution-status listing API still parses cleanly and (b) the
* underlying ES doc carries no top-level {@code deleted} field.
*/
@Execution(ExecutionMode.CONCURRENT)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class TestCaseSoftDeleteSearchIT {
Comment thread
harshach marked this conversation as resolved.
Outdated

private static final ObjectMapper MAPPER = new ObjectMapper();

@Test
void softDeletingTestCaseDoesNotPollutePropagatedTimeSeriesDocs() throws Exception {
OpenMetadataClient client = SdkClients.adminClient();

long ts = System.currentTimeMillis();
Database database =
client
.databases()
.create(
new CreateDatabase()
.withName("soft_delete_db_" + ts)
.withService(SharedEntities.get().MYSQL_SERVICE.getFullyQualifiedName()));
DatabaseSchema schema =
client
.databaseSchemas()
.create(
new CreateDatabaseSchema()
.withName("soft_delete_schema_" + ts)
.withDatabase(database.getFullyQualifiedName()));
Table table =
client
.tables()
.create(
new CreateTable()
.withName("soft_delete_table_" + ts)
.withDatabaseSchema(schema.getFullyQualifiedName())
.withColumns(
List.of(new Column().withName("id").withDataType(ColumnDataType.BIGINT))));

String testDefFqn =
client
.testDefinitions()
.list(new ListParams().withLimit(1))
.getData()
.get(0)
.getFullyQualifiedName();

TestCase testCase =
client
.testCases()
.create(
new CreateTestCase()
.withName("soft_delete_tc_" + ts)
.withEntityLink(
"<#E::table::" + table.getFullyQualifiedName() + "::columns::id>")
.withTestDefinition(testDefFqn));

client
.testCaseResolutionStatuses()
.create(
new CreateTestCaseResolutionStatus()
.withTestCaseResolutionStatusType(TestCaseResolutionStatusTypes.New)
.withTestCaseReference(testCase.getFullyQualifiedName())
.withSeverity(Severity.Severity2));

awaitIncidentIndexed(client, testCase.getFullyQualifiedName());

client
.testCases()
.delete(testCase.getId().toString(), Map.of("hardDelete", "false", "recursive", "true"));

assertListingApiReturnsCleanlyAfterSoftDelete(client, testCase.getFullyQualifiedName());
assertNoTopLevelDeletedFieldOnIncidentDoc(client, testCase.getFullyQualifiedName());
Comment thread
harshach marked this conversation as resolved.
Outdated
}

Comment thread
harshach marked this conversation as resolved.
Outdated
private void awaitIncidentIndexed(OpenMetadataClient client, String testCaseFqn) {
await("Wait for resolution status to be searchable")
.atMost(Duration.ofMinutes(2))
.pollInterval(Duration.ofSeconds(2))
.ignoreExceptions()
.untilAsserted(
() -> {
ListResponse<TestCaseResolutionStatus> resp =
client
.testCaseResolutionStatuses()
.searchList(
new ListParams()
.withLimit(1)
.withLatest(true)
.addFilter("testCaseFQN", testCaseFqn));
assertNotNull(resp);
assertEquals(
1,
resp.getData().size(),
"Incident for the test case should be indexed before we soft-delete");
});
}

private void assertListingApiReturnsCleanlyAfterSoftDelete(
OpenMetadataClient client, String testCaseFqn) {
await("API returns parseable body after soft-delete propagation")
.atMost(Duration.ofMinutes(1))
.pollInterval(Duration.ofSeconds(2))
.ignoreExceptions()
.untilAsserted(
() -> {
ListResponse<TestCaseResolutionStatus> resp =
client
.testCaseResolutionStatuses()
.searchList(
new ListParams()
.withLimit(10)
.withLatest(true)
.addFilter("testCaseFQN", testCaseFqn));
assertNotNull(
resp, "list endpoint must return a body; null implies a deserialization failure");
});
}

/**
* The fix in {@link org.openmetadata.service.search.SearchRepository#softDeleteOrRestoredChildren}
* filters out time-series child aliases before invoking the soft-delete script. Confirm by
* querying ES directly for any TCRS doc that has a top-level {@code deleted} field — there
* must be none for our test case.
*/
private void assertNoTopLevelDeletedFieldOnIncidentDoc(
OpenMetadataClient client, String testCaseFqn) throws Exception {
String rawJson =
client
.search()
.query(
"testCaseReference.fullyQualifiedName.keyword:\""
+ testCaseFqn
+ "\" AND _exists_:deleted")
.index("test_case_resolution_status_search_index")
.size(5)
.execute();
JsonNode root = MAPPER.readTree(rawJson);
JsonNode hits = root.path("hits").path("hits");
assertTrue(
hits.isArray(),
() -> "ES response missing hits.hits array; raw response was: " + rawJson);
assertFalse(
hits.elements().hasNext(),
() ->
"No `deleted` field should exist on testCaseResolutionStatus docs after a parent"
+ " soft-delete; found "
+ hits.size()
+ " polluted docs. Raw response: "
+ rawJson);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,16 @@ public static boolean hasEntityRepository(@NonNull String entityType) {
|| ENTITY_TS_REPOSITORY_MAP.containsKey(entityType);
}

/**
* Returns true when {@code entityTypeOrAlias} maps to an {@link EntityTimeSeriesInterface}
* (append-only, no top-level {@code deleted} field). Used by the search layer to skip
* propagation scripts that assume a regular-entity shape — e.g. the soft-delete script that
* stamps {@code deleted} onto child docs.
*/
public static boolean isTimeSeriesEntity(@NonNull String entityTypeOrAlias) {
return ENTITY_TS_REPOSITORY_MAP.containsKey(entityTypeOrAlias);
}

public static EntityTimeSeriesRepository<? extends EntityTimeSeriesInterface>
getEntityTimeSeriesRepository(@NonNull String entityType) {
EntityTimeSeriesRepository<? extends EntityTimeSeriesInterface> entityTimeSeriesRepository =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ public ResultList<T> listFromSearchWithOffset(
searchListFilter, limit, offset, entityType, searchSortFilter, q, queryString);
total = results.getTotal();
for (Map<String, Object> json : results.getResults()) {
T entity = setFieldsInternal(JsonUtils.readOrConvertValue(json, entityClass), fields);
T entity = setFieldsInternal(readTimeSeriesSource(json), fields);
try {
setInheritedFields(entity);
} catch (RuntimeException e) {
Expand Down Expand Up @@ -524,9 +524,7 @@ public ResultList<T> listLatestFromSearch(
hitList -> {
for (Map<String, Object> hit : (List<Map<String, Object>>) hitList) {
Map<String, Object> source = extractAndFilterSource(hit);
T entity =
setFieldsInternal(
JsonUtils.readOrConvertValue(source, entityClass), fields);
T entity = setFieldsInternal(readTimeSeriesSource(source), fields);
if (entity != null) {
try {
setInheritedFields(entity);
Expand Down Expand Up @@ -684,7 +682,7 @@ public T latestFromSearch(EntityUtil.Fields fields, SearchListFilter searchListF
SearchResultListMapper results =
searchRepository.listWithOffset(searchListFilter, 1, 0, entityType, searchSortFilter, q);
for (Map<String, Object> json : results.getResults()) {
T entity = setFieldsInternal(JsonUtils.readOrConvertValue(json, entityClass), fields);
T entity = setFieldsInternal(readTimeSeriesSource(json), fields);
setInheritedFields(entity);
clearFieldsInternal(entity, fields);
return entity;
Expand All @@ -696,6 +694,16 @@ protected void setIncludeSearchFields(SearchListFilter searchListFilter) {
// Nothing to do in the default implementation
}

/**
* Lenient-deserializes a search hit source into the time-series entity type. Tolerates legacy
* {@code deleted} fields that the live-indexing soft-delete script may have stamped onto
* append-only docs whose schema declares no such field. Once a recreate-style reindex has
* cleaned the index, the lenient mode is a no-op.
*/
private T readTimeSeriesSource(Object source) {
return JsonUtils.readOrConvertValueLenient(source, entityClass);
Comment thread
harshach marked this conversation as resolved.
Outdated
}
Comment thread
harshach marked this conversation as resolved.

protected void setExcludeSearchFields(SearchListFilter searchListFilter) {
// Nothing to do in the default implementation
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,9 @@

@Slf4j
public class TestCaseRepository extends EntityRepository<TestCase> {
private static final String TEST_SUITE_FIELD = "testSuite";
private static final String INCIDENTS_FIELD = "incidentId";
public static final String TEST_SUITE_FIELD = "testSuite";
public static final String TEST_DEFINITION_FIELD = "testDefinition";
public static final String INCIDENTS_FIELD = "incidentId";
private static final String UPDATE_FIELDS =
"owners,entityLink,testSuite,testSuites,testDefinition,dimensionColumns,topDimensions";
private static final String PATCH_FIELDS =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2511,33 +2511,37 @@ public void deleteOrUpdateChildren(EntityInterface entity, IndexMapping indexMap
public void softDeleteOrRestoredChildren(
EntityReference entityReference, IndexMapping indexMapping, boolean delete)
throws IOException {
String docId = entityReference.getId().toString();
// childAliases values are entity-type names (per indexMapping.json). Drop time-series
// children — their docs have no top-level `deleted` field and the script would pollute them.
List<String> targets =
indexMapping.getChildAliases().stream()
.filter(a -> !Entity.isTimeSeriesEntity(a))
.map(a -> clusterAlias == null || clusterAlias.isEmpty() ? a : clusterAlias + "_" + a)
Comment thread
harshach marked this conversation as resolved.
Outdated
.toList();
if (targets.isEmpty()) {
return;
}
String entityType = entityReference.getType();
String parentIdField =
SERVICE_ENTITY_TYPES.contains(entityType) ? "service.id" : entityType + ".id";
String scriptTxt = String.format(SOFT_DELETE_RESTORE_SCRIPT, delete);
switch (entityType) {
case Entity.DASHBOARD_SERVICE,
searchClient.softDeleteOrRestoreChildren(
targets,
scriptTxt,
List.of(new ImmutablePair<>(parentIdField, entityReference.getId().toString())));
}

private static final Set<String> SERVICE_ENTITY_TYPES =
Set.of(
Entity.DASHBOARD_SERVICE,
Entity.DATABASE_SERVICE,
Entity.MESSAGING_SERVICE,
Entity.PIPELINE_SERVICE,
Entity.MLMODEL_SERVICE,
Entity.STORAGE_SERVICE,
Entity.SEARCH_SERVICE,
Entity.SECURITY_SERVICE,
Entity.DRIVE_SERVICE -> searchClient.softDeleteOrRestoreChildren(
indexMapping.getChildAliases(clusterAlias),
scriptTxt,
List.of(new ImmutablePair<>("service.id", docId)));
default -> {
List<String> indexNames = indexMapping.getChildAliases(clusterAlias);
if (!indexNames.isEmpty()) {
searchClient.softDeleteOrRestoreChildren(
indexMapping.getChildAliases(clusterAlias),
scriptTxt,
List.of(new ImmutablePair<>(entityType + ".id", docId)));
}
}
}
}
Entity.DRIVE_SERVICE);
Comment thread
harshach marked this conversation as resolved.
Outdated
Comment thread
harshach marked this conversation as resolved.
Outdated

public String getScriptWithParams(
EntityInterface entity,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.openmetadata.schema.type.Include;
import org.openmetadata.service.Entity;
import org.openmetadata.service.exception.EntityNotFoundException;
import org.openmetadata.service.jdbi3.TestCaseRepository;
import org.openmetadata.service.resources.feeds.MessageParser;
import org.openmetadata.service.search.SearchIndexUtils;

Expand All @@ -34,16 +35,19 @@ public String getEntityTypeName() {
@Override
public Set<String> getRequiredReindexFields() {
Set<String> fields = new java.util.HashSet<>(TaggableIndex.super.getRequiredReindexFields());
fields.add("testSuite");
fields.add("testSuites");
fields.add("testDefinition");
fields.add(TestCaseRepository.TEST_SUITE_FIELD);
fields.add(Entity.FIELD_TEST_SUITES);
fields.add(TestCaseRepository.TEST_DEFINITION_FIELD);
fields.add(Entity.TEST_CASE_RESULT);
fields.add(TestCaseRepository.INCIDENTS_FIELD);
return java.util.Collections.unmodifiableSet(fields);
}

@Override
public void removeNonIndexableFields(Map<String, Object> esDoc) {
TaggableIndex.super.removeNonIndexableFields(esDoc);
List<Map<String, Object>> testSuites = (List<Map<String, Object>>) esDoc.get("testSuites");
List<Map<String, Object>> testSuites =
(List<Map<String, Object>>) esDoc.get(Entity.FIELD_TEST_SUITES);
if (testSuites != null) {
for (Map<String, Object> testSuite : testSuites) {
SearchIndexUtils.removeNonIndexableFields(testSuite, excludeFields);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.openmetadata.service.search.indexes;

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
Expand Down Expand Up @@ -29,6 +31,13 @@ public Set<String> getExcludedFields() {
return excludeFields;
}

@Override
public Set<String> getRequiredReindexFields() {
Set<String> fields = new HashSet<>(TaggableIndex.super.getRequiredReindexFields());
fields.add("summary");
Comment thread
harshach marked this conversation as resolved.
Outdated
return Collections.unmodifiableSet(fields);
}

public Map<String, Object> buildSearchIndexDocInternal(Map<String, Object> doc) {
setParentRelationships(doc, testSuite);

Expand Down
Loading
Loading