This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new d4b57f052 UNOMI-964: Fix flaky GraphQLListIT via live condition-type
resolution (#827)
d4b57f052 is described below
commit d4b57f052217fea1d9d9483bcddeafe98fe74659
Author: Serge Huber <[email protected]>
AuthorDate: Thu Jul 16 09:35:44 2026 +0200
UNOMI-964: Fix flaky GraphQLListIT via live condition-type resolution (#827)
Merge flaky test fixes
---
.../org/apache/unomi/api/utils/DiagnosticLog.java | 90 ++++++++++++++
build.sh | 26 ++--
.../commands/list/AddProfileToListCommand.java | 12 +-
.../list/RemoveProfileFromListCommand.java | 12 +-
.../condition/factories/ConditionFactory.java | 47 ++++++-
.../condition/factories/EventConditionFactory.java | 9 +-
.../factories/ProfileAliasConditionFactory.java | 10 +-
.../factories/ProfileConditionFactory.java | 9 +-
.../condition/factories/TopicConditionFactory.java | 10 +-
.../commands/list/AddProfileToListCommandTest.java | 135 ++++++++++++++++++++
.../list/RemoveProfileFromListCommandTest.java | 137 +++++++++++++++++++++
.../condition/factories/ConditionFactoryTest.java | 103 ++++++++++++++++
.../org/apache/unomi/itests/RuleServiceIT.java | 49 --------
.../apache/unomi/itests/graphql/GraphQLListIT.java | 18 ++-
.../ConditionESQueryBuilderDispatcher.java | 23 ++++
.../ElasticSearchPersistenceServiceImpl.java | 34 ++++-
.../ConditionOSQueryBuilderDispatcher.java | 23 ++++
.../OpenSearchPersistenceServiceImpl.java | 18 +++
.../services/impl/rules/RulesServiceImplTest.java | 90 ++++++++++++++
19 files changed, 747 insertions(+), 108 deletions(-)
diff --git a/api/src/main/java/org/apache/unomi/api/utils/DiagnosticLog.java
b/api/src/main/java/org/apache/unomi/api/utils/DiagnosticLog.java
new file mode 100644
index 000000000..3ee2eb298
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/utils/DiagnosticLog.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.api.utils;
+
+import org.slf4j.Logger;
+
+/**
+ * Small, reusable helper for emitting structured, greppable diagnostic log
lines when investigating
+ * intermittent/flaky behaviour (e.g. match-none queries, index/rollover
lifecycle gaps, eventual-consistency
+ * timeouts). It is intentionally generic so the same convention can be reused
across modules instead of
+ * bespoke one-off logging.
+ * <p>
+ * All lines share a common {@link #PREFIX} and a {@code category}, followed
by {@code key=value} pairs, e.g.:
+ * <pre>[unomi-diag] category=es-match-none conditionTypeId=null
visibleConditionTypes=42</pre>
+ * Grep for {@code [unomi-diag]} to collect every diagnostic, or {@code
category=<name>} for a specific one.
+ * <p>
+ * Callers pass their own {@link Logger} so the originating class still shows
up in the log output. Formatting
+ * only happens when the relevant level is enabled and, because these lines
are meant for the (rare) failure
+ * paths, they add no cost to the happy path.
+ */
+public final class DiagnosticLog {
+
+ /** Common prefix on every diagnostic line; grep this to collect all
diagnostics. */
+ public static final String PREFIX = "[unomi-diag]";
+
+ private DiagnosticLog() {
+ }
+
+ /**
+ * Emits a WARN-level diagnostic line.
+ *
+ * @param logger the caller's logger (so the source class is
preserved); ignored if {@code null}
+ * @param category short, stable category name (e.g. {@code
es-match-none}, {@code rollover-index})
+ * @param keyValues alternating key/value pairs; a trailing key without a
value renders as empty
+ */
+ public static void warn(final Logger logger, final String category, final
Object... keyValues) {
+ if (logger != null && logger.isWarnEnabled()) {
+ logger.warn("{} category={} {}", PREFIX, category,
format(keyValues));
+ }
+ }
+
+ /**
+ * Emits an INFO-level diagnostic line. Use for lifecycle checkpoints that
are useful even on healthy runs.
+ *
+ * @param logger the caller's logger (so the source class is
preserved); ignored if {@code null}
+ * @param category short, stable category name
+ * @param keyValues alternating key/value pairs; a trailing key without a
value renders as empty
+ */
+ public static void info(final Logger logger, final String category, final
Object... keyValues) {
+ if (logger != null && logger.isInfoEnabled()) {
+ logger.info("{} category={} {}", PREFIX, category,
format(keyValues));
+ }
+ }
+
+ /**
+ * Formats alternating key/value pairs into a single {@code key=value
key=value} string.
+ *
+ * @param keyValues alternating key/value pairs
+ * @return the formatted string (empty when no pairs are provided)
+ */
+ public static String format(final Object... keyValues) {
+ if (keyValues == null || keyValues.length == 0) {
+ return "";
+ }
+ final StringBuilder sb = new StringBuilder();
+ for (int i = 0; i < keyValues.length; i += 2) {
+ if (i > 0) {
+ sb.append(' ');
+ }
+ sb.append(keyValues[i]).append('=');
+ sb.append(i + 1 < keyValues.length ? String.valueOf(keyValues[i +
1]) : "");
+ }
+ return sb.toString();
+ }
+}
diff --git a/build.sh b/build.sh
index fd28ed1ff..c74fd5eb8 100755
--- a/build.sh
+++ b/build.sh
@@ -264,6 +264,7 @@ SEARCH_HEAP=""
KARAF_HEAP=""
MAVEN_QUIET=false
NO_KARAF=false
+SKIP_CLEAN=false
AUTO_START=""
# Only initialize UNOMI_DISTRIBUTION if not already set (e.g., by
setup-opensearch.sh or setup-elasticsearch.sh)
if [ -z "${UNOMI_DISTRIBUTION+x}" ]; then
@@ -315,6 +316,7 @@ EOF
echo -e " ${CYAN}--use-opensearch${NC} Use OpenSearch
instead of ElasticSearch"
echo -e " ${CYAN}--distribution DIST${NC} Set Unomi
distribution (e.g., unomi-distribution-opensearch)"
echo -e " ${CYAN}--no-karaf${NC} Build without
starting Karaf"
+ echo -e " ${CYAN}--no-clean${NC} Skip 'mvn clean'
(reuse previous build; faster re-runs)"
echo -e " ${CYAN}--auto-start ENGINE${NC} Auto-start with
specified engine"
echo -e " ${CYAN}--single-test TEST${NC} Run a single
integration test"
echo -e " ${CYAN}--it-debug${NC} Enable integration
test debug mode"
@@ -358,6 +360,7 @@ EOF
echo " --use-opensearch Use OpenSearch instead of
ElasticSearch"
echo " --distribution DIST Set Unomi distribution (e.g.,
unomi-distribution-opensearch)"
echo " --no-karaf Build without starting Karaf"
+ echo " --no-clean Skip 'mvn clean' (reuse previous
build; faster re-runs)"
echo " --auto-start ENGINE Auto-start with specified engine"
echo " --single-test TEST Run a single integration test"
echo " --it-debug Enable integration test debug mode"
@@ -499,6 +502,9 @@ while [ "$1" != "" ]; do
--no-karaf)
NO_KARAF=true
;;
+ --no-clean)
+ SKIP_CLEAN=true
+ ;;
--auto-start)
shift
if [[ "$1" != "elasticsearch" && "$1" != "opensearch" ]]; then
@@ -1238,16 +1244,20 @@ finalize_it_run_trace() {
} >> "$trace_file"
}
-print_progress $((++current_step)) $total_steps "Cleaning previous build..."
-if [ "$HAS_COLORS" -eq 1 ]; then
- echo -e "${GRAY}Running: $MVN_CMD clean $MVN_OPTS${NC}"
+if [ "$SKIP_CLEAN" = true ]; then
+ print_status "info" "Skipping 'mvn clean' (--no-clean): reusing previous
build output"
else
- echo "Running: $MVN_CMD clean $MVN_OPTS"
+ print_progress $((++current_step)) $total_steps "Cleaning previous
build..."
+ if [ "$HAS_COLORS" -eq 1 ]; then
+ echo -e "${GRAY}Running: $MVN_CMD clean $MVN_OPTS${NC}"
+ else
+ echo "Running: $MVN_CMD clean $MVN_OPTS"
+ fi
+ $MVN_CMD clean $MVN_OPTS || {
+ print_status "error" "Maven clean failed"
+ exit 1
+ }
fi
-$MVN_CMD clean $MVN_OPTS || {
- print_status "error" "Maven clean failed"
- exit 1
-}
if [ "$RUN_INTEGRATION_TESTS" = true ]; then
write_it_run_trace_start
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommand.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommand.java
index 4c7e69e77..e4499a739 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommand.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommand.java
@@ -28,12 +28,16 @@ import org.apache.unomi.graphql.types.output.CDPList;
import org.apache.unomi.graphql.utils.EventBuilder;
import org.apache.unomi.lists.UserList;
import org.apache.unomi.services.UserListService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Objects;
public class AddProfileToListCommand extends BaseCommand<CDPList> {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(AddProfileToListCommand.class.getName());
+
private final String listId;
private final CDPProfileIDInput profileIDInput;
private final Boolean active;
@@ -66,7 +70,13 @@ public class AddProfileToListCommand extends
BaseCommand<CDPList> {
.setPersistent(true)
.build();
- if (serviceManager.getService(EventService.class).send(event) ==
EventService.PROFILE_UPDATED) {
+ final int changes =
serviceManager.getService(EventService.class).send(event);
+
+ if ((changes & EventService.ERROR) == EventService.ERROR) {
+ LOGGER.warn("Error processing addProfileToList event for profile
{} and list {}", profileIDInput.getId(), listId);
+ }
+
+ if ((changes & EventService.PROFILE_UPDATED) ==
EventService.PROFILE_UPDATED) {
profileService.save(event.getProfile());
}
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommand.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommand.java
index 1415bb756..65b29e947 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommand.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommand.java
@@ -26,12 +26,16 @@ import
org.apache.unomi.graphql.types.input.CDPProfileIDInput;
import org.apache.unomi.graphql.utils.EventBuilder;
import org.apache.unomi.lists.UserList;
import org.apache.unomi.services.UserListService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.util.Collections;
import java.util.Objects;
public class RemoveProfileFromListCommand extends BaseCommand<Boolean> {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(RemoveProfileFromListCommand.class.getName());
+
private final String listId;
private final CDPProfileIDInput profileIDInput;
@@ -62,9 +66,13 @@ public class RemoveProfileFromListCommand extends
BaseCommand<Boolean> {
.setPersistent(true)
.build();
- int eventCode =
serviceManager.getService(EventService.class).send(event);
+ final int changes =
serviceManager.getService(EventService.class).send(event);
+
+ if ((changes & EventService.ERROR) == EventService.ERROR) {
+ LOGGER.warn("Error processing removeProfileFromList event for
profile {} and list {}", profileIDInput.getId(), listId);
+ }
- if (eventCode == EventService.PROFILE_UPDATED) {
+ if ((changes & EventService.PROFILE_UPDATED) ==
EventService.PROFILE_UPDATED) {
profileService.save(event.getProfile());
return true;
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ConditionFactory.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ConditionFactory.java
index 87a6384b6..5d1088ddb 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ConditionFactory.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ConditionFactory.java
@@ -18,9 +18,12 @@
package org.apache.unomi.graphql.condition.factories;
import graphql.schema.DataFetchingEnvironment;
+import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.conditions.ConditionType;
import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.utils.DiagnosticLog;
import org.apache.unomi.graphql.services.ServiceManager;
import org.apache.unomi.graphql.utils.ConditionBuilder;
import org.apache.unomi.graphql.utils.DateUtils;
@@ -46,17 +49,12 @@ public class ConditionFactory {
protected String conditionTypeId;
- private Map<String, ConditionType> conditionTypesMap;
-
public ConditionFactory(final String conditionTypeId, final
DataFetchingEnvironment environment) {
this.environment = environment;
this.conditionTypeId = conditionTypeId;
final ServiceManager context = environment.getContext();
this.definitionsService = context.getService(DefinitionsService.class);
-
- this.conditionTypesMap =
definitionsService.getAllConditionTypes().stream()
- .collect(Collectors.toMap(ConditionType::getItemId,
Function.identity()));
}
public Condition matchAllCondition() {
@@ -127,7 +125,44 @@ public class ConditionFactory {
}
public ConditionType getConditionType(final String typeId) {
- return this.conditionTypesMap.get(typeId);
+ final ConditionType conditionType =
definitionsService.getConditionType(typeId);
+ if (conditionType == null) {
+ logNullConditionTypeDiagnostics(typeId);
+ }
+ return conditionType;
+ }
+
+ /**
+ * A null condition type here becomes a match-none query downstream (see
TypeResolutionServiceImpl
+ * "Condition has no type ID" + query-builder "returning match-none").
Capture the current execution
+ * context so we can tell whether the null is caused by a missing/blank
tenant on the request thread,
+ * an empty definitions cache, or a genuinely absent type in the system
tenant. Only runs on the (rare)
+ * null path, so it adds no cost to the happy path.
+ */
+ private void logNullConditionTypeDiagnostics(final String typeId) {
+ String currentTenant = "<unavailable>";
+ int visibleConditionTypes = -1;
+ Boolean resolvableUnderSystemTenant = null;
+ try {
+ final ServiceManager serviceManager = environment.getContext();
+ visibleConditionTypes =
definitionsService.getAllConditionTypes().size();
+
+ final ExecutionContextManager contextManager =
serviceManager.getService(ExecutionContextManager.class);
+ if (contextManager != null) {
+ final ExecutionContext context =
contextManager.getCurrentContext();
+ currentTenant = context == null ? "<null-context>" :
String.valueOf(context.getTenantId());
+ resolvableUnderSystemTenant =
+ contextManager.executeAsSystem(() ->
definitionsService.getConditionType(typeId)) != null;
+ }
+ } catch (Exception e) {
+ DiagnosticLog.warn(LOGGER, "graphql-condition-type-null-error",
"typeId", typeId, "error", e.getMessage());
+ }
+ DiagnosticLog.warn(LOGGER, "graphql-condition-type-null",
+ "typeId", typeId,
+ "effect", "match-none",
+ "currentTenant", currentTenant,
+ "visibleConditionTypesInContext", visibleConditionTypes,
+ "resolvableUnderSystemTenant", resolvableUnderSystemTenant);
}
public <INPUT> Condition filtersToCondition(
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/EventConditionFactory.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/EventConditionFactory.java
index d9bc0c8a0..734f236f5 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/EventConditionFactory.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/EventConditionFactory.java
@@ -38,13 +38,8 @@ import java.util.stream.Collectors;
public class EventConditionFactory extends ConditionFactory {
- private static EventConditionFactory instance;
-
- public static synchronized EventConditionFactory get(final
DataFetchingEnvironment environment) {
- if (instance == null) {
- instance = new EventConditionFactory(environment);
- }
- return instance;
+ public static EventConditionFactory get(final DataFetchingEnvironment
environment) {
+ return new EventConditionFactory(environment);
}
private EventConditionFactory(final DataFetchingEnvironment environment) {
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileAliasConditionFactory.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileAliasConditionFactory.java
index 28c6340aa..3fdcad1bf 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileAliasConditionFactory.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileAliasConditionFactory.java
@@ -26,14 +26,8 @@ import java.util.Map;
public class ProfileAliasConditionFactory extends ConditionFactory {
- private static ProfileAliasConditionFactory instance;
-
- public static synchronized ProfileAliasConditionFactory get(final
DataFetchingEnvironment environment) {
- if (instance == null) {
- instance = new ProfileAliasConditionFactory(environment);
- }
-
- return instance;
+ public static ProfileAliasConditionFactory get(final
DataFetchingEnvironment environment) {
+ return new ProfileAliasConditionFactory(environment);
}
private ProfileAliasConditionFactory(final DataFetchingEnvironment
environment) {
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileConditionFactory.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileConditionFactory.java
index 7fd7666ae..727d5ceec 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileConditionFactory.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/ProfileConditionFactory.java
@@ -39,13 +39,8 @@ import java.util.stream.Collectors;
public class ProfileConditionFactory extends ConditionFactory {
- private static ProfileConditionFactory instance;
-
- public static synchronized ProfileConditionFactory get(final
DataFetchingEnvironment environment) {
- if (instance == null) {
- instance = new ProfileConditionFactory(environment);
- }
- return instance;
+ public static ProfileConditionFactory get(final DataFetchingEnvironment
environment) {
+ return new ProfileConditionFactory(environment);
}
private ProfileConditionFactory(final DataFetchingEnvironment environment)
{
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/TopicConditionFactory.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/TopicConditionFactory.java
index 9c683619d..d75131dd3 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/TopicConditionFactory.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/condition/factories/TopicConditionFactory.java
@@ -26,14 +26,8 @@ import java.util.Map;
public class TopicConditionFactory extends ConditionFactory {
- private static TopicConditionFactory instance;
-
- public static synchronized TopicConditionFactory get(final
DataFetchingEnvironment environment) {
- if (instance == null) {
- instance = new TopicConditionFactory(environment);
- }
-
- return instance;
+ public static TopicConditionFactory get(final DataFetchingEnvironment
environment) {
+ return new TopicConditionFactory(environment);
}
private TopicConditionFactory(final DataFetchingEnvironment environment) {
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommandTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommandTest.java
new file mode 100644
index 000000000..b8e700346
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/list/AddProfileToListCommandTest.java
@@ -0,0 +1,135 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.graphql.commands.list;
+
+import graphql.schema.DataFetchingEnvironment;
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.types.input.CDPProfileIDInput;
+import org.apache.unomi.lists.UserList;
+import org.apache.unomi.services.UserListService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Guards the UNOMI-964 bitmask fix in {@link AddProfileToListCommand}. {@link
EventService#send}
+ * returns a bitmask; the profile must be persisted whenever the
PROFILE_UPDATED bit is set,
+ * even when it is OR-ed with other change flags. The previous {@code ==
PROFILE_UPDATED} check
+ * silently dropped the save whenever another flag was also set.
+ */
+@ExtendWith(MockitoExtension.class)
+class AddProfileToListCommandTest {
+
+ private static final String LIST_ID = "testListId";
+ private static final String PROFILE_ID = "test_profile_id";
+
+ @Mock
+ private DataFetchingEnvironment environment;
+ @Mock
+ private ServiceManager serviceManager;
+ @Mock
+ private UserListService userListService;
+ @Mock
+ private ProfileService profileService;
+ @Mock
+ private EventService eventService;
+
+ private Profile profile;
+
+ @BeforeEach
+ void setUp() {
+ doReturn(serviceManager).when(environment).getContext();
+
when(serviceManager.getService(UserListService.class)).thenReturn(userListService);
+
when(serviceManager.getService(ProfileService.class)).thenReturn(profileService);
+
when(serviceManager.getService(EventService.class)).thenReturn(eventService);
+
+ final UserList userList = new UserList();
+ userList.setItemId(LIST_ID);
+ when(userListService.load(LIST_ID)).thenReturn(userList);
+
+ profile = new Profile(PROFILE_ID);
+ when(profileService.load(PROFILE_ID)).thenReturn(profile);
+ }
+
+ private AddProfileToListCommand command() {
+ return AddProfileToListCommand.create()
+ .listId(LIST_ID)
+ .profileIDInput(new CDPProfileIDInput(PROFILE_ID, null))
+ .setEnvironment(environment)
+ .build();
+ }
+
+ @Test
+ void savesProfile_whenEventReturnsProfileUpdatedExactly() {
+
when(eventService.send(any(Event.class))).thenReturn(EventService.PROFILE_UPDATED);
+
+ command().execute();
+
+ verify(profileService).save(profile);
+ }
+
+ @Test
+ void savesProfile_whenProfileUpdatedBitIsCombinedWithOtherFlags() {
+ when(eventService.send(any(Event.class)))
+ .thenReturn(EventService.PROFILE_UPDATED |
EventService.SESSION_UPDATED);
+
+ command().execute();
+
+ verify(profileService).save(profile);
+ }
+
+ @Test
+ void savesProfile_whenProfileUpdatedBitIsCombinedWithErrorFlag() {
+ when(eventService.send(any(Event.class)))
+ .thenReturn(EventService.PROFILE_UPDATED | EventService.ERROR);
+
+ command().execute();
+
+ verify(profileService).save(profile);
+ }
+
+ @Test
+ void doesNotSaveProfile_whenEventReportsNoChange() {
+
when(eventService.send(any(Event.class))).thenReturn(EventService.NO_CHANGE);
+
+ command().execute();
+
+ verify(profileService, never()).save(any(Profile.class));
+ }
+
+ @Test
+ void doesNotSaveProfile_whenProfileUpdatedBitIsNotSet() {
+
when(eventService.send(any(Event.class))).thenReturn(EventService.SESSION_UPDATED);
+
+ command().execute();
+
+ verify(profileService, never()).save(any(Profile.class));
+ }
+}
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommandTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommandTest.java
new file mode 100644
index 000000000..81080d8ad
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/commands/list/RemoveProfileFromListCommandTest.java
@@ -0,0 +1,137 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.graphql.commands.list;
+
+import graphql.schema.DataFetchingEnvironment;
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.graphql.services.ServiceManager;
+import org.apache.unomi.graphql.types.input.CDPProfileIDInput;
+import org.apache.unomi.lists.UserList;
+import org.apache.unomi.services.UserListService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Guards the same {@link EventService#send} bitmask handling in {@link
RemoveProfileFromListCommand}
+ * as {@link AddProfileToListCommandTest} does for {@link
AddProfileToListCommand}: the profile must
+ * be persisted whenever the PROFILE_UPDATED bit is set, even when OR-ed with
other change flags. The
+ * previous {@code == PROFILE_UPDATED} check silently dropped the save
whenever another flag was also set.
+ */
+@ExtendWith(MockitoExtension.class)
+class RemoveProfileFromListCommandTest {
+
+ private static final String LIST_ID = "testListId";
+ private static final String PROFILE_ID = "test_profile_id";
+
+ @Mock
+ private DataFetchingEnvironment environment;
+ @Mock
+ private ServiceManager serviceManager;
+ @Mock
+ private UserListService userListService;
+ @Mock
+ private ProfileService profileService;
+ @Mock
+ private EventService eventService;
+
+ private Profile profile;
+
+ @BeforeEach
+ void setUp() {
+ doReturn(serviceManager).when(environment).getContext();
+
when(serviceManager.getService(UserListService.class)).thenReturn(userListService);
+
when(serviceManager.getService(ProfileService.class)).thenReturn(profileService);
+
when(serviceManager.getService(EventService.class)).thenReturn(eventService);
+
+ final UserList userList = new UserList();
+ userList.setItemId(LIST_ID);
+ when(userListService.load(LIST_ID)).thenReturn(userList);
+
+ profile = new Profile(PROFILE_ID);
+ when(profileService.load(PROFILE_ID)).thenReturn(profile);
+ }
+
+ private RemoveProfileFromListCommand command() {
+ return RemoveProfileFromListCommand.create()
+ .listId(LIST_ID)
+ .profileIDInput(new CDPProfileIDInput(PROFILE_ID, null))
+ .setEnvironment(environment)
+ .build();
+ }
+
+ @Test
+ void savesProfileAndReturnsTrue_whenEventReturnsProfileUpdatedExactly() {
+
when(eventService.send(any(Event.class))).thenReturn(EventService.PROFILE_UPDATED);
+
+ assertTrue(command().execute());
+
+ verify(profileService).save(profile);
+ }
+
+ @Test
+ void
savesProfileAndReturnsTrue_whenProfileUpdatedBitIsCombinedWithOtherFlags() {
+ when(eventService.send(any(Event.class)))
+ .thenReturn(EventService.PROFILE_UPDATED |
EventService.SESSION_UPDATED);
+
+ assertTrue(command().execute());
+
+ verify(profileService).save(profile);
+ }
+
+ @Test
+ void
savesProfileAndReturnsTrue_whenProfileUpdatedBitIsCombinedWithErrorFlag() {
+ when(eventService.send(any(Event.class)))
+ .thenReturn(EventService.PROFILE_UPDATED | EventService.ERROR);
+
+ assertTrue(command().execute());
+
+ verify(profileService).save(profile);
+ }
+
+ @Test
+ void doesNotSaveProfileAndReturnsFalse_whenEventReportsNoChange() {
+
when(eventService.send(any(Event.class))).thenReturn(EventService.NO_CHANGE);
+
+ assertFalse(command().execute());
+
+ verify(profileService, never()).save(any(Profile.class));
+ }
+
+ @Test
+ void doesNotSaveProfileAndReturnsFalse_whenProfileUpdatedBitIsNotSet() {
+
when(eventService.send(any(Event.class))).thenReturn(EventService.SESSION_UPDATED);
+
+ assertFalse(command().execute());
+
+ verify(profileService, never()).save(any(Profile.class));
+ }
+}
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/condition/factories/ConditionFactoryTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/condition/factories/ConditionFactoryTest.java
new file mode 100644
index 000000000..cef87edb8
--- /dev/null
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/condition/factories/ConditionFactoryTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.apache.unomi.graphql.condition.factories;
+
+import graphql.schema.DataFetchingEnvironment;
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.services.DefinitionsService;
+import org.apache.unomi.graphql.services.ServiceManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Guards the UNOMI-964 fix: the GraphQL condition factories must resolve
condition types
+ * live from {@link DefinitionsService} (not from a frozen snapshot) and must
not be cached
+ * as static singletons. A stale snapshot captured before the condition-type
cache was warm
+ * produced null condition types, which the ES dispatcher turned into
match-none queries.
+ */
+@ExtendWith(MockitoExtension.class)
+class ConditionFactoryTest {
+
+ @Mock
+ private DataFetchingEnvironment environment;
+ @Mock
+ private ServiceManager serviceManager;
+ @Mock
+ private DefinitionsService definitionsService;
+
+ @BeforeEach
+ void setUp() {
+ doReturn(serviceManager).when(environment).getContext();
+
when(serviceManager.getService(DefinitionsService.class)).thenReturn(definitionsService);
+ }
+
+ @Test
+ void getConditionType_resolvesLiveFromDefinitionsService() {
+ final ConditionFactory factory = new
ConditionFactory("profilePropertyCondition", environment);
+
+ final ConditionType profilePropertyCondition =
mock(ConditionType.class);
+ // First lookup happens before the definition is registered, second
after: a live
+ // resolver must reflect the change without the factory being
recreated.
+ when(definitionsService.getConditionType("profilePropertyCondition"))
+ .thenReturn(null, profilePropertyCondition);
+
+ assertNull(factory.getConditionType("profilePropertyCondition"),
+ "Type should be absent while the definition cache is still
cold");
+ assertSame(profilePropertyCondition,
factory.getConditionType("profilePropertyCondition"),
+ "Type should be visible once the definition is registered
(live resolution)");
+ }
+
+ @Test
+ void constructor_doesNotSnapshotAllConditionTypes() {
+ new ConditionFactory("profilePropertyCondition", environment);
+
+ verify(definitionsService, never()).getAllConditionTypes();
+ }
+
+ @Test
+ void profileConditionFactory_returnsFreshInstancePerGet() {
+ assertNotSame(ProfileConditionFactory.get(environment),
ProfileConditionFactory.get(environment));
+ }
+
+ @Test
+ void eventConditionFactory_returnsFreshInstancePerGet() {
+ assertNotSame(EventConditionFactory.get(environment),
EventConditionFactory.get(environment));
+ }
+
+ @Test
+ void profileAliasConditionFactory_returnsFreshInstancePerGet() {
+ assertNotSame(ProfileAliasConditionFactory.get(environment),
ProfileAliasConditionFactory.get(environment));
+ }
+
+ @Test
+ void topicConditionFactory_returnsFreshInstancePerGet() {
+ assertNotSame(TopicConditionFactory.get(environment),
TopicConditionFactory.get(environment));
+ }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
b/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
index 5187810a1..c6135051a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/RuleServiceIT.java
@@ -220,55 +220,6 @@ public class RuleServiceIT extends BaseIT {
rulesService.refreshRules();
}
- @Test
- public void testRuleOptimizationPerf() throws NoSuchFieldException,
IllegalAccessException, IOException, InterruptedException {
- Profile profile = new Profile(UUID.randomUUID().toString());
- Session session = new Session(UUID.randomUUID().toString(), profile,
new Date(), TEST_SCOPE);
-
- updateConfiguration(null, "org.apache.unomi.services",
"rules.optimizationActivated", "false");
- rulesService = getService(RulesService.class);
- eventService = getService(EventService.class);
-
- LOGGER.info("Running unoptimized rules performance test...");
- long unoptimizedRunTime = runEventTest(profile, session);
-
- updateConfiguration(null, "org.apache.unomi.services",
"rules.optimizationActivated", "true");
- rulesService = getService(RulesService.class);
- eventService = getService(EventService.class);
-
- LOGGER.info("Running optimized rules performance test...");
- long optimizedRunTime = runEventTest(profile, session);
-
- double improvementRatio = ((double) unoptimizedRunTime) / ((double)
optimizedRunTime);
- LOGGER.info("Unoptimized run time = {}ms, optimized run time = {}ms.
Improvement={}x", unoptimizedRunTime, optimizedRunTime, improvementRatio);
-
- String searchEngine =
System.getProperty("org.apache.unomi.itests.searchEngine", "elasticsearch");
- // we check with a ratio of 0.7 because the test can sometimes fail
due to the fact that the sample size is small and can be affected by
- // environmental issues such as CPU or I/O load, JVM warmup, garbage
collection, etc.
- // The optimization may not always show improvement in a single test
run, but should not be significantly worse
- if ("opensearch".equals(searchEngine)) {
- // OpenSearch may have different performance characteristics
- assertTrue("Optimized run time should not be significantly worse
(ratio: " + improvementRatio + ")",
- improvementRatio > 0.7);
- } else {
- assertTrue("Optimized run time should not be significantly worse
(ratio: " + improvementRatio + ")",
- improvementRatio > 0.7);
- }
- }
-
- private long runEventTest(Profile profile, Session session) {
- LOGGER.info("eventService={}", eventService);
- Event viewEvent = generateViewEvent(session, profile);
- int loopCount = 0;
- long startTime = System.currentTimeMillis();
- while (loopCount < 500) {
- eventService.send(viewEvent);
- viewEvent = generateViewEvent(session, profile);
- loopCount++;
- }
- return System.currentTimeMillis() - startTime;
- }
-
private Event generateViewEvent(Session session, Profile profile) {
CustomItem sourceItem = new CustomItem();
sourceItem.setScope(TEST_SCOPE);
diff --git
a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
index b75a83ecb..7b378948e 100644
--- a/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/graphql/GraphQLListIT.java
@@ -75,18 +75,15 @@ public class GraphQLListIT extends BaseGraphQLIT {
Assert.assertEquals("testListId",
context.getValue("data.cdp.addProfileToList.id"));
}
- refreshPersistence(UserList.class);
+ refreshPersistence(UserList.class, Profile.class);
- // Profile.class refresh is deferred into the retry loop:
addProfileToList processes
- // list membership via an async rule-engine event, so the profile
write may not be
- // visible yet when we first reach this point.
+ // The list membership is written to the profile synchronously by
addProfileToList,
+ // then made searchable via the index refresh above; keepTrying
only absorbs normal
+ // Elasticsearch refresh latency.
final ResponseContext findListsContext = keepTrying("Failed
waiting for profile in list query",
() -> {
- try {
- refreshPersistence(Profile.class);
- try (CloseableHttpResponse response =
post("graphql/list/find-lists.json")) {
- return
ResponseContext.parse(response.getEntity());
- }
+ try (CloseableHttpResponse response =
post("graphql/list/find-lists.json")) {
+ return ResponseContext.parse(response.getEntity());
} catch (Exception e) {
return null;
}
@@ -102,8 +99,7 @@ public class GraphQLListIT extends BaseGraphQLIT {
Object profileId =
context.getValue("data.cdp.findLists.edges[0].node.active.edges[0].node.cdp_profileIDs[0].id");
return profile.getItemId().equals(profileId);
},
- // async rule-engine processing needs more retries on
loaded CI
- DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES * 3);
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
Assert.assertEquals("testListId",
findListsContext.getValue("data.cdp.findLists.edges[0].node.id"));
diff --git
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java
index ba91d1fe8..239ae260b 100644
---
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java
+++
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ConditionESQueryBuilderDispatcher.java
@@ -21,6 +21,7 @@ import
co.elastic.clients.elasticsearch._types.query_dsl.Query;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.services.DefinitionsService;
import org.apache.unomi.api.services.TypeResolutionService;
+import org.apache.unomi.api.utils.DiagnosticLog;
import org.apache.unomi.api.utils.ParserHelper;
import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper;
import
org.apache.unomi.persistence.spi.conditions.dispatcher.ConditionQueryBuilderDispatcher;
@@ -104,6 +105,27 @@ public class ConditionESQueryBuilderDispatcher extends
ConditionQueryBuilderDisp
return buildFilter(condition, new HashMap<>());
}
+ /**
+ * Emits a generic diagnostic when a condition collapses into a match-none
query, which silently makes a
+ * search return nothing. Captures how many condition types the
DefinitionsService can currently see so we
+ * can distinguish "this specific type is unresolved" from "the
definitions cache/context sees nothing".
+ */
+ private void logMatchNoneDiagnostics(String stage, String conditionTypeId)
{
+ int visibleConditionTypes = -1;
+ try {
+ if (definitionsService != null) {
+ visibleConditionTypes =
definitionsService.getAllConditionTypes().size();
+ }
+ } catch (Exception e) {
+ DiagnosticLog.warn(LOGGER, "es-match-none-error", "stage", stage,
"error", e.getMessage());
+ }
+ DiagnosticLog.warn(LOGGER, "es-match-none",
+ "stage", stage,
+ "conditionTypeId", conditionTypeId,
+ "definitionsServiceAvailable", definitionsService != null,
+ "visibleConditionTypes", visibleConditionTypes);
+ }
+
public Query buildFilter(Condition condition, Map<String, Object> context)
{
if (condition == null) {
throw new IllegalArgumentException("Condition is null, impossible
to build filter");
@@ -122,6 +144,7 @@ public class ConditionESQueryBuilderDispatcher extends
ConditionQueryBuilderDisp
// If still null after attempting resolution (or
definitionsService was null), return match-none
if (condition.getConditionType() == null) {
LOGGER.warn("Condition type is null for condition typeID={},
returning match-none query", condition.getConditionTypeId());
+ logMatchNoneDiagnostics("build-filter",
condition.getConditionTypeId());
return Query.of(q -> q.matchNone(m -> m));
}
}
diff --git
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index 2756e78ad..69ca79dc9 100644
---
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -69,6 +69,7 @@ import org.apache.unomi.api.query.NumericRange;
import org.apache.unomi.api.security.SecurityServiceConfiguration;
import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.tenants.TenantTransformationListener;
+import org.apache.unomi.api.utils.DiagnosticLog;
import org.apache.unomi.metrics.MetricAdapter;
import org.apache.unomi.metrics.MetricsService;
import org.apache.unomi.persistence.spi.PersistenceService;
@@ -1483,9 +1484,18 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
Phase hotPhase = new Phase.Builder().actions(new
Actions.Builder().rollover(rolloverAction).build())
.minAge(new
Time.Builder().time("0ms").build()).build();
IlmPolicy ilmPolicy = new IlmPolicy.Builder().phases(new
Phases.Builder().hot(hotPhase).build()).build();
+ String policyName = indexPrefix + "-" +
ROLLOVER_LIFECYCLE_NAME;
PutLifecycleRequest request = new
PutLifecycleRequest.Builder().policy(ilmPolicy)
- .name(indexPrefix + "-" +
ROLLOVER_LIFECYCLE_NAME).build();
+ .name(policyName).build();
PutLifecycleResponse response =
esClient.ilm().putLifecycle(request);
+ // RolloverIT asserts the event index references this policy;
if the policy itself was never
+ // acknowledged, every later "attach policy to index" step is
doomed, so record it explicitly.
+ DiagnosticLog.info(LOGGER, "rollover-policy-register",
+ "policyName", policyName,
+ "acknowledged", response.acknowledged(),
+ "maxAge", rolloverMaxAge,
+ "maxSize", rolloverMaxSize,
+ "maxDocs", rolloverMaxDocs);
return response.acknowledged();
}
}.catchingExecuteInClassLoader(true);
@@ -1698,6 +1708,28 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
hasDynamicTemplates = true;
}
+ // RolloverIT asserts the created event index carries
index.lifecycle.name; the checks above only
+ // verify folding + dynamic templates, so capture the lifecycle
attachment explicitly to reveal
+ // whether it is missing at creation time (the exact null
RolloverIT reports).
+ String lifecycleName = null;
+ String lifecycleRolloverAlias = null;
+ try {
+ var lifecycle = indexSettings.settings().index().lifecycle();
+ if (lifecycle != null) {
+ lifecycleName = lifecycle.name();
+ lifecycleRolloverAlias = lifecycle.rolloverAlias();
+ }
+ } catch (Exception e) {
+ DiagnosticLog.warn(LOGGER, "rollover-index-lifecycle-error",
"index", fullIndexName, "error", e.getMessage());
+ }
+ DiagnosticLog.info(LOGGER, "rollover-index-created",
+ "index", fullIndexName,
+ "attempt", retryCount + 1,
+ "hasFoldingAnalyzer", hasFoldingAnalyzer,
+ "hasDynamicTemplates", hasDynamicTemplates,
+ "lifecycleName", lifecycleName,
+ "lifecycleRolloverAlias", lifecycleRolloverAlias);
+
if (hasFoldingAnalyzer && hasDynamicTemplates) {
// Template was applied successfully
LOGGER.debug("Template successfully applied to index {} -
folding analyzer and dynamic templates present", fullIndexName);
diff --git
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java
index 4e5b22b18..b11cf3d20 100644
---
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java
+++
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/ConditionOSQueryBuilderDispatcher.java
@@ -20,6 +20,7 @@ package org.apache.unomi.persistence.opensearch;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.services.DefinitionsService;
import org.apache.unomi.api.services.TypeResolutionService;
+import org.apache.unomi.api.utils.DiagnosticLog;
import org.apache.unomi.api.utils.ParserHelper;
import org.apache.unomi.persistence.spi.conditions.ConditionContextHelper;
import
org.apache.unomi.persistence.spi.conditions.dispatcher.ConditionQueryBuilderDispatcher;
@@ -103,6 +104,27 @@ public class ConditionOSQueryBuilderDispatcher extends
ConditionQueryBuilderDisp
return buildFilter(condition, new HashMap<>());
}
+ /**
+ * Emits a generic diagnostic when a condition collapses into a match-none
query, which silently makes a
+ * search return nothing. Captures how many condition types the
DefinitionsService can currently see so we
+ * can distinguish "this specific type is unresolved" from "the
definitions cache/context sees nothing".
+ */
+ private void logMatchNoneDiagnostics(String stage, String conditionTypeId)
{
+ int visibleConditionTypes = -1;
+ try {
+ if (definitionsService != null) {
+ visibleConditionTypes =
definitionsService.getAllConditionTypes().size();
+ }
+ } catch (Exception e) {
+ DiagnosticLog.warn(LOGGER, "os-match-none-error", "stage", stage,
"error", e.getMessage());
+ }
+ DiagnosticLog.warn(LOGGER, "os-match-none",
+ "stage", stage,
+ "conditionTypeId", conditionTypeId,
+ "definitionsServiceAvailable", definitionsService != null,
+ "visibleConditionTypes", visibleConditionTypes);
+ }
+
public Query buildFilter(Condition condition, Map<String, Object> context)
{
if (condition == null) {
throw new IllegalArgumentException("Condition is null, impossible
to build filter");
@@ -121,6 +143,7 @@ public class ConditionOSQueryBuilderDispatcher extends
ConditionQueryBuilderDisp
// If still null after attempting resolution (or
definitionsService was null), return match-none
if (condition.getConditionType() == null) {
LOGGER.warn("Condition type is null for condition typeID={},
returning match-none query", condition.getConditionTypeId());
+ logMatchNoneDiagnostics("build-filter",
condition.getConditionTypeId());
return Query.of(q -> q.matchNone(t -> t));
}
}
diff --git
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index 7451ee5a5..823c33d3f 100644
---
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -45,6 +45,7 @@ import org.apache.unomi.api.query.NumericRange;
import org.apache.unomi.api.security.SecurityServiceConfiguration;
import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.tenants.TenantTransformationListener;
+import org.apache.unomi.api.utils.DiagnosticLog;
import org.apache.unomi.metrics.MetricAdapter;
import org.apache.unomi.metrics.MetricsService;
import org.apache.unomi.persistence.spi.PersistenceService;
@@ -1445,8 +1446,17 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
.build()
);
+ // RolloverIT asserts the event index references this ISM
policy; if the policy PUT itself
+ // failed, every later "attach policy to index" step is
doomed, so record it explicitly.
+ DiagnosticLog.info(LOGGER, "rollover-policy-register",
+ "policyName", policyName,
+ "httpStatus", response.getStatus(),
+ "maxAge", rolloverMaxAge,
+ "maxSize", rolloverMaxSize,
+ "maxDocs", rolloverMaxDocs);
return response.getStatus() == 200;
} catch (Exception e) {
+ DiagnosticLog.warn(LOGGER,
"rollover-policy-register-error", "error", e.getMessage());
LOGGER.error("Error registering rollover lifecycle
policy", e);
return false;
}
@@ -1657,6 +1667,14 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
failures = Boolean.TRUE.equals(parsed.get("failures"))
||
Integer.valueOf(0).equals(parsed.get("updated_indices"));
}
+ // RolloverIT asserts the event index is actually ISM-managed
(policy_id set + managed). This explicit
+ // attach is where that happens, so record the outcome to distinguish
"policy never attached" from
+ // "attached but index/query flaked later".
+ DiagnosticLog.info(LOGGER, "rollover-index-attach-policy",
+ "index", fullIndexName,
+ "policyName", policyName,
+ "httpStatus", response.getStatus(),
+ "attachFailed", failures);
if (failures) {
throw new IOException("Failed to attach ISM policy " + policyName
+ " to index " + fullIndexName +
" - status: " + response.getStatus() + ", body: " +
responseBody);
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/rules/RulesServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/rules/RulesServiceImplTest.java
index 8350631ba..753e15d27 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/rules/RulesServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/rules/RulesServiceImplTest.java
@@ -317,6 +317,96 @@ public class RulesServiceImplTest {
});
}
+ /**
+ * Replaces the flaky RuleServiceIT.testRuleOptimizationPerf IT. That test
timed full
+ * {@code eventService.send} loops in a PaxExam suite with a tiny rule
set, so the signal was
+ * dominated by persistence / GC / CPU noise and the assert had to be
diluted to "not ~30% worse".
+ * <p>
+ * Here we isolate {@link RulesServiceImpl#getMatchingRules(Event)} with a
large synthetic index
+ * (many rules for unrelated event types, one for the target type). The
optimized path should only
+ * evaluate the target-type bucket; the unoptimized path scans every rule.
That yields a large,
+ * stable speedup independent of Elasticsearch / OSGi lifecycle.
+ */
+ @Test
+ public void testEventTypeOptimizationOutperformsScanningAllRules() {
+ executionContextManager.executeAsTenant(TENANT_1, () -> {
+ final String targetEventType = "perf-target-view";
+ final int noiseRuleCount = 400;
+ final int warmUpIterations = 50;
+ final int timedIterations = 200;
+ // With ~400:1 evaluation count, a modest floor stays well clear
of timer noise.
+ final double minImprovementRatio = 5.0;
+
+ for (int i = 0; i < noiseRuleCount; i++) {
+ Rule noise = createEventTypeRule("noise-rule-" + i,
"noise-type-" + i, TENANT_1);
+ rulesService.setRule(noise);
+ }
+ Rule targetRule = createEventTypeRule("target-rule",
targetEventType, TENANT_1);
+ rulesService.setRule(targetRule);
+ // Do NOT call refreshRules(): it rebuilds the event-type index
from persistence, and the
+ // in-memory persistence's simulated ES refresh delay would
temporarily hide just-saved
+ // rules and wipe the index setRule already populated. setRule
indexes each rule itself.
+
+ Event event = createTestEvent();
+ event.setEventType(targetEventType);
+ event.setItemId("perf-target-event");
+ event.setScope(Metadata.SYSTEM_SCOPE);
+
+ // Correctness first: optimized matching must still find the
target rule.
+ rulesService.setOptimizedRulesActivated(true);
+ Set<Rule> optimizedMatches = waitForMatchingRules(event, 1);
+ assertTrue(optimizedMatches.stream().anyMatch(r ->
"target-rule".equals(r.getItemId())),
+ "Optimized matching should still find the target
event-type rule");
+
+ // Warm both code paths so timed runs are not dominated by
first-call class loading.
+ for (int i = 0; i < warmUpIterations; i++) {
+ rulesService.setOptimizedRulesActivated(false);
+ rulesService.getMatchingRules(event);
+ rulesService.setOptimizedRulesActivated(true);
+ rulesService.getMatchingRules(event);
+ }
+
+ long unoptimizedNanos = timeMatchingRules(event, false,
timedIterations);
+ long optimizedNanos = timeMatchingRules(event, true,
timedIterations);
+
+ double improvementRatio = (double) unoptimizedNanos / (double)
optimizedNanos;
+ assertTrue(unoptimizedNanos > 1_000_000L,
+ "Unoptimized run should take a measurable time (got " +
unoptimizedNanos + " ns)");
+ assertTrue(improvementRatio >= minImprovementRatio,
+ String.format(Locale.ROOT,
+ "Event-type indexing should be at least %.1fx
faster than scanning all rules "
+ + "(unoptimized=%dns, optimized=%dns,
ratio=%.2f)",
+ minImprovementRatio, unoptimizedNanos,
optimizedNanos, improvementRatio));
+
+ return null;
+ });
+ }
+
+ private long timeMatchingRules(Event event, boolean optimized, int
iterations) {
+ rulesService.setOptimizedRulesActivated(optimized);
+ long start = System.nanoTime();
+ for (int i = 0; i < iterations; i++) {
+ rulesService.getMatchingRules(event);
+ }
+ return System.nanoTime() - start;
+ }
+
+ private Rule createEventTypeRule(String ruleId, String eventType, String
tenantId) {
+ Rule rule = new Rule();
+ rule.setItemId(ruleId);
+ rule.setTenantId(tenantId);
+
+ Metadata metadata = new Metadata();
+ metadata.setId(ruleId);
+ metadata.setScope(Metadata.SYSTEM_SCOPE);
+ metadata.setEnabled(true);
+ rule.setMetadata(metadata);
+
+ rule.setCondition(createEventTypeCondition(eventType));
+ rule.setActions(Collections.singletonList(createTestAction()));
+ return rule;
+ }
+
@Test
public void testGetMatchingRules_WithMatchingRule() {
// Setup test data