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 ad5fefbb2 UNOMI-999: Keep scope purge limited to one scope (#907)
ad5fefbb2 is described below

commit ad5fefbb21630bb919cab7001a07caf87de28c81
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 6 15:19:41 2026 +0200

    UNOMI-999: Keep scope purge limited to one scope (#907)
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .../apache/unomi/itests/PersistenceServiceIT.java  | 130 ++++++++++++++++++++-
 .../ElasticSearchPersistenceServiceImpl.java       |  26 ++++-
 .../ElasticSearchScopePurgeQueryTest.java          | 107 +++++++++++++++++
 .../OpenSearchPersistenceServiceImpl.java          |  37 +++---
 .../opensearch/OpenSearchScopePurgeQueryTest.java  |  74 ++++++++++++
 .../unomi/persistence/spi/PersistenceService.java  |   5 +-
 .../impl/InMemoryPersistenceServiceImpl.java       |   4 +-
 .../impl/InMemoryPersistenceServiceImplTest.java   |   5 +-
 8 files changed, 358 insertions(+), 30 deletions(-)

diff --git 
a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
index a2c9e581e..05d00aef4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
@@ -16,8 +16,10 @@
  */
 package org.apache.unomi.itests;
 
+import org.apache.unomi.api.Event;
 import org.apache.unomi.api.PartialList;
 import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.tenants.Tenant;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Test;
@@ -27,13 +29,14 @@ import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
 import org.ops4j.pax.exam.spi.reactors.PerSuite;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
 import java.util.List;
 import java.util.UUID;
 
 /**
- * Integration tests for {@link 
org.apache.unomi.persistence.spi.PersistenceService} query APIs against the live
- * search backend (Elasticsearch or OpenSearch). Initial coverage focuses on 
{@code rangeQuery}; additional methods
- * should be covered in follow-up work (UNOMI-956).
+ * Integration tests for {@link 
org.apache.unomi.persistence.spi.PersistenceService} against the live
+ * search backend (Elasticsearch or OpenSearch).
  */
 @RunWith(PaxExam.class)
 @ExamReactorStrategy(PerSuite.class)
@@ -42,14 +45,19 @@ public class PersistenceServiceIT extends BaseIT {
     private static final String AGE_PROPERTY = "properties.age";
 
     private final List<String> profileIds = new ArrayList<>();
+    private final List<String> eventIds = new ArrayList<>();
 
     @After
     public void tearDown() throws InterruptedException {
+        for (String eventId : eventIds) {
+            persistenceService.remove(eventId, Event.class);
+        }
+        eventIds.clear();
         for (String profileId : profileIds) {
             persistenceService.remove(profileId, Profile.class);
         }
         profileIds.clear();
-        refreshPersistence(Profile.class);
+        refreshPersistence(Event.class, Profile.class);
     }
 
     @Test
@@ -105,6 +113,120 @@ public class PersistenceServiceIT extends BaseIT {
         Assert.assertEquals(5, lastPage.getList().get(0).getProperty("age"));
     }
 
+    @Test
+    public void testPurgeByScopeKeepsOtherScopesAndProfiles() throws 
InterruptedException {
+        String suffix = UUID.randomUUID().toString();
+        String dropScope = "purge-drop-" + suffix;
+        String keepScope = "purge-keep-" + suffix;
+        Profile profile = saveProfile("purge-profile-" + suffix);
+        Event dropEvent = saveViewEvent("purge-drop-event-" + suffix, profile, 
dropScope);
+        Event keepEvent = saveViewEvent("purge-keep-event-" + suffix, profile, 
keepScope);
+        refreshPersistence(Event.class, Profile.class);
+
+        persistenceService.purge(dropScope);
+        refreshPersistence(Event.class, Profile.class);
+
+        Assert.assertNull("Events of the purged scope should be gone", 
persistenceService.load(dropEvent.getItemId(), Event.class));
+        Assert.assertNotNull("Events of another scope should stay", 
persistenceService.load(keepEvent.getItemId(), Event.class));
+        Assert.assertNotNull("Profiles have no scope and must stay", 
persistenceService.load(profile.getItemId(), Profile.class));
+    }
+
+    @Test
+    public void testPurgeByScopeDoesNotRemoveAnotherTenantsEvents() throws 
Exception {
+        String suffix = UUID.randomUUID().toString();
+        String scope = "purge-shared-scope-" + suffix;
+        // Items of the other tenant are not tracked for tearDown, which runs 
as the current tenant and would not find them
+        String otherProfileId = "purge-other-profile-" + suffix;
+        String otherEventId = "purge-other-event-" + suffix;
+        Tenant otherTenant = tenantService.createTenant("purge-other-" + 
suffix.substring(0, 8), Collections.emptyMap());
+        try {
+            Event localEvent = saveViewEvent("purge-local-event-" + suffix, 
saveProfile("purge-local-profile-" + suffix), scope);
+            executionContextManager.executeAsTenant(otherTenant.getItemId(), 
() -> {
+                Profile otherProfile = new Profile(otherProfileId);
+                persistenceService.save(otherProfile);
+                persistenceService.save(new Event(otherEventId, "view", null, 
otherProfile, scope, null, null, new Date()));
+            });
+            refreshPersistence(Event.class, Profile.class);
+
+            persistenceService.purge(scope);
+            refreshPersistence(Event.class, Profile.class);
+
+            Assert.assertNull("Current tenant events in the scope should be 
gone",
+                    persistenceService.load(localEvent.getItemId(), 
Event.class));
+            executionContextManager.executeAsTenant(otherTenant.getItemId(), 
() -> {
+                Assert.assertNotNull("Other tenant events in a scope with the 
same name should stay",
+                        persistenceService.load(otherEventId, Event.class));
+            });
+        } finally {
+            try {
+                
executionContextManager.executeAsTenant(otherTenant.getItemId(), () -> {
+                    persistenceService.remove(otherEventId, Event.class);
+                    persistenceService.remove(otherProfileId, Profile.class);
+                });
+            } finally {
+                tenantService.deleteTenant(otherTenant.getItemId());
+            }
+        }
+    }
+
+    @Test
+    public void testPurgeByScopeRemovesMoreThanOnePageOfItems() throws 
InterruptedException {
+        String suffix = UUID.randomUUID().toString();
+        String scope = "purge-many-" + suffix;
+        Profile profile = saveProfile("purge-many-profile-" + suffix);
+        // purge reads matching items 100 at a time
+        int eventCount = 250;
+        for (int i = 0; i < eventCount; i++) {
+            saveViewEvent("purge-many-event-" + i + "-" + suffix, profile, 
scope);
+        }
+        refreshPersistence(Event.class, Profile.class);
+
+        persistenceService.purge(scope);
+        refreshPersistence(Event.class, Profile.class);
+
+        for (int i = 0; i < eventCount; i++) {
+            Assert.assertNull("Event " + i + " of the purged scope should be 
gone",
+                    persistenceService.load("purge-many-event-" + i + "-" + 
suffix, Event.class));
+        }
+    }
+
+    @Test
+    public void testPurgeByScopeMatchesMixedCaseAndAccentedScope() throws 
InterruptedException {
+        String suffix = UUID.randomUUID().toString();
+        String scope = "Purge-Mixed-Scopé-" + suffix;
+        Event event = saveViewEvent("purge-mixed-event-" + suffix, 
saveProfile("purge-mixed-profile-" + suffix), scope);
+        refreshPersistence(Event.class, Profile.class);
+
+        persistenceService.purge(scope);
+        refreshPersistence(Event.class, Profile.class);
+
+        Assert.assertNull("Events of the purged scope should be gone", 
persistenceService.load(event.getItemId(), Event.class));
+    }
+
+    @Test
+    public void testPurgeByScopeRejectsBlankScope() {
+        try {
+            persistenceService.purge(" ");
+            Assert.fail("A blank scope should be refused");
+        } catch (IllegalArgumentException expected) {
+            Assert.assertTrue(expected.getMessage().contains("required"));
+        }
+    }
+
+    private Profile saveProfile(String itemId) {
+        Profile profile = new Profile(itemId);
+        persistenceService.save(profile);
+        profileIds.add(itemId);
+        return profile;
+    }
+
+    private Event saveViewEvent(String itemId, Profile profile, String scope) {
+        Event event = new Event(itemId, "view", null, profile, scope, null, 
null, new Date());
+        persistenceService.save(event);
+        eventIds.add(itemId);
+        return event;
+    }
+
     private void saveProfileWithAge(String idSuffix, int age) {
         Profile profile = new Profile();
         profile.setItemId(idSuffix + "-" + UUID.randomUUID());
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 f6d66d37f..fea126b58 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
@@ -2726,13 +2726,12 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
     }
 
     @Override public void purge(final String scope) {
-        LOGGER.debug("Purge scope {}", scope);
         String finalTenantId = 
validateTenantAndGetId(SecurityServiceConfiguration.PERMISSION_PURGE);
+        Query query = buildScopePurgeQuery(scope, finalTenantId);
+        LOGGER.debug("Purge scope {}", scope);
         new InClassLoaderExecute<Void>(metricsService, 
this.getClass().getName() + ".purgeWithScope", this.bundleContext,
                 this.fatalIllegalStateErrors, throwExceptions) {
             @Override protected Void execute(Object... args) throws 
IOException {
-                Query query = TermQuery.of(builder -> 
builder.field("scope").value(scope).field("tenantId").value(ConditionContextHelper.foldToASCII(finalTenantId)))._toQuery();
-
                 List<BulkOperation> operations = new ArrayList<>();
 
                 Time keepAlive = Time.of(t -> t.time("1h"));
@@ -2745,7 +2744,7 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
                 String scrollId = searchResponse.scrollId();
                 // Scroll until no more hits are returned
                 while (!hits.isEmpty()) {
-                    for (Hit<JsonData> hit : searchResponse.hits().hits()) {
+                    for (Hit<JsonData> hit : hits) {
                         // add hit to bulk delete
                         operations.add(BulkOperation.of(builder -> 
builder.delete(d -> d.index(hit.index()).id(hit.id()))));
                     }
@@ -2777,6 +2776,25 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
         }.catchingExecuteInClassLoader(true);
     }
 
+    /**
+     * Builds the search used by {@link #purge(String)}.
+     * A {@code TermQuery} has a single field, so both filters must be bool 
must clauses.
+     * Both values are folded, as the fields are indexed with the folding 
analyzer.
+     * Keep in step with the OpenSearch implementation.
+     */
+    static Query buildScopePurgeQuery(String scope, String tenantId) {
+        if (scope == null || scope.isBlank()) {
+            throw new IllegalArgumentException("A scope is required to purge 
by scope");
+        }
+        if (tenantId == null || tenantId.isBlank()) {
+            throw new IllegalArgumentException("A tenant is required to purge 
by scope");
+        }
+        return Query.of(q -> q.bool(b -> b
+                .must(m -> m.term(t -> 
t.field("scope").value(ConditionContextHelper.foldToASCII(scope))))
+                .must(m -> m.term(t -> t.field("tenantId")
+                        
.value(ConditionContextHelper.foldToASCII(tenantId))))));
+    }
+
     @Override public Map<String, Double> getSingleValuesMetrics(final 
Condition condition, final String[] metrics, final String field,
             final String itemType) {
         return new InClassLoaderExecute<Map<String, Double>>(metricsService, 
this.getClass().getName() + ".getSingleValuesMetrics",
diff --git 
a/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchScopePurgeQueryTest.java
 
b/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchScopePurgeQueryTest.java
new file mode 100644
index 000000000..83beef4c3
--- /dev/null
+++ 
b/persistence-elasticsearch/core/src/test/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchScopePurgeQueryTest.java
@@ -0,0 +1,107 @@
+/*
+ * 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.persistence.elasticsearch;
+
+import co.elastic.clients.elasticsearch._types.query_dsl.Query;
+import co.elastic.clients.json.jackson.JacksonJsonpMapper;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.json.stream.JsonGenerator;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class ElasticSearchScopePurgeQueryTest {
+
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+    @Test
+    public void buildScopePurgeQueryRequiresBothScopeAndTenantTerms() throws 
Exception {
+        Query query = 
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", 
"tenant_a");
+        JsonNode json = OBJECT_MAPPER.readTree(toJson(query));
+
+        JsonNode must = json.path("bool").path("must");
+        assertTrue("Purge query must be a bool query with must clauses: " + 
json, must.isArray());
+        assertEquals(2, must.size());
+
+        Set<String> fields = new HashSet<>();
+        for (JsonNode clause : must) {
+            JsonNode term = clause.path("term");
+            assertTrue("Each must clause should be a term query: " + json, 
term.isObject() && term.size() == 1);
+            fields.add(term.fieldNames().next());
+        }
+        assertEquals(Set.of("scope", "tenantId"), fields);
+        assertEquals("drop_scope", termValue(must, "scope"));
+        assertEquals("tenant_a", termValue(must, "tenantId"));
+    }
+
+    @Test
+    public void buildScopePurgeQueryFoldsScopeAndTenant() throws Exception {
+        Query query = 
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("Drop_Scopé", 
"Tenant_A");
+        JsonNode must = 
OBJECT_MAPPER.readTree(toJson(query)).path("bool").path("must");
+
+        assertEquals("drop_scope", termValue(must, "scope"));
+        assertEquals("tenant_a", termValue(must, "tenantId"));
+    }
+
+    @Test
+    public void buildScopePurgeQueryRejectsBlankScopeOrTenant() {
+        assertIllegalArgument(() -> 
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("  ", "tenant_a"));
+        assertIllegalArgument(() -> 
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery(null, "tenant_a"));
+        assertIllegalArgument(() -> 
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", " "));
+        assertIllegalArgument(() -> 
ElasticSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", null));
+    }
+
+    private static String termValue(JsonNode must, String field) {
+        for (JsonNode clause : must) {
+            JsonNode term = clause.path("term").path(field);
+            if (term.has("value")) {
+                return term.get("value").asText();
+            }
+            if (term.isTextual()) {
+                return term.asText();
+            }
+        }
+        fail("No term for field " + field);
+        return null;
+    }
+
+    private static String toJson(Query query) {
+        JacksonJsonpMapper mapper = new JacksonJsonpMapper();
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        JsonGenerator generator = mapper.jsonProvider().createGenerator(out);
+        query.serialize(generator, mapper);
+        generator.close();
+        return out.toString(StandardCharsets.UTF_8);
+    }
+
+    private static void assertIllegalArgument(Runnable action) {
+        try {
+            action.run();
+            fail("Expected IllegalArgumentException");
+        } catch (IllegalArgumentException expected) {
+            assertTrue(expected.getMessage().contains("required"));
+        }
+    }
+}
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 4afc072ca..5b4270f57 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
@@ -2667,6 +2667,7 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
     @Override
     public void purge(final String scope) {
         String finalTenantId = 
validateTenantAndGetId(SecurityServiceConfiguration.PERMISSION_PURGE);
+        Query query = buildScopePurgeQuery(scope, finalTenantId);
 
         LOGGER.debug("Purge scope {}", scope);
         new InClassLoaderExecute<Void>(metricsService, 
this.getClass().getName() + ".purgeWithScope", this.bundleContext, 
this.fatalIllegalStateErrors, throwExceptions) {
@@ -2674,22 +2675,7 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
             protected Void execute(Object... args) throws IOException {
 
                 SearchResponse<Item> response = client.search(s -> s
-                        .query(q -> q
-                                .bool(b -> b
-                                        .must(m -> m
-                                .term(t -> t
-                                        .field("scope")
-                                                        .value(v -> 
v.stringValue(scope))
-                                                )
-                                        )
-                                        .must(m -> m
-                                                .term(t -> t
-                                                        .field("tenantId")
-                                                        .value(v -> 
v.stringValue(ConditionContextHelper.foldToASCII(finalTenantId)))
-                                                )
-                                        )
-                                )
-                        )
+                        .query(query)
                         .size(100)
                         .scroll(scr -> scr
                                 .time("1h")
@@ -2730,6 +2716,25 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
         }.catchingExecuteInClassLoader(true);
     }
 
+    /**
+     * Builds the search used by {@link #purge(String)}.
+     * Both values are folded, as the fields are indexed with the folding 
analyzer.
+     * Keep in step with the Elasticsearch implementation.
+     */
+    static Query buildScopePurgeQuery(String scope, String tenantId) {
+        if (scope == null || scope.isBlank()) {
+            throw new IllegalArgumentException("A scope is required to purge 
by scope");
+        }
+        if (tenantId == null || tenantId.isBlank()) {
+            throw new IllegalArgumentException("A tenant is required to purge 
by scope");
+        }
+        return Query.of(q -> q.bool(b -> b
+                .must(m -> m.term(t -> t.field("scope")
+                        .value(v -> 
v.stringValue(ConditionContextHelper.foldToASCII(scope)))))
+                .must(m -> m.term(t -> t.field("tenantId")
+                        .value(v -> 
v.stringValue(ConditionContextHelper.foldToASCII(tenantId)))))));
+    }
+
     @Override
     public Map<String, Double> getSingleValuesMetrics(final Condition 
condition, final String[] metrics, final String field, final String itemType) {
         return new InClassLoaderExecute<Map<String, Double>>(metricsService, 
this.getClass().getName() + ".getSingleValuesMetrics", this.bundleContext, 
this.fatalIllegalStateErrors, throwExceptions) {
diff --git 
a/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchScopePurgeQueryTest.java
 
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchScopePurgeQueryTest.java
new file mode 100644
index 000000000..d7d5b18f6
--- /dev/null
+++ 
b/persistence-opensearch/core/src/test/java/org/apache/unomi/persistence/opensearch/OpenSearchScopePurgeQueryTest.java
@@ -0,0 +1,74 @@
+/*
+ * 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.persistence.opensearch;
+
+import org.junit.jupiter.api.Test;
+import org.opensearch.client.opensearch._types.query_dsl.Query;
+import org.opensearch.client.opensearch._types.query_dsl.TermQuery;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class OpenSearchScopePurgeQueryTest {
+
+    @Test
+    public void buildScopePurgeQueryRequiresBothScopeAndTenantTerms() {
+        Query query = 
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", "tenant_a");
+
+        assertEquals(Map.of("scope", "drop_scope", "tenantId", "tenant_a"), 
mustTerms(query));
+    }
+
+    @Test
+    public void buildScopePurgeQueryFoldsScopeAndTenant() {
+        Query query = 
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("Drop_Scopé", "Tenant_A");
+
+        assertEquals(Map.of("scope", "drop_scope", "tenantId", "tenant_a"), 
mustTerms(query));
+    }
+
+    @Test
+    public void buildScopePurgeQueryRejectsBlankScopeOrTenant() {
+        assertRequired("scope", () -> 
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("  ", "tenant_a"));
+        assertRequired("scope", () -> 
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery(null, "tenant_a"));
+        assertRequired("tenant", () -> 
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", " "));
+        assertRequired("tenant", () -> 
OpenSearchPersistenceServiceImpl.buildScopePurgeQuery("drop_scope", null));
+    }
+
+    private static Map<String, String> mustTerms(Query query) {
+        assertTrue(query.isBool(), "Purge query must be a bool query: " + 
query);
+        List<Query> must = query.bool().must();
+        assertEquals(2, must.size());
+        assertTrue(query.bool().should().isEmpty() && 
query.bool().mustNot().isEmpty());
+
+        Map<String, String> terms = new HashMap<>();
+        for (Query clause : must) {
+            assertTrue(clause.isTerm(), "Each must clause should be a term 
query: " + query);
+            TermQuery term = clause.term();
+            terms.put(term.field(), term.value().stringValue());
+        }
+        return terms;
+    }
+
+    private static void assertRequired(String what, Runnable action) {
+        IllegalArgumentException e = 
assertThrows(IllegalArgumentException.class, action::run);
+        assertTrue(e.getMessage().contains(what) && 
e.getMessage().contains("required"), e.getMessage());
+    }
+}
diff --git 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
index b572ab129..5289917e0 100644
--- 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
+++ 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
@@ -790,9 +790,10 @@ public interface PersistenceService {
     boolean removeIndex(final String itemType);
 
     /**
-     * Removes all data associated with the provided scope.
+     * Removes all data associated with the provided scope, for the current 
tenant only.
      *
-     * @param scope the scope for which we want to remove data
+     * @param scope the scope for which we want to remove data, must not be 
null or blank
+     * @throws IllegalArgumentException if the scope is null or blank
      */
     void purge(final String scope);
 
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
 
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
index 38b7fb136..210f6913c 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
@@ -1193,8 +1193,8 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
 
     @Override
     public void purge(String scope) {
-        if (scope == null) {
-            return;
+        if (scope == null || scope.isBlank()) {
+            throw new IllegalArgumentException("A scope is required to purge 
by scope");
         }
 
         String currentTenantId = 
executionContextManager.getCurrentContext().getTenantId();
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
index 9d1fc916b..3d730133d 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java
@@ -3944,8 +3944,9 @@ public class InMemoryPersistenceServiceImplTest {
             assertNull(persistenceService.load("purge-null-test", 
TestMetadataItem.class),
                 "purge(null) should purge all items (used for test isolation 
reset)");
 
-            // Test purge with null scope — also a no-op
-            assertDoesNotThrow(() -> persistenceService.purge((String) null));
+            // A null or blank scope is refused, as in the Elasticsearch and 
OpenSearch implementations
+            assertThrows(IllegalArgumentException.class, () -> 
persistenceService.purge((String) null));
+            assertThrows(IllegalArgumentException.class, () -> 
persistenceService.purge(" "));
 
             // Test refresh index with null class
             assertDoesNotThrow(() -> persistenceService.refreshIndex(null, 
null));

Reply via email to