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

Reply via email to