This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch UNOMI-999-scope-purge
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/UNOMI-999-scope-purge by this
push:
new fbb3295b0 UNOMI-999: Purge whole scopes and match folded scope names
fbb3295b0 is described below
commit fbb3295b069b616baa731aad5d12bad625df3ca5
Author: Serge Huber <[email protected]>
AuthorDate: Tue Oct 6 10:27:40 2026 +0200
UNOMI-999: Purge whole scopes and match folded scope names
* Elasticsearch read only the first 100 items of a scope on every scroll
page, so larger scopes were purged in part. Read each page instead.
* Fold the scope in the purge query on Elasticsearch and OpenSearch, as
is already done for the tenant. A scope with upper case or accented
letters matched nothing before.
* Build the OpenSearch purge query in a helper that mirrors the
Elasticsearch one, and check the permission before the scope on both.
* Refuse a null or blank scope in the in-memory test implementation too.
* Document the tenant limit and the refused blank scope on the SPI.
* Tests: OpenSearch query unit test, folding unit tests, integration
tests for a scope of 250 events and a mixed-case accented scope. The
other-tenant test now removes its own items.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
.../apache/unomi/itests/PersistenceServiceIT.java | 63 +++++++++++++++---
.../ElasticSearchPersistenceServiceImpl.java | 10 +--
.../ElasticSearchScopePurgeQueryTest.java | 11 +++-
.../OpenSearchPersistenceServiceImpl.java | 40 ++++++------
.../opensearch/OpenSearchScopePurgeQueryTest.java | 74 ++++++++++++++++++++++
.../unomi/persistence/spi/PersistenceService.java | 5 +-
.../impl/InMemoryPersistenceServiceImpl.java | 4 +-
.../impl/InMemoryPersistenceServiceImplTest.java | 12 +---
8 files changed, 173 insertions(+), 46 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 55d5dc9ed..05d00aef4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PersistenceServiceIT.java
@@ -135,15 +135,19 @@ public class PersistenceServiceIT extends BaseIT {
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());
- Event localEvent = saveViewEvent("purge-local-event-" + suffix,
saveProfile("purge-local-profile-" + suffix), scope);
- Event otherEvent =
executionContextManager.executeAsTenant(otherTenant.getItemId(), () -> {
- Profile otherProfile = saveProfile("purge-other-profile-" +
suffix);
- return saveViewEvent("purge-other-event-" + suffix, otherProfile,
scope);
- });
- refreshPersistence(Event.class, Profile.class);
-
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);
@@ -151,13 +155,54 @@ public class PersistenceServiceIT extends BaseIT {
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(otherEvent.getItemId(),
Event.class));
+ persistenceService.load(otherEventId, Event.class));
});
} finally {
- tenantService.deleteTenant(otherTenant.getItemId());
+ 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 {
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 c4ada031a..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,12 +2726,12 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
}
@Override public void purge(final String scope) {
- Query query = buildScopePurgeQuery(scope,
validateTenantAndGetId(SecurityServiceConfiguration.PERMISSION_PURGE));
+ 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 {
-
List<BulkOperation> operations = new ArrayList<>();
Time keepAlive = Time.of(t -> t.time("1h"));
@@ -2744,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()))));
}
@@ -2779,6 +2779,8 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
/**
* 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()) {
@@ -2788,7 +2790,7 @@ public class ElasticSearchPersistenceServiceImpl
implements PersistenceService,
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(scope)))
+ .must(m -> m.term(t ->
t.field("scope").value(ConditionContextHelper.foldToASCII(scope))))
.must(m -> m.term(t -> t.field("tenantId")
.value(ConditionContextHelper.foldToASCII(tenantId))))));
}
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
index baf27bc8a..83beef4c3 100644
---
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
@@ -57,7 +57,16 @@ public class ElasticSearchScopePurgeQueryTest {
}
@Test
- public void buildScopePurgeQueryRejectsBlankScope() {
+ 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", " "));
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 8832c05c7..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
@@ -2666,10 +2666,8 @@ public class OpenSearchPersistenceServiceImpl implements
PersistenceService, Syn
@Override
public void purge(final String scope) {
- if (scope == null || scope.isBlank()) {
- throw new IllegalArgumentException("A scope is required to purge
by 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) {
@@ -2677,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")
@@ -2733,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 8546f47d4..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,15 +3944,9 @@ public class InMemoryPersistenceServiceImplTest {
assertNull(persistenceService.load("purge-null-test",
TestMetadataItem.class),
"purge(null) should purge all items (used for test isolation
reset)");
- assertDoesNotThrow(() -> persistenceService.purge((String) null));
-
- TestMetadataItem keepOnBlank = new TestMetadataItem();
- keepOnBlank.setItemId("purge-blank-scope");
- keepOnBlank.setScope("scope1");
- persistenceService.save(keepOnBlank);
- persistenceService.purge(" ");
- assertNotNull(persistenceService.load("purge-blank-scope",
TestMetadataItem.class),
- "A blank scope must not delete items");
+ // 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));