Skip to content

Commit d1250ed

Browse files
committed
Fixes #31331: stop a single unevaluable event from discarding its whole change-event batch (#32953)
1 parent 599e542 commit d1250ed

8 files changed

Lines changed: 582 additions & 20 deletions

File tree

openmetadata-integration-tests/src/test/java/org/openmetadata/it/tests/AlertsRuleEvaluatorDeletedEntityIT.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
* after the entity is gone — the re-read then threw {@code EntityNotFoundException} and aborted the
7373
* whole subscription instead of simply not matching. The re-read also used {@code NON_DELETED},
7474
* which made every soft-delete event throw as well.
75+
*
76+
* <p>Also covers open-metadata/OpenMetadata#31331, the same batch loss reached through a different
77+
* exception: an entity type whose schema does not declare the filtered field at all.
7578
*/
7679
@Execution(ExecutionMode.CONCURRENT)
7780
@ExtendWith(TestNamespaceExtension.class)
@@ -161,6 +164,74 @@ void getFilteredEvents_deletedEntityInBatch_stillDeliversTheLiveEvent(TestNamesp
161164
"the hard-deleted entity no longer resolves a domain, so its event must not match");
162165
}
163166

167+
/**
168+
* #31331: a batch is filtered as a unit and its offset is committed either way, so an event whose
169+
* entity type cannot supply the filtered field must be dropped on its own. {@code domain.json}
170+
* declares no {@code domains} property, so re-reading it raised {@code IllegalArgumentException}
171+
* and took every other event in the batch down with it.
172+
*/
173+
@Test
174+
void getFilteredEvents_entityTypeWithoutDomains_stillDeliversTheMatchingEvent(TestNamespace ns) {
175+
Domain domain = createDomain(ns);
176+
Table table = createTable(ns, createDomainAssignment(domain), null);
177+
178+
ChangeEvent tableEvent =
179+
updateEvent(Entity.TABLE, payloadWithoutRelationships(table)).withId(UUID.randomUUID());
180+
ChangeEvent domainEvent = updateEvent(Entity.DOMAIN, domain).withId(UUID.randomUUID());
181+
182+
Map<ChangeEvent, Set<UUID>> batch = new LinkedHashMap<>();
183+
batch.put(domainEvent, Set.of(UUID.randomUUID()));
184+
batch.put(tableEvent, Set.of(UUID.randomUUID()));
185+
186+
Map<ChangeEvent, Set<UUID>> delivered =
187+
AlertUtil.getFilteredEvents(
188+
subscriptionOnAllResourcesFilteringOnDomain(domain.getFullyQualifiedName()),
189+
batch,
190+
null);
191+
192+
assertTrue(
193+
delivered.containsKey(tableEvent),
194+
"the matching event must survive a batch holding an entity type without domains");
195+
assertFalse(
196+
delivered.containsKey(domainEvent),
197+
"a domain declares no domains of its own, so its event must not match");
198+
}
199+
200+
/**
201+
* The same evaluation backs {@code /diagnosticInfo}, which walks unprocessed events in a parallel
202+
* stream with no isolation and answered 500 while such an event sat unprocessed.
203+
*/
204+
@Test
205+
void isChangeEventAllowed_entityTypeWithoutDomains_returnsFalseInsteadOfThrowing(
206+
TestNamespace ns) {
207+
Domain domain = createDomain(ns);
208+
ChangeEvent domainEvent = updateEvent(Entity.DOMAIN, domain).withId(UUID.randomUUID());
209+
210+
assertFalse(
211+
AlertUtil.isChangeEventAllowed(
212+
domainEvent,
213+
subscriptionOnAllResourcesFilteringOnDomain(domain.getFullyQualifiedName())
214+
.getFilteringRules(),
215+
null,
216+
AlertUtil.LOG_EVALUATION_ERROR),
217+
"a domain event must evaluate to false rather than throw out of the matcher");
218+
}
219+
220+
private EventSubscription subscriptionOnAllResourcesFilteringOnDomain(String domainFqn) {
221+
EventFilterRule rule =
222+
new EventFilterRule()
223+
.withName("matchAnyDomain")
224+
.withEffect(ArgumentsInput.Effect.INCLUDE)
225+
.withCondition("matchAnyDomain({'" + domainFqn + "'})");
226+
return new EventSubscription()
227+
.withName("alertAllResourcesDomainSubscription")
228+
.withFilteringRules(
229+
new FilteringRules()
230+
.withResources(List.of("all"))
231+
.withRules(List.of(rule))
232+
.withActions(List.of()));
233+
}
234+
164235
private EventSubscription subscriptionFilteringOnDomain(String domainFqn) {
165236
EventFilterRule rule =
166237
new EventFilterRule()

openmetadata-service/src/main/java/org/openmetadata/service/apps/bundles/changeEvent/AbstractEventConsumer.java

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import lombok.Getter;
3030
import lombok.Setter;
3131
import lombok.extern.slf4j.Slf4j;
32+
import org.apache.commons.lang3.tuple.Pair;
3233
import org.openmetadata.schema.api.events.CreateEventSubscription;
3334
import org.openmetadata.schema.entity.events.AlertMetrics;
3435
import org.openmetadata.schema.entity.events.EventSubscription;
@@ -254,7 +255,7 @@ public void publishEvents(Map<ChangeEvent, Set<UUID>> events) {
254255
return;
255256
}
256257
Map<ChangeEvent, Set<UUID>> filteredEvents =
257-
getFilteredEvents(eventSubscription, events, startingTimestamp);
258+
getFilteredEvents(eventSubscription, events, startingTimestamp, this::deadLetterEvent);
258259
RecipientResolver resolver = new RecipientResolver();
259260
int successDeliveries = 0;
260261
int failedDeliveries = 0;
@@ -273,6 +274,20 @@ public void publishEvents(Map<ChangeEvent, Set<UUID>> events) {
273274
alertMetrics.withFailedEvents(alertMetrics.getFailedEvents() + failedDeliveries);
274275
}
275276

277+
/** An event we could not even filter is a publisher-side failure, so record it as one. */
278+
private void deadLetterEvent(ChangeEvent event, Exception error) {
279+
LOG.error(
280+
"Event Subscription: {} could not evaluate filters for change event {}",
281+
eventSubscription.getName(),
282+
event.getId(),
283+
error);
284+
handleFailedEvent(
285+
new EventPublisherException(
286+
String.format("Failed to evaluate alert filters: %s", error.getMessage()),
287+
Pair.of(eventSubscription.getId(), event)),
288+
false);
289+
}
290+
276291
private EventDeliveryResult publishEvent(
277292
ChangeEvent event, Set<UUID> destinationIds, RecipientResolver resolver) {
278293
// Group destinations by type to enable cross-destination recipient deduplication

openmetadata-service/src/main/java/org/openmetadata/service/events/scheduled/EventSubscriptionScheduler.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,8 @@ public long getRelevantUnprocessedEvents(UUID subscriptionId) {
385385
.map(
386386
eventJson -> {
387387
ChangeEvent event = JsonUtils.readValue(eventJson, ChangeEvent.class);
388-
return AlertUtil.checkIfChangeEventIsAllowed(event, filteringRules, startingTimestamp)
388+
return AlertUtil.isChangeEventAllowed(
389+
event, filteringRules, startingTimestamp, AlertUtil.LOG_EVALUATION_ERROR)
389390
? event
390391
: null;
391392
})
@@ -489,7 +490,8 @@ public List<ChangeEvent> getRelevantUnprocessedEvents(
489490
.map(
490491
eventJson -> {
491492
ChangeEvent event = JsonUtils.readValue(eventJson, ChangeEvent.class);
492-
return AlertUtil.checkIfChangeEventIsAllowed(event, filteringRules, startingTimestamp)
493+
return AlertUtil.isChangeEventAllowed(
494+
event, filteringRules, startingTimestamp, AlertUtil.LOG_EVALUATION_ERROR)
493495
? event
494496
: null;
495497
})

openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/AlertUtil.java

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@
2626
import java.net.URI;
2727
import java.util.ArrayList;
2828
import java.util.Collections;
29+
import java.util.HashMap;
2930
import java.util.List;
3031
import java.util.Locale;
3132
import java.util.Map;
3233
import java.util.Objects;
3334
import java.util.OptionalLong;
3435
import java.util.Set;
3536
import java.util.UUID;
37+
import java.util.function.BiConsumer;
3638
import java.util.function.Function;
3739
import java.util.stream.Collectors;
3840
import lombok.extern.slf4j.Slf4j;
@@ -68,6 +70,15 @@ public final class AlertUtil {
6870

6971
private static final String FIELD_PIPELINE_STATUS = "pipelineStatus";
7072

73+
/** Default handler for {@link #isChangeEventAllowed}: log the event and leave it out. */
74+
public static final BiConsumer<ChangeEvent, Exception> LOG_EVALUATION_ERROR =
75+
(event, error) ->
76+
LOG.error(
77+
"Excluding change event {} on {}: alert filter evaluation failed",
78+
event.getId(),
79+
event.getEntityType(),
80+
error);
81+
7182
private AlertUtil() {}
7283

7384
public static <T> void validateExpression(String condition, Class<T> clz) {
@@ -253,13 +264,60 @@ public static Map<ChangeEvent, Set<UUID>> getFilteredEvents(
253264
EventSubscription eventSubscription,
254265
Map<ChangeEvent, Set<UUID>> events,
255266
Long startingTimestamp) {
267+
return getFilteredEvents(eventSubscription, events, startingTimestamp, LOG_EVALUATION_ERROR);
268+
}
269+
270+
public static Map<ChangeEvent, Set<UUID>> getFilteredEvents(
271+
EventSubscription eventSubscription,
272+
Map<ChangeEvent, Set<UUID>> events,
273+
Long startingTimestamp,
274+
BiConsumer<ChangeEvent, Exception> onEvaluationError) {
256275
Long watermark = alertingWatermark(eventSubscription, startingTimestamp);
257-
return events.entrySet().stream()
258-
.filter(
259-
entry ->
260-
checkIfChangeEventIsAllowed(
261-
entry.getKey(), eventSubscription.getFilteringRules(), watermark))
262-
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
276+
FilteringRules filteringRules = eventSubscription.getFilteringRules();
277+
Map<ChangeEvent, Set<UUID>> filteredEvents = new HashMap<>();
278+
for (Map.Entry<ChangeEvent, Set<UUID>> entry : events.entrySet()) {
279+
if (isChangeEventAllowed(entry.getKey(), filteringRules, watermark, onEvaluationError)) {
280+
filteredEvents.put(entry.getKey(), entry.getValue());
281+
}
282+
}
283+
return filteredEvents;
284+
}
285+
286+
/**
287+
* Evaluates one event in isolation, excluding it rather than letting the failure escape. Callers
288+
* evaluate whole batches, and the consumer commits its offset either way, so an exception thrown
289+
* out of a single matcher would silently drop every other event with it (issue #31331). The catch
290+
* is deliberately cause-agnostic: matchers reach the store, the SpEL runtime and the event
291+
* payload, and none of those failures may cost an unrelated event its notification.
292+
*/
293+
public static boolean isChangeEventAllowed(
294+
ChangeEvent event,
295+
FilteringRules filteringRules,
296+
Long startingTimestamp,
297+
BiConsumer<ChangeEvent, Exception> onEvaluationError) {
298+
boolean allowed;
299+
try {
300+
allowed = checkIfChangeEventIsAllowed(event, filteringRules, startingTimestamp);
301+
} catch (Exception e) {
302+
reportEvaluationError(onEvaluationError, event, e);
303+
allowed = false;
304+
}
305+
return allowed;
306+
}
307+
308+
/**
309+
* Runs the failure handler without letting it become a second failure. The consumer's handler
310+
* writes a dead-letter row, so a transient database error there would otherwise escape this
311+
* method, abort the surrounding batch loop and lose the very events this isolation exists to
312+
* protect.
313+
*/
314+
private static void reportEvaluationError(
315+
BiConsumer<ChangeEvent, Exception> onEvaluationError, ChangeEvent event, Exception error) {
316+
try {
317+
onEvaluationError.accept(event, error);
318+
} catch (Exception handlerError) {
319+
LOG.error("Failed to record unevaluable change event {}", event.getId(), handlerError);
320+
}
263321
}
264322

265323
/**

openmetadata-service/src/main/java/org/openmetadata/service/events/subscription/AlertsRuleEvaluator.java

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import java.util.Optional;
2323
import java.util.Set;
2424
import java.util.UUID;
25+
import java.util.function.Predicate;
2526
import java.util.stream.Collectors;
2627
import lombok.extern.slf4j.Slf4j;
2728
import org.openmetadata.schema.EntityInterface;
@@ -51,11 +52,17 @@
5152
import org.openmetadata.service.Entity;
5253
import org.openmetadata.service.exception.EntityNotFoundException;
5354
import org.openmetadata.service.formatter.util.FormatterUtil;
55+
import org.openmetadata.service.jdbi3.EntityRepository;
5456
import org.openmetadata.service.jdbi3.TaskRepository;
5557
import org.openmetadata.service.resources.feeds.MessageParser;
5658
import org.openmetadata.service.util.EntityUtil.RelationIncludes;
5759
import org.openmetadata.service.util.FullyQualifiedName;
5860

61+
/**
62+
* SpEL matchers for alert filtering rules. A matcher returns {@code false} when it cannot evaluate
63+
* and must never throw for a well-formed event: it runs inside a change-event batch whose offset is
64+
* committed either way, so an escaping exception silently discards every other event in that batch.
65+
*/
5966
@Slf4j
6067
public class AlertsRuleEvaluator {
6168
private static final String FIELD_TEST_SUITES_AND_OWNERS =
@@ -150,7 +157,8 @@ private boolean matchesEntityOrTestSuiteOwner(
150157

151158
private List<EntityReference> resolveOwners(EntityInterface entity) {
152159
List<EntityReference> ownerReferences = entity.getOwners();
153-
if (nullOrEmpty(ownerReferences)) {
160+
if (nullOrEmpty(ownerReferences)
161+
&& supports(changeEvent.getEntityType(), EntityRepository::isSupportsOwners)) {
154162
EntityInterface storedEntity = readStoredEntity(entity.getId(), Entity.FIELD_OWNERS);
155163
ownerReferences = storedEntity == null ? ownerReferences : storedEntity.getOwners();
156164
}
@@ -496,9 +504,11 @@ public boolean matchAnyDomain(List<String> fieldChangeUpdate) {
496504
}
497505

498506
private boolean matchesEntityOrTestSuiteDomain(EntityInterface entity, List<String> domainFqns) {
499-
EntityInterface storedEntity = readStoredEntity(entity.getId(), Entity.FIELD_DOMAINS);
500-
List<EntityReference> domains =
501-
storedEntity == null ? entity.getDomains() : storedEntity.getDomains();
507+
List<EntityReference> domains = entity.getDomains();
508+
if (supports(changeEvent.getEntityType(), EntityRepository::isSupportsDomains)) {
509+
EntityInterface storedEntity = readStoredEntity(entity.getId(), Entity.FIELD_DOMAINS);
510+
domains = storedEntity == null ? domains : storedEntity.getDomains();
511+
}
502512
boolean matched = matchesAnyDomainFqn(domains, domainFqns);
503513
if (!matched && TEST_CASE.equals(changeEvent.getEntityType())) {
504514
// If we did not match on the domain and are dealing with a test case,
@@ -531,6 +541,24 @@ private <T extends EntityInterface> T readStoredEntity(UUID entityId, String fie
531541
changeEvent.getEntityType(), entityId, fields, DELETED_TOLERANT_SUBJECT);
532542
}
533543

544+
/**
545+
* Whether {@code entityType}'s repository declares the capability, so a matcher can skip the store
546+
* re-read for a field the entity's schema does not declare. Reading it anyway raises
547+
* {@code IllegalArgumentException} out of the matcher and discards the whole change-event batch
548+
* (issue #31331). An unregistered type reads as unsupported: a feed subject can name one.
549+
*/
550+
private static boolean supports(String entityType, Predicate<EntityRepository<?>> capability) {
551+
boolean supported = false;
552+
if (entityType != null) {
553+
try {
554+
supported = capability.test(Entity.getEntityRepository(entityType));
555+
} catch (EntityNotFoundException e) {
556+
LOG.debug("No repository for {}, treating the field as unsupported", entityType);
557+
}
558+
}
559+
return supported;
560+
}
561+
534562
private List<TestSuite> resolveTestSuites(TestCase testCase, String fields) {
535563
TestCase storedTestCase = readStoredEntity(testCase.getId(), fields);
536564
List<TestSuite> testSuites =
@@ -770,18 +798,28 @@ private static UUID parseUuidOrNull(String id) {
770798

771799
private boolean threadSubjectMatchesOwner(List<String> ownerNameList) {
772800
EntityInterface subject =
773-
Entity.getEntityOrNull(threadSubject(), Entity.FIELD_OWNERS, Include.NON_DELETED);
801+
readFeedSubject(Entity.FIELD_OWNERS, EntityRepository::isSupportsOwners);
774802
return subject != null
775803
&& !nullOrEmpty(subject.getOwners())
776804
&& matchOwners(subject.getOwners(), ownerNameList);
777805
}
778806

779807
private boolean threadSubjectMatchesDomain(List<String> domainFqns) {
780808
EntityInterface subject =
781-
Entity.getEntityOrNull(threadSubject(), Entity.FIELD_DOMAINS, Include.NON_DELETED);
809+
readFeedSubject(Entity.FIELD_DOMAINS, EntityRepository::isSupportsDomains);
782810
return subject != null && matchesAnyDomainFqn(subject.getDomains(), domainFqns);
783811
}
784812

813+
/** The feed's subject read with {@code field}, or null when its type cannot supply that field. */
814+
private EntityInterface readFeedSubject(String field, Predicate<EntityRepository<?>> capability) {
815+
EntityReference subject = threadSubject();
816+
EntityInterface entity = null;
817+
if (subject != null && supports(subject.getType(), capability)) {
818+
entity = Entity.getEntityOrNull(subject, field, Include.NON_DELETED);
819+
}
820+
return entity;
821+
}
822+
785823
private boolean matchOwners(List<EntityReference> ownerReferences, List<String> ownerNameList) {
786824
Set<String> ownerNames =
787825
ownerNameList.stream().map(EntityInterfaceUtil::unquoteName).collect(Collectors.toSet());

0 commit comments

Comments
 (0)