Skip to content

Commit 2acf52f

Browse files
mohityadav766claudeCopilot
authored
fix(search): treat stale parentOf as warning during time-series reindex (open-metadata#27417) (open-metadata#27800)
* fix(search): treat stale parentOf as warning during time-series reindex (open-metadata#27417) Time-series records (testCaseResolutionStatus, testCaseResult, ...) whose parentOf entity_relationship row is missing surface as "does not have expected relationship parentOf to/from entity type ..." from EntityRepository.ensureSingleRelationship and were failing the entire reindex batch. Mirror the warning-vs-failure split already used for EntityInterface sources: extract isEntityNotFoundError into ReindexingUtil, broaden it to match the relationship-not-found message, and apply the same partitioning to PaginatedEntityTimeSeriesSource.read/readWithCursor/ readNextKeyset so orphaned rows are counted as warnings via result.getWarningsCount() instead of failing the job. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: tighten matcher, propagate warnings on cursor/keyset paths Address PR open-metadata#27800 review: - Drop bare "not found" from isEntityNotFoundError; add "entity not found" so we still match EntityNotFoundException's "Entity not found:" form but no longer misclassify "Column 'foo' not found in result set" or "SSL certificate not found" as warnings. - Call updateStats(success, failed, warnings) in readWithCursor and readNextKeyset so the source's StepStats.warningRecords reflects warnings for cursor- and keyset-based reads (was already correct in read()). - partitionErrors: requireNonNull(warningsOut) and document the contract. - Tests: cover the new bare-"not found" exclusion, the "Entity not found:" inclusion, the readWithCursor stats propagation, and the null-warningsOut guard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Show warn for failing test case resolution * Always recreate * review: rename matcher + record reader failures before early-return Address two Copilot comments on PR open-metadata#27800: 1. PartitionWorker.processBatch was returning early when readSuccessCount==0 without invoking failureRecorder.recordReaderEntityFailure for the per-row errors in that batch — losing entity-level failure diagnostics for "all-error" batches. Extract the failure-recording into a helper and call it before the early-return so it runs whether or not the batch contained any successful rows. 2. Rename ReindexingUtil.isEntityNotFoundError -> isStaleReferenceError to reflect that the matcher now also catches the relationship-not-found message ("does not have expected relationship ...") raised by EntityRepository.ensureSingleRelationship, not just plain entity-not-found. Update PaginatedEntitiesSource and ReindexingUtilStaleRelationshipTest call sites. EntityRepository has its own private isEntityNotFoundError helper that is unrelated and unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: import java.util.Objects instead of fully qualified name in ReindexingUtil Agent-Logs-Url: https://github.com/open-metadata/OpenMetadata/sessions/e6d9ce67-1181-4e61-80f4-e7aba664dfe7 Co-authored-by: mohityadav766 <105265192+mohityadav766@users.noreply.github.com> * Fill End time * Not * reads * Fix Fields issue * Add tags back * Fix failing test * Revert "Always recreate" This reverts commit 23578b0. * review: cover all EntityNotFoundException formats; quiet null-id WARN Address two review concerns on PR open-metadata#27800: 1. The stale-reference matcher missed EntityNotFoundException's primary factory messages — byId ("Entity with id [...] not found."), byName ("Entity with name [...] not found."), byVersion ("Entity with id [...] and version [...] not found."), and byParserSchema ("Parser schema not found ..."). These would have been classified as real failures instead of warnings. Add specific contains() patterns ("entity with id", "entity with name", "parser schema not found") that match each factory's exact prefix without reintroducing the over-broad bare "not found" check. byFilter ("Entity not found for query params [...]") was already covered by "entity not found". 2. PartitionWorker.recordReaderFailures emitted WARN per error when entityId was null. EntityTimeSeriesRepository builds EntityError with only a message (no entity reference), so every time-series error hit that branch — log spam under load. Downgrade to DEBUG with a comment explaining why the id is absent. Tests: ReindexingUtilStaleRelationshipTest now exercises every EntityNotFoundException factory message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * review: NPE guard, locale-stable matcher, less log spam, accurate Javadoc Address remaining Copilot comments on PR open-metadata#27800: 1. EntityTimeSeriesRepository.getResultList(...) returns ResultList with errors=null on the success path. PaginatedEntityTimeSeriesSource was reading result.getErrors().size() / .isEmpty() right after, which would NPE on the common no-error path. Normalize errors to an empty list inside filterStaleRelationshipErrors so callers can rely on non-null. 2. ReindexingUtil.isStaleReferenceError now lowercases with Locale.ROOT instead of the platform default — avoids Turkish-locale style edge cases where 'I' lowercases differently and substring matches fail. 3. PaginatedEntityTimeSeriesSource.read() was logging every real reader error message at WARN. For large failed batches this floods logs. Switch to a single WARN with the error count plus the first 5 message details at DEBUG (gated by isDebugEnabled). 4. OmAppJobListener.fillTerminalTimings Javadoc claimed "does nothing if endTime is already set" but the body still backfills executionTime in that case. Rewrite to accurately describe the per-field idempotency. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Relaign Changes * Add Exception handling * fix(reindex): degrade to required-set, not '*', when filter step throws ReindexingUtil.getSearchIndexFields was catching every exception in the filter path and returning ["*"]. That defeats the entire point of selective fields — sending all fields silently masks any drift between SearchIndex.COMMON_REINDEX_FIELDS and the entity's schema, instead of surfacing it at the PaginatedEntitiesSource boundary. Split the try/catch so: - getReindexFieldsFor() throwing → ["*"] (we have no required set to fall back to; pre-selective behavior). - getOnlySupportedFields() throwing (typically because the EntityRepository isn't registered yet — boot/test scenarios) → return the unfiltered required set. PaginatedEntitiesSource validates the fields when an actual entity flows through, so any real drift surfaces loudly rather than being silently swallowed by a "*" wildcard. Restores the assertion and intent of ReindexingUtilTest.unregisteredRepositoryReturnsRequiredUnfiltered, and fixes the parametrized parity tests that were also seeing "*" because of this regression. Also stubs repo.getOnlySupportedFields(...) in the test mocks so it returns a real EntityUtil.Fields built against the declared allowedFields — matches the production code path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add Only Supported field at other call sites --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent f43d762 commit 2acf52f

19 files changed

Lines changed: 680 additions & 117 deletions

openmetadata-service/src/main/java/org/openmetadata/service/Entity.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,11 @@ public static Fields getFields(String entityType, List<String> fields) {
563563
return entityRepository.getFields(String.join(",", fields));
564564
}
565565

566+
public static Fields getOnlySupportedFields(String entityType, List<String> fields) {
567+
EntityRepository<?> entityRepository = Entity.getEntityRepository(entityType);
568+
return entityRepository.getOnlySupportedFields(String.join(",", fields));
569+
}
570+
566571
public static <T> T getEntity(EntityReference ref, String fields, Include include) {
567572
if (ref == null) {
568573
return null;

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/EntityReader.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static org.openmetadata.service.Entity.QUERY_COST_RECORD;
44
import static org.openmetadata.service.Entity.TEST_CASE_RESOLUTION_STATUS;
55
import static org.openmetadata.service.Entity.TEST_CASE_RESULT;
6+
import static org.openmetadata.service.workflows.searchIndex.ReindexingUtil.getSearchIndexFields;
67

78
import java.util.ArrayList;
89
import java.util.List;

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/ReindexingOrchestrator.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.openmetadata.service.Entity;
2525
import org.openmetadata.service.apps.bundles.searchIndex.listeners.LoggingProgressListener;
2626
import org.openmetadata.service.apps.bundles.searchIndex.listeners.SlackProgressListener;
27+
import org.openmetadata.service.apps.scheduler.OmAppJobListener;
2728
import org.openmetadata.service.jdbi3.CollectionDAO;
2829
import org.openmetadata.service.jdbi3.SystemRepository;
2930
import org.openmetadata.service.search.SearchRepository;
@@ -109,7 +110,7 @@ public void stop() {
109110

110111
AppRunRecord appRecord = context.getJobRecord();
111112
appRecord.setStatus(AppRunRecord.Status.STOPPED);
112-
appRecord.setEndTime(System.currentTimeMillis());
113+
OmAppJobListener.fillTerminalTimings(appRecord);
113114
context.storeRunRecord(JsonUtils.pojoToJson(appRecord));
114115
context.pushStatusUpdate(appRecord, true);
115116
sendUpdates();
@@ -368,6 +369,7 @@ private void finalizeJobExecution() {
368369
if (stopped) {
369370
AppRunRecord appRecord = context.getJobRecord();
370371
appRecord.setStatus(AppRunRecord.Status.STOPPED);
372+
OmAppJobListener.fillTerminalTimings(appRecord);
371373
context.storeRunRecord(JsonUtils.pojoToJson(appRecord));
372374
}
373375
}
@@ -383,6 +385,7 @@ private void sendUpdates() {
383385
private void updateRecordToDbAndNotify() {
384386
AppRunRecord appRecord = context.getJobRecord();
385387
appRecord.setStatus(AppRunRecord.Status.fromValue(jobData.getStatus().value()));
388+
OmAppJobListener.fillTerminalTimings(appRecord);
386389

387390
if (jobData.getFailure() != null) {
388391
appRecord.setFailureContext(

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/distributed/DistributedJobStatsAggregator.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.openmetadata.service.apps.bundles.searchIndex.BulkSink;
3636
import org.openmetadata.service.apps.bundles.searchIndex.ReindexingJobContext;
3737
import org.openmetadata.service.apps.bundles.searchIndex.ReindexingProgressListener;
38+
import org.openmetadata.service.apps.scheduler.OmAppJobListener;
3839
import org.openmetadata.service.jdbi3.CollectionDAO;
3940
import org.openmetadata.service.socket.WebSocketManager;
4041

@@ -562,6 +563,7 @@ private AppRunRecord convertToAppRunRecord(
562563
appRecord.setStartTime(appStartTime != null ? appStartTime : job.getStartedAt());
563564
appRecord.setEndTime(job.getCompletedAt());
564565
appRecord.setTimestamp(job.getUpdatedAt());
566+
OmAppJobListener.fillTerminalTimings(appRecord);
565567

566568
// Add stats as success context
567569
SuccessContext successContext = new SuccessContext();

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/distributed/PartitionWorker.java

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -506,15 +506,16 @@ private BatchResult processBatch(
506506
ResultList<?> resultList = readEntitiesKeyset(entityType, keysetCursor, batchSize);
507507
long readDurationNanos = System.nanoTime() - readStartNanos;
508508

509-
if (resultList == null || resultList.getData() == null || resultList.getData().isEmpty()) {
510-
LOG.debug("{} read={}ms returned empty", entityType, readDurationNanos / 1_000_000L);
511-
return new BatchResult(0, 0, 0, null);
512-
}
513-
514-
String nextCursor = resultList.getPaging() != null ? resultList.getPaging().getAfter() : null;
515-
int readSuccessCount = listOrEmpty(resultList.getData()).size();
516-
int readErrorCount = listOrEmpty(resultList.getErrors()).size();
517-
int warningsCount = resultList.getWarningsCount() != null ? resultList.getWarningsCount() : 0;
509+
int readSuccessCount = resultList != null ? listOrEmpty(resultList.getData()).size() : 0;
510+
int readErrorCount = resultList != null ? listOrEmpty(resultList.getErrors()).size() : 0;
511+
int warningsCount =
512+
(resultList != null && resultList.getWarningsCount() != null)
513+
? resultList.getWarningsCount()
514+
: 0;
515+
String nextCursor =
516+
(resultList != null && resultList.getPaging() != null)
517+
? resultList.getPaging().getAfter()
518+
: null;
518519

519520
if (statsTracker != null) {
520521
// Reader timing = wall-clock time of the keyset DB read (listAfter + setFieldsInBulk
@@ -523,28 +524,16 @@ private BatchResult processBatch(
523524
readSuccessCount, readErrorCount, warningsCount, readDurationNanos);
524525
}
525526

526-
if (failureRecorder != null && readErrorCount > 0) {
527-
for (EntityError entityError : listOrEmpty(resultList.getErrors())) {
528-
Object rawEntity = entityError.getEntity();
529-
String entityId = null;
530-
if (rawEntity instanceof EntityInterface) {
531-
UUID id = ((EntityInterface) rawEntity).getId();
532-
if (id != null) {
533-
entityId = id.toString();
534-
}
535-
} else if (rawEntity != null) {
536-
entityId = rawEntity.toString();
537-
}
538-
if (entityId == null) {
539-
LOG.warn(
540-
"Skipping reader failure record for entityType={}: entityId is null, message={}",
541-
entityType,
542-
entityError.getMessage());
543-
continue;
544-
}
545-
failureRecorder.recordReaderEntityFailure(
546-
entityType, entityId, null, entityError.getMessage());
547-
}
527+
recordReaderFailures(entityType, resultList, readErrorCount);
528+
529+
if (readSuccessCount == 0) {
530+
LOG.debug(
531+
"{} read={}ms returned no indexable rows (warnings={}, errors={})",
532+
entityType,
533+
readDurationNanos / 1_000_000L,
534+
warningsCount,
535+
readErrorCount);
536+
return new BatchResult(0, readErrorCount, warningsCount, nextCursor);
548537
}
549538

550539
Map<String, Object> contextData = createContextData(entityType, statsTracker);
@@ -572,6 +561,44 @@ private BatchResult processBatch(
572561
}
573562
}
574563

564+
/**
565+
* Persist per-entity reader failures so that downstream tooling (e.g. the failures dashboard)
566+
* can show which specific records the reader could not hydrate. Runs whether or not the batch
567+
* has any successful rows — losing failure diagnostics for "all-error" batches would defeat
568+
* the point of the recorder.
569+
*/
570+
private void recordReaderFailures(
571+
String entityType, ResultList<?> resultList, int readErrorCount) {
572+
if (failureRecorder == null || readErrorCount == 0 || resultList == null) {
573+
return;
574+
}
575+
for (EntityError entityError : listOrEmpty(resultList.getErrors())) {
576+
Object rawEntity = entityError.getEntity();
577+
String entityId = null;
578+
if (rawEntity instanceof EntityInterface) {
579+
UUID id = ((EntityInterface) rawEntity).getId();
580+
if (id != null) {
581+
entityId = id.toString();
582+
}
583+
} else if (rawEntity != null) {
584+
entityId = rawEntity.toString();
585+
}
586+
if (entityId == null) {
587+
// Time-series readers (EntityTimeSeriesRepository) build EntityError without an id —
588+
// they only have access to the JSON row, not the entity reference. Per-entity recording
589+
// requires an id, so log at DEBUG (not WARN) to avoid spamming logs for every error in
590+
// large time-series batches.
591+
LOG.debug(
592+
"No entityId on reader failure for entityType={} — skipping per-entity record. message={}",
593+
entityType,
594+
entityError.getMessage());
595+
continue;
596+
}
597+
failureRecorder.recordReaderEntityFailure(
598+
entityType, entityId, null, entityError.getMessage());
599+
}
600+
}
601+
575602
/**
576603
* Read entities from the database.
577604
*

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/searchIndex/listeners/QuartzProgressListener.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import org.openmetadata.service.apps.bundles.searchIndex.ReindexingJobContext;
2424
import org.openmetadata.service.apps.bundles.searchIndex.ReindexingProgressListener;
2525
import org.openmetadata.service.apps.bundles.searchIndex.distributed.DistributedJobContext;
26+
import org.openmetadata.service.apps.scheduler.OmAppJobListener;
2627
import org.openmetadata.service.socket.WebSocketManager;
2728
import org.quartz.JobExecutionContext;
2829

@@ -228,6 +229,7 @@ private void broadcastViaWebSocket(AppRunRecord appRecord) {
228229
private AppRunRecord getUpdatedAppRunRecord() {
229230
AppRunRecord appRecord = readExistingRecord();
230231
appRecord.setStatus(AppRunRecord.Status.fromValue(jobData.getStatus().value()));
232+
OmAppJobListener.fillTerminalTimings(appRecord);
231233

232234
if (jobData.getStats() != null) {
233235
SuccessContext ctx = appRecord.getSuccessContext();

openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/AppScheduler.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ private void updateAndBroadcastStoppedStatus(JobExecutionContext context) {
465465
if (runRecord != null) {
466466
// Update status to STOPPED
467467
runRecord.withStatus(AppRunRecord.Status.STOPPED);
468-
runRecord.withEndTime(System.currentTimeMillis());
468+
OmAppJobListener.fillTerminalTimings(runRecord);
469469

470470
// Get WebSocket channel name
471471
String webSocketChannelName =

openmetadata-service/src/main/java/org/openmetadata/service/apps/scheduler/OmAppJobListener.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,45 @@ protected OmAppJobListener() {
4747
this.repository = new AppRepository();
4848
}
4949

50+
/**
51+
* Populate {@code endTime} and {@code executionTime} on a terminal-state run record. Each field
52+
* is filled independently and only if currently null:
53+
*
54+
* <ul>
55+
* <li>{@code endTime} defaults to {@code System.currentTimeMillis()} if absent.
56+
* <li>{@code executionTime} is computed from {@code endTime - startTime} if absent and both
57+
* endpoints are available — this means callers that pre-populated {@code endTime} (e.g.
58+
* from {@code job.getCompletedAt()}) still get an accurate {@code executionTime}.
59+
* </ul>
60+
*
61+
* <p>The method is a no-op for non-terminal statuses, so it is safe to call from progress
62+
* listeners that may persist before {@link #jobWasExecuted} runs. Without this, mid-flight
63+
* writes by progress listeners (e.g. {@code QuartzProgressListener} firing {@code onJobFailed})
64+
* would persist a terminal status to the DB without timings; if the job dies before {@code
65+
* jobWasExecuted} fires, polling consumers would see {@code status=FAILED} with no
66+
* {@code endTime} / {@code executionTime}.
67+
*/
68+
public static void fillTerminalTimings(AppRunRecord record) {
69+
if (record == null || record.getStatus() == null || !isTerminalStatus(record.getStatus())) {
70+
return;
71+
}
72+
if (record.getEndTime() == null) {
73+
record.withEndTime(System.currentTimeMillis());
74+
}
75+
if (record.getExecutionTime() == null
76+
&& record.getStartTime() != null
77+
&& record.getEndTime() != null) {
78+
record.setExecutionTime(record.getEndTime() - record.getStartTime());
79+
}
80+
}
81+
82+
private static boolean isTerminalStatus(AppRunRecord.Status status) {
83+
return switch (status) {
84+
case SUCCESS, FAILED, ACTIVE_ERROR, STOPPED, COMPLETED -> true;
85+
default -> false;
86+
};
87+
}
88+
5089
@Override
5190
public String getName() {
5291
return JOB_LISTENER_NAME;

openmetadata-service/src/main/java/org/openmetadata/service/jdbi3/EntityRepository.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6607,6 +6607,13 @@ public final Fields getFields(String fields) {
66076607
return new Fields(allowedFields, fields);
66086608
}
66096609

6610+
public final Fields getOnlySupportedFields(String fields) {
6611+
if ("*".equals(fields)) {
6612+
return new Fields(allowedFields, String.join(",", allowedFields), true);
6613+
}
6614+
return new Fields(allowedFields, fields, true);
6615+
}
6616+
66106617
protected final Fields getFields(Set<String> fields) {
66116618
return new Fields(allowedFields, fields);
66126619
}

openmetadata-service/src/main/java/org/openmetadata/service/search/indexes/SearchIndex.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ public interface SearchIndex {
6767
"followers",
6868
"votes",
6969
"extension",
70+
"tags",
7071
"certification",
7172
"dataProducts");
7273

0 commit comments

Comments
 (0)