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 a01866516 UNOMI-997: Put migrated items in the right tenant (#909)
a01866516 is described below
commit a01866516ff54e61d48cc2058ac1e2ffeaeb7ca6
Author: Serge Huber <[email protected]>
AuthorDate: Wed Oct 7 12:30:43 2026 +0200
UNOMI-997: Put migrated items in the right tenant (#909)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../test/java/org/apache/unomi/itests/AllITs.java | 2 +
.../apache/unomi/itests/CorePersistenceITs.java | 2 +
.../migration/Migrate16xToCurrentVersionIT.java | 17 +-
.../itests/migration/Migrate40IndexMappingIT.java | 1 +
.../migration/Migrate40TenantAssignmentIT.java | 280 +++++++++++++++++++++
rest/pom.xml | 6 -
.../cache/AbstractMultiTypeCachingService.java | 23 +-
.../cache/AbstractMultiTypeCachingServiceTest.java | 50 ++++
.../shell/migration/utils/MigrationUtils.java | 60 +++++
.../migrate-4.0.0-01-tenantDocumentIds.groovy | 9 +-
.../migrate-4.0.0-05-fixSystemItemIds.groovy | 54 ++--
.../requestBody/4.0.0/fix_system_item_ids.painless | 37 +--
.../initialize_tenant_and_audit_fields.painless | 84 ++++---
.../4.0.0/shipped_system_definitions.json | 127 ++++++++++
.../shell/migration/utils/MigrationUtilsTest.java | 107 ++++++++
15 files changed, 741 insertions(+), 118 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 4d377c022..b072a31fd 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -19,6 +19,7 @@ package org.apache.unomi.itests;
import org.apache.unomi.itests.migration.Migrate16xToCurrentVersionIT;
import org.apache.unomi.itests.graphql.*;
import org.apache.unomi.itests.migration.Migrate40IndexMappingIT;
+import org.apache.unomi.itests.migration.Migrate40TenantAssignmentIT;
import org.apache.unomi.itests.migration.MigrationIT;
import org.apache.unomi.itests.shell.*;
import org.junit.runner.RunWith;
@@ -36,6 +37,7 @@ import org.junit.runners.Suite.SuiteClasses;
Migrate16xToCurrentVersionIT.class,
MigrationIT.class,
Migrate40IndexMappingIT.class,
+ Migrate40TenantAssignmentIT.class,
PersistenceServiceIT.class,
BasicIT.class,
ConditionEvaluatorIT.class,
diff --git
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index bf50ac2b2..6f3c510ef 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -19,6 +19,7 @@ package org.apache.unomi.itests;
import org.apache.unomi.itests.migration.Migrate16xToCurrentVersionIT;
import org.apache.unomi.itests.graphql.*;
import org.apache.unomi.itests.migration.Migrate40IndexMappingIT;
+import org.apache.unomi.itests.migration.Migrate40TenantAssignmentIT;
import org.apache.unomi.itests.migration.MigrationIT;
import org.apache.unomi.itests.shell.*;
import org.junit.runner.RunWith;
@@ -37,6 +38,7 @@ import org.junit.runners.Suite.SuiteClasses;
Migrate16xToCurrentVersionIT.class,
MigrationIT.class,
Migrate40IndexMappingIT.class,
+ Migrate40TenantAssignmentIT.class,
PersistenceServiceIT.class,
BasicIT.class,
ConditionEvaluatorIT.class,
diff --git
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
index 3a66818f5..63e0755ef 100644
---
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
+++
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate16xToCurrentVersionIT.java
@@ -611,7 +611,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
}
// Check audit metadata
- checkAuditMetadata(source);
+ checkAuditMetadata(source, itemId);
}
}
}
@@ -619,22 +619,23 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
/**
* Helper method to check audit metadata fields
* @param source The document source containing the metadata
+ * @param itemId Document id included in assertion messages
*/
- private void checkAuditMetadata(JsonNode source) {
- Assert.assertNotNull("Created by should be set",
source.get("createdBy"));
+ private void checkAuditMetadata(JsonNode source, String itemId) {
+ Assert.assertNotNull("Created by should be set for " + itemId,
source.get("createdBy"));
String createdBy = source.get("createdBy").asText();
// After migration, documents may be refreshed by bundles during
startup,
// which changes createdBy from system-migration-4.0.0 to system-bundle
// Both are valid - migration sets it, bundles may refresh it
boolean isValidCreatedBy = "system-migration-4.0.0".equals(createdBy)
|| "system-bundle".equals(createdBy);
- Assert.assertTrue("Created by should be system-migration-4.0.0 or
system-bundle, but was: " + createdBy, isValidCreatedBy);
- Assert.assertNotNull("Creation date should be set",
source.get("creationDate"));
- Assert.assertNotNull("Last modified by should be set",
source.get("lastModifiedBy"));
+ Assert.assertTrue("Created by should be system-migration-4.0.0 or
system-bundle for " + itemId + ", but was: " + createdBy, isValidCreatedBy);
+ Assert.assertNotNull("Creation date should be set for " + itemId,
source.get("creationDate"));
+ Assert.assertNotNull("Last modified by should be set for " + itemId,
source.get("lastModifiedBy"));
String lastModifiedBy = source.get("lastModifiedBy").asText();
// Similarly, lastModifiedBy may be updated by bundles after migration
boolean isValidLastModifiedBy =
"system-migration-4.0.0".equals(lastModifiedBy) ||
"system-bundle".equals(lastModifiedBy);
- Assert.assertTrue("Last modified by should be system-migration-4.0.0
or system-bundle, but was: " + lastModifiedBy, isValidLastModifiedBy);
- Assert.assertNotNull("Last modification date should be set",
source.get("lastModificationDate"));
+ Assert.assertTrue("Last modified by should be system-migration-4.0.0
or system-bundle for " + itemId + ", but was: " + lastModifiedBy,
isValidLastModifiedBy);
+ Assert.assertNotNull("Last modification date should be set for " +
itemId, source.get("lastModificationDate"));
}
/**
diff --git
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
index b66b472f5..04fba0729 100644
---
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
+++
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40IndexMappingIT.java
@@ -155,6 +155,7 @@ public class Migrate40IndexMappingIT extends BaseIT {
params.put("itemType", itemType);
params.put("date", "2026-01-01T00:00:00Z");
params.put("systemItems", Collections.emptySet());
+ params.put("shippedDefinitionIds", Collections.emptyMap());
String painless = MigrationUtils.getFileWithoutComments(
migrationBundles,
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
diff --git
a/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
new file mode 100644
index 000000000..4c466756d
--- /dev/null
+++
b/itests/src/test/java/org/apache/unomi/itests/migration/Migrate40TenantAssignmentIT.java
@@ -0,0 +1,280 @@
+/*
+ * 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.itests.migration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.unomi.itests.BaseIT;
+import org.apache.unomi.itests.persistence.SearchBackendIT;
+import org.apache.unomi.shell.migration.utils.HttpUtils;
+import org.apache.unomi.shell.migration.utils.MigrationUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Assume;
+import org.junit.Test;
+import org.junit.experimental.categories.Category;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.FrameworkUtil;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * UNOMI-997: after migration, shipped definitions and geonames land in system,
+ * while user content with systemscope stays in the configured tenant.
+ */
+@Category(SearchBackendIT.class)
+public class Migrate40TenantAssignmentIT extends BaseIT {
+
+ private static final String SOURCE_INDEX =
"context-unomi997source-systemitems";
+ private static final String DEST_INDEX =
"context-unomi997dest-systemitems";
+ private static final String GEO_SOURCE =
"context-unomi997source-geonameentry";
+ private static final String GEO_DEST = "context-unomi997dest-geonameentry";
+ private static final String DATA_SOURCE = "context-unomi997source-profile";
+ private static final String DATA_DEST = "context-unomi997dest-profile";
+ private static final String CAMEL_CASE_INDEX = "context-unomi997camelcase";
+
+ private static final String MAPPING = "{"
+ + "\"settings\":{\"number_of_shards\":1,\"number_of_replicas\":0},"
+ + "\"mappings\":{\"properties\":{"
+ + "\"itemId\":{\"type\":\"keyword\"},"
+ + "\"itemType\":{\"type\":\"keyword\"},"
+ + "\"scope\":{\"type\":\"keyword\"},"
+ + "\"tenantId\":{\"type\":\"keyword\"}"
+ + "}}}";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ @After
+ public void deleteTestIndices() throws Exception {
+ if (!persistenceCapabilities().httpAdminApi()) {
+ return;
+ }
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ for (String index : new String[]{SOURCE_INDEX, DEST_INDEX,
GEO_SOURCE, GEO_DEST, DATA_SOURCE, DATA_DEST, CAMEL_CASE_INDEX}) {
+ MigrationUtils.deleteIndex(httpClient, es, index);
+ }
+ }
+ }
+
+ @Test
+ public void shippedDefinitionsListIsPresentInTheMigrationBundle() {
+ Map<String, Set<String>> shipped =
MigrationUtils.loadShippedDefinitionIds(migrationBundle());
+
Assert.assertTrue(shipped.get("conditiontype").contains("booleancondition"));
+
Assert.assertTrue(shipped.get("conditiontype").contains("pasteventcondition"));
+
Assert.assertTrue(shipped.get("conditiontype").contains("profilepropertycondition"));
+ Assert.assertFalse("user content must not be in the shipped list by id
alone",
+ shipped.get("rule").contains("my-user-rule"));
+ }
+
+ @Test
+ public void migrationPutsEachKindOfItemInTheRightTenant() throws Exception
{
+ assumeHttpAdminApi();
+
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX,
MAPPING, null);
+ // Shipped definitions: without scope, with the 3.0 default scope,
and with the 3.0 _itemtype id suffix
+ indexDocument(httpClient, SOURCE_INDEX, "booleanCondition",
"booleanCondition", "conditionType", null);
+ indexDocument(httpClient, SOURCE_INDEX, "updateProperties",
"updateProperties", "rule", "systemscope");
+ indexDocument(httpClient, SOURCE_INDEX,
"pastEventCondition_conditiontype", "pastEventCondition_conditiontype",
"conditionType", null);
+ // User content with the 3.0 default scope
+ indexDocument(httpClient, SOURCE_INDEX, "my-user-rule",
"my-user-rule", "rule", "systemscope");
+ indexDocument(httpClient, SOURCE_INDEX, "my-segment",
"my-segment", "segment", "systemscope");
+ indexDocument(httpClient, SOURCE_INDEX, "my-scoring",
"my-scoring", "scoring", "systemscope");
+ indexDocument(httpClient, SOURCE_INDEX, "my-goal_goal", "my-goal",
"goal", "systemscope");
+ // Literal system scope on a type that is neither shipped nor a
system item type
+ indexDocument(httpClient, SOURCE_INDEX, "cfg1", "cfg1",
"sfdcConfiguration", "system");
+
+ Map<String, JsonNode> byItemId = migrate(httpClient, SOURCE_INDEX,
DEST_INDEX, "systemitems", false,
+ Set.of("conditiontype", "rule", "segment", "scoring",
"goal"),
+
MigrationUtils.loadShippedDefinitionIds(migrationBundle()));
+ Assert.assertEquals(8, byItemId.size());
+
+ assertTenant(byItemId.get("booleanCondition"), "system");
+ assertTenant(byItemId.get("updateProperties"), "system");
+ assertTenant(byItemId.get("pastEventCondition_conditiontype"),
"system");
+ assertTenant(byItemId.get("my-user-rule"), TEST_TENANT_ID);
+ assertTenant(byItemId.get("my-segment"), TEST_TENANT_ID);
+ assertTenant(byItemId.get("my-scoring"), TEST_TENANT_ID);
+ assertTenant(byItemId.get("my-goal"), TEST_TENANT_ID);
+ assertTenant(byItemId.get("cfg1"), "system");
+
+ // System item types get the _itemtype suffix exactly once, other
types none
+ Assert.assertEquals("system_booleanCondition_conditiontype",
byItemId.get("booleanCondition").path("_id").asText());
+ Assert.assertEquals("system_pastEventCondition_conditiontype",
+
byItemId.get("pastEventCondition_conditiontype").path("_id").asText());
+ Assert.assertEquals(TEST_TENANT_ID + "_my-goal_goal",
byItemId.get("my-goal").path("_id").asText());
+ Assert.assertEquals("system_cfg1",
byItemId.get("cfg1").path("_id").asText());
+
+ // Migrated system items carry create audit, so the bundle
redeploy is an update
+ JsonNode source = byItemId.get("booleanCondition").path("_source");
+ Assert.assertEquals("system-migration-4.0.0",
source.path("createdBy").asText());
+ Assert.assertEquals("system-migration-4.0.0",
source.path("lastModifiedBy").asText());
+ Assert.assertEquals("2026-01-01T00:00:00Z",
source.path("creationDate").asText());
+ }
+ }
+
+ @Test
+ public void forcedDefaultTenantNeverUsesTheSystemTenant() throws Exception
{
+ assumeHttpAdminApi();
+
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ HttpUtils.executePutRequest(httpClient, es + "/" + SOURCE_INDEX,
MAPPING, null);
+ indexDocument(httpClient, SOURCE_INDEX, "node1", "node1",
"clusterNode", "system");
+ indexDocument(httpClient, SOURCE_INDEX, "booleanCondition",
"booleanCondition", "conditionType", null);
+
+ Map<String, JsonNode> byItemId = migrate(httpClient, SOURCE_INDEX,
DEST_INDEX, "clusterNode", true,
+ Collections.emptySet(),
MigrationUtils.loadShippedDefinitionIds(migrationBundle()));
+ Assert.assertEquals(2, byItemId.size());
+ assertTenant(byItemId.get("node1"), TEST_TENANT_ID);
+ assertTenant(byItemId.get("booleanCondition"), TEST_TENANT_ID);
+ }
+ }
+
+ @Test
+ public void profilesSessionsAndEventsStayInTheConfiguredTenant() throws
Exception {
+ assumeHttpAdminApi();
+
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ HttpUtils.executePutRequest(httpClient, es + "/" + DATA_SOURCE,
MAPPING, null);
+ indexDocument(httpClient, DATA_SOURCE, "p1", "p1", "profile",
null);
+ indexDocument(httpClient, DATA_SOURCE, "s1", "s1", "session",
"systemscope");
+ indexDocument(httpClient, DATA_SOURCE, "e1", "e1", "event",
"systemscope");
+
+ Map<String, JsonNode> byItemId = migrate(httpClient, DATA_SOURCE,
DATA_DEST, "profile", false,
+ Set.of("conditiontype", "rule"),
MigrationUtils.loadShippedDefinitionIds(migrationBundle()));
+ Assert.assertEquals(3, byItemId.size());
+ for (String itemId : new String[]{"p1", "s1", "e1"}) {
+ assertTenant(byItemId.get(itemId), TEST_TENANT_ID);
+ Assert.assertEquals(TEST_TENANT_ID + "_" + itemId,
byItemId.get(itemId).path("_id").asText());
+ }
+ }
+ }
+
+ @Test
+ public void geonamesGoToTheSystemTenant() throws Exception {
+ assumeHttpAdminApi();
+
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ HttpUtils.executePutRequest(httpClient, es + "/" + GEO_SOURCE,
MAPPING, null);
+ indexDocument(httpClient, GEO_SOURCE, "paris", "paris",
"geonameEntry", null);
+
+ Map<String, JsonNode> byItemId = migrate(httpClient, GEO_SOURCE,
GEO_DEST, "geonameentry", false,
+ Collections.emptySet(), Collections.emptyMap());
+ assertTenant(byItemId.get("paris"), "system");
+ }
+ }
+
+ @Test
+ public void
itemIdFixMatchesCamelCaseItemTypesAndCountsOnlyChangedDocuments() throws
Exception {
+ assumeHttpAdminApi();
+
+ try (CloseableHttpClient httpClient = createSearchEngineHttpClient()) {
+ String es = getSearchEngineBaseUrl();
+ String mapping = "{"
+ + "\"settings\":{"
+ + "\"number_of_shards\":1,\"number_of_replicas\":0,"
+ +
"\"analysis\":{\"analyzer\":{\"folding\":{\"type\":\"custom\",\"tokenizer\":\"keyword\",\"filter\":[\"lowercase\",\"asciifolding\"]}}}"
+ + "},"
+ + "\"mappings\":{\"properties\":{"
+ + "\"itemId\":{\"type\":\"keyword\"},"
+ +
"\"itemType\":{\"type\":\"text\",\"analyzer\":\"folding\",\"fields\":{\"keyword\":{\"type\":\"keyword\",\"ignore_above\":256}}}"
+ + "}}}";
+ HttpUtils.executePutRequest(httpClient, es + "/" +
CAMEL_CASE_INDEX, mapping, null);
+ indexDocument(httpClient, CAMEL_CASE_INDEX,
"system_booleanCondition_conditiontype", "wrong", "conditionType", null);
+ // Already consistent: must be left untouched and not counted
+ indexDocument(httpClient, CAMEL_CASE_INDEX,
"system_pastEventCondition_conditiontype",
+ "pastEventCondition_conditiontype", "conditionType", null);
+
+ // Same request as migrate-4.0.0-05-fixSystemItemIds.groovy
+ String fixScript = MigrationUtils.getFileWithoutComments(
+ migrationBundle(),
"requestBody/4.0.0/fix_system_item_ids.painless");
+ long updated = MigrationUtils.updateByQueryAndCount(httpClient,
es, CAMEL_CASE_INDEX,
+ MigrationUtils.buildItemTypeUpdateRequest(fixScript,
"conditionType"));
+ Assert.assertEquals(1L, updated);
+
+ JsonNode source =
MAPPER.readTree(HttpUtils.executeGetRequest(httpClient,
+ es + "/" + CAMEL_CASE_INDEX +
"/_doc/system_booleanCondition_conditiontype", null))
+ .path("_source");
+ Assert.assertEquals("booleanCondition_conditiontype",
source.path("itemId").asText());
+ }
+ }
+
+ private void assumeHttpAdminApi() {
+ Assume.assumeTrue(
+ "HTTP admin API required (provider=" +
getPersistenceBackend().providerId() + ")",
+ persistenceCapabilities().httpAdminApi());
+ }
+
+ private static BundleContext migrationBundle() {
+ return
FrameworkUtil.getBundle(MigrationUtils.class).getBundleContext();
+ }
+
+ private void indexDocument(CloseableHttpClient httpClient, String index,
String documentId, String itemId,
+ String itemType, String scope) throws Exception
{
+ String source = "{\"itemId\":\"" + itemId + "\",\"itemType\":\"" +
itemType + "\""
+ + (scope != null ? ",\"scope\":\"" + scope + "\"" : "") + "}";
+ HttpUtils.executePutRequest(httpClient,
+ getSearchEngineBaseUrl() + "/" + index + "/_doc/" + documentId
+ "?refresh=true", source, null);
+ }
+
+ /**
+ * Runs the tenant assignment script from {@code source} to {@code dest},
as migrate-4.0.0-01 does,
+ * and returns the migrated hits by source itemId.
+ */
+ private Map<String, JsonNode> migrate(CloseableHttpClient httpClient,
String source, String dest, String itemType,
+ boolean forceDefaultTenant,
Set<String> systemItems,
+ Map<String, Set<String>>
shippedDefinitionIds) throws Exception {
+ String es = getSearchEngineBaseUrl();
+ MigrationUtils.createIndex(httpClient, es, dest, MAPPING);
+
+ Map<String, Object> params = new HashMap<>();
+ params.put("tenantId", TEST_TENANT_ID);
+ params.put("systemTenantId", "system");
+ params.put("forceDefaultTenant", forceDefaultTenant);
+ params.put("itemType", itemType);
+ params.put("date", "2026-01-01T00:00:00Z");
+ params.put("systemItems", systemItems);
+ params.put("shippedDefinitionIds", shippedDefinitionIds);
+
+ String painless = MigrationUtils.getFileWithoutComments(
+ migrationBundle(),
"requestBody/4.0.0/initialize_tenant_and_audit_fields.painless");
+ MigrationUtils.moveToIndex(httpClient, migrationBundle(), es, source,
dest, painless, params);
+ HttpUtils.executePostRequest(httpClient, es + "/" + dest +
"/_refresh", null, null);
+
+ Map<String, JsonNode> byItemId = new HashMap<>();
+ for (JsonNode hit :
MAPPER.readTree(HttpUtils.executeGetRequest(httpClient,
+ es + "/" + dest + "/_search?size=20",
null)).path("hits").path("hits")) {
+ byItemId.put(hit.path("_source").path("itemId").asText(), hit);
+ }
+ return byItemId;
+ }
+
+ private static void assertTenant(JsonNode hit, String expectedTenant) {
+ Assert.assertNotNull(hit);
+ Assert.assertTrue(hit.path("_id").asText().startsWith(expectedTenant +
"_"));
+ Assert.assertEquals(expectedTenant,
hit.path("_source").path("tenantId").asText());
+ }
+}
diff --git a/rest/pom.xml b/rest/pom.xml
index 2deb35fd4..625556b2f 100644
--- a/rest/pom.xml
+++ b/rest/pom.xml
@@ -182,12 +182,6 @@
<version>${cxf.version}</version>
<scope>provided</scope>
</dependency>
- <dependency>
- <groupId>org.apache.cxf</groupId>
- <artifactId>cxf-rt-frontend-jaxrs</artifactId>
- <version>${cxf.version}</version>
- <scope>provided</scope>
- </dependency>
<dependency>
<groupId>org.slf4j</groupId>
diff --git
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
index f1a7ea87c..8cf444157 100644
---
a/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
+++
b/services-common/src/main/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingService.java
@@ -932,22 +932,25 @@ public abstract class AbstractMultiTypeCachingService
extends AbstractContextAwa
Class<T> itemClass = (Class<T>) item.getClass();
T existingItem = persistenceService.load(itemId, itemClass);
- boolean itemExists = false;
- if (existingItem != null) {
- // Item exists in persistence, check if it has audit metadata
- itemExists = existingItem.getCreatedBy() != null &&
existingItem.getCreationDate() != null;
- } else {
- // Item doesn't exist in persistence, check if current item has
audit metadata (might be a reload from cache)
- itemExists = item.getCreatedBy() != null && item.getCreationDate()
!= null;
+ // Bundle JSON definitions have no creator, and Item() defaults their
creation date and
+ // version to "now" and 0. If persistence already has create audit
(e.g. from migration),
+ // carry it over to the incoming item so the save below does not
overwrite it (UNOMI-997).
+ if (existingItem != null && item.getCreatedBy() == null
+ && existingItem.getCreatedBy() != null &&
existingItem.getCreationDate() != null) {
+ item.setCreatedBy(existingItem.getCreatedBy());
+ item.setCreationDate(existingItem.getCreationDate());
+ if (existingItem.getVersion() != null) {
+ item.setVersion(existingItem.getVersion());
+ }
}
+ boolean hasCreateAudit = item.getCreatedBy() != null &&
item.getCreationDate() != null;
+
// Set audit metadata for bundle-deployed items
if (auditService != null) {
- if (itemExists) {
- // Item exists, this is an update
+ if (hasCreateAudit) {
auditService.auditUpdate(item, "system-bundle");
} else {
- // New item, this is a create
auditService.auditCreate(item, "system-bundle");
}
}
diff --git
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
index 57923a771..ad962c909 100644
---
a/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
+++
b/services-common/src/test/java/org/apache/unomi/services/common/cache/AbstractMultiTypeCachingServiceTest.java
@@ -21,6 +21,7 @@ import org.apache.unomi.api.Item;
import org.apache.unomi.api.services.ExecutionContextManager;
import org.apache.unomi.api.services.cache.CacheableTypeConfig;
import org.apache.unomi.api.services.cache.MultiTypeCacheService;
+import org.apache.unomi.api.tenants.AuditService;
import org.apache.unomi.api.tenants.Tenant;
import org.apache.unomi.api.tenants.TenantService;
import org.apache.unomi.persistence.spi.PersistenceService;
@@ -62,6 +63,9 @@ public class AbstractMultiTypeCachingServiceTest {
@Mock
private TenantService tenantService;
+ @Mock
+ private AuditService auditService;
+
private TestCachingServiceImpl testCachingService;
// Simple test class that implements Serializable
@@ -194,6 +198,7 @@ public class AbstractMultiTypeCachingServiceTest {
testCachingService.setContextManager(contextManager);
testCachingService.setCacheService(cacheService);
testCachingService.setTenantService(tenantService);
+ testCachingService.setAuditService(auditService);
testCachingService.makeConfigPersistable();
// Mock tenant service to return tenant list
@@ -467,4 +472,49 @@ public class AbstractMultiTypeCachingServiceTest {
assertFalse("saveItem() must not proceed until the concurrent refresh
released its write lock",
refreshStillRunningWhenSaveReturned.get());
}
+
+ /**
+ * Bundle redeploy loads JSON without a creator, with the creation date
and version defaulted by
+ * {@code Item()}. If persistence already has create audit (for example
after migration),
+ * saveItem must keep it instead of overwriting it with those defaults.
+ */
+ @Test
+ public void testSaveItemPreservesMigratedCreateAuditOnBundleRedeploy() {
+ Date creationDate = new Date(1_700_000_000_000L);
+ TestItem existing = new TestItem("item1");
+ existing.setCreatedBy("system-migration-4.0.0");
+ existing.setCreationDate(creationDate);
+ existing.setVersion(3L);
+ when(persistenceService.load("item1",
TestItem.class)).thenReturn(existing);
+
+ TestItem fromBundle = new TestItem("item1");
+ assertNull(fromBundle.getCreatedBy());
+ assertNotNull("Item() defaults the creation date",
fromBundle.getCreationDate());
+
+ testCachingService.callSaveItem(fromBundle);
+
+ ArgumentCaptor<TestItem> saved =
ArgumentCaptor.forClass(TestItem.class);
+ verify(persistenceService).save(saved.capture());
+ assertEquals("system-migration-4.0.0",
saved.getValue().getCreatedBy());
+ assertEquals(creationDate, saved.getValue().getCreationDate());
+ assertEquals(Long.valueOf(3L), saved.getValue().getVersion());
+ verify(auditService).auditUpdate(same(fromBundle),
eq("system-bundle"));
+ verify(auditService, never()).auditCreate(any(), anyString());
+ }
+
+ /**
+ * A persisted item without complete create audit is still audited as a
creation.
+ */
+ @Test
+ public void testSaveItemAuditsCreateWhenExistingItemHasNoCreateAudit() {
+ TestItem existing = new TestItem("item1");
+ existing.setCreatedBy(null);
+ when(persistenceService.load("item1",
TestItem.class)).thenReturn(existing);
+
+ TestItem fromBundle = new TestItem("item1");
+ testCachingService.callSaveItem(fromBundle);
+
+ verify(auditService).auditCreate(same(fromBundle),
eq("system-bundle"));
+ verify(auditService, never()).auditUpdate(any(), anyString());
+ }
}
diff --git
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
index 4e8d71657..5ab1a8bc0 100644
---
a/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
+++
b/tools/shell-commands/src/main/java/org/apache/unomi/shell/migration/utils/MigrationUtils.java
@@ -365,6 +365,66 @@ public class MigrationUtils {
return name;
}
+ /**
+ * Loads the definitions Unomi ships and reloads at startup, as lower-case
item ids grouped by
+ * lower-case item type (for example {@code conditiontype ->
booleancondition}).
+ * The migrate command bundle ships a snapshot of that list so it is
available before plugins start.
+ * <p>
+ * Matching is by type and id only: a 3.0 item that shares both with a
shipped definition (such as a
+ * built-in rule edited in place) is migrated to the system tenant and
replaced by the 4.0 version
+ * at the next startup.
+ *
+ * @throws IllegalStateException if the list is empty or an entry is not
{@code itemType:itemId}
+ */
+ public static Map<String, Set<String>>
loadShippedDefinitionIds(BundleContext bundleContext) {
+ String resource = "requestBody/4.0.0/shipped_system_definitions.json";
+ JSONArray array = new JSONArray(resourceAsString(bundleContext,
resource));
+ Map<String, Set<String>> idsByType = new TreeMap<>();
+ for (int i = 0; i < array.length(); i++) {
+ String key = String.valueOf(array.get(i)).toLowerCase(Locale.ROOT);
+ int separator = key.indexOf(':');
+ if (separator <= 0 || separator == key.length() - 1) {
+ throw new IllegalStateException("Invalid entry in " + resource
+ ": " + key);
+ }
+ idsByType.computeIfAbsent(key.substring(0, separator), type -> new
TreeSet<>()).add(key.substring(separator + 1));
+ }
+ if (idsByType.isEmpty()) {
+ throw new IllegalStateException(resource + " is empty: shipped
definitions would be migrated to the wrong tenant");
+ }
+ return idsByType;
+ }
+
+ /**
+ * Builds an update by query request running a Painless script on the
documents of an item type.
+ * The item type is matched on both the keyword sub-field and the folded
text field, so camelCase
+ * values such as {@code conditionType} are not missed.
+ */
+ public static String buildItemTypeUpdateRequest(String painlessScript,
String itemType) {
+ JSONArray should = new JSONArray()
+ .put(new JSONObject().put("term", new
JSONObject().put("itemType.keyword", itemType)))
+ .put(new JSONObject().put("term", new
JSONObject().put("itemType", itemType.toLowerCase(Locale.ROOT))));
+ return new JSONObject()
+ .put("script", new JSONObject().put("source",
painlessScript).put("lang", "painless"))
+ .put("query", new JSONObject().put("bool", new
JSONObject().put("should", should).put("minimum_should_match", 1)))
+ .toString();
+ }
+
+ /**
+ * Runs an update by query synchronously, refreshes the index and returns
how many documents were
+ * actually changed (documents the script marks as noop are not counted).
+ *
+ * @throws IOException if the update timed out or reported failures;
version conflicts abort the request
+ */
+ public static long updateByQueryAndCount(CloseableHttpClient httpClient,
String esAddress, String indexName, String requestBody) throws IOException {
+ JSONObject response = new JSONObject(HttpUtils.executePostRequest(
+ httpClient, esAddress + "/" + indexName +
"/_update_by_query?refresh=true", requestBody, null));
+ JSONArray failures = response.optJSONArray("failures");
+ if (response.optBoolean("timed_out") || (failures != null &&
!failures.isEmpty())) {
+ throw new IOException("Update by query on " + indexName + " did
not complete: " + response);
+ }
+ return response.optLong("updated", 0L);
+ }
+
public static String buildIndexCreationRequest(String baseIndexSettings,
String mapping, MigrationContext context, boolean isMonthlyIndex) throws
IOException {
String settings = baseIndexSettings
.replace("#numberOfShards",
context.getConfigString(isMonthlyIndex ? MONTHLY_NUMBER_OF_SHARDS :
NUMBER_OF_SHARDS))
diff --git
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
index f5f265e3d..6f3c755b4 100644
---
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
+++
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-01-tenantDocumentIds.groovy
@@ -26,7 +26,7 @@ MigrationContext context = migrationContext
String esAddress = context.getConfigString(CONFIG_ES_ADDRESS)
String indexPrefix = context.getConfigString(INDEX_PREFIX)
String tenantId = context.getConfigString(TENANT_ID)
-String systemTenantId = "system" // System tenant ID for system-level items
+String systemTenantId = "system"
String rolloverPolicyName = indexPrefix + "-unomi-rollover-policy"
String rolloverSessionAlias = indexPrefix + "-session"
String rolloverEventAlias = indexPrefix + "-event"
@@ -104,16 +104,21 @@ context.performMigrationStep("4.0.0-get-all-indices", ()
-> {
Set<String> allItemTypes =
MigrationUtils.getAllItemTypes(context.getHttpClient(), esAddress, indexPrefix,
"*", bundleContext)
context.printMessage("Found " + allItemTypes.size() + " item types")
- // Get all system items from the systemitems index
+ // Get all system items from the systemitems index (store lower-case for
painless checks)
Set<String> systemItems =
MigrationUtils.getAllItemTypes(context.getHttpClient(), esAddress, indexPrefix,
"systemitems", bundleContext)
+ .collect { it.toLowerCase(java.util.Locale.ROOT) } as Set
context.printMessage("Found " + systemItems.size() + " system items")
+ Map<String, Set<String>> shippedDefinitionIds =
MigrationUtils.loadShippedDefinitionIds(bundleContext)
+ context.printMessage("Loaded " + shippedDefinitionIds.values().sum {
it.size() } + " shipped definition ids")
+
// Create base parameters
Map<String, Object> baseParams = new HashMap<>()
baseParams.put("date", isoDate)
baseParams.put("tenantId", tenantId)
baseParams.put("systemTenantId", systemTenantId)
baseParams.put("systemItems", systemItems)
+ baseParams.put("shippedDefinitionIds", shippedDefinitionIds)
context.printMessage("Using tenant ID: " + tenantId)
diff --git
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
index c92606ddc..6a5065fa1 100644
---
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
+++
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-4.0.0-05-fixSystemItemIds.groovy
@@ -1,7 +1,6 @@
import org.apache.unomi.shell.migration.service.MigrationContext
import org.apache.unomi.shell.migration.utils.MigrationUtils
import org.apache.unomi.shell.migration.utils.HttpUtils
-import org.json.JSONObject
import static org.apache.unomi.shell.migration.service.MigrationConfig.*
/*
@@ -25,7 +24,7 @@ MigrationContext context = migrationContext
String esAddress = context.getConfigString(CONFIG_ES_ADDRESS)
String indexPrefix = context.getConfigString(INDEX_PREFIX)
-// Get all system item types
+// Get all system item types (keep the casing returned by the index for
keyword queries)
Set<String> systemItems =
MigrationUtils.getAllItemTypes(context.getHttpClient(), esAddress, indexPrefix,
"systemitems", bundleContext)
context.printMessage("Found " + systemItems.size() + " system item types")
@@ -38,48 +37,39 @@ context.printMessage("Found " + systemItems.size() + "
system item types")
// incorrectly processed the baseId, we need to fix the itemId in the source
to match the document ID.
context.performMigrationStep("4.0.0-fix-system-item-ids", () -> {
String systemItemsIndex = "${indexPrefix}-systemitems"
-
+
if (MigrationUtils.indexExists(context.getHttpClient(), esAddress,
systemItemsIndex)) {
context.printMessage("Fixing itemIds in systemitems index that end
with itemType suffix")
-
- // Process each system item type
+
+ long totalUpdated = 0L
+ List<String> failedItemTypes = []
+
+ // Process each system item type. The query matches camelCase values
such as
+ // conditionType / actionType / propertyType (UNOMI-997).
systemItems.each { itemType ->
context.printMessage("Fixing items of type: ${itemType}")
-
- // Get the Painless script from file
+
String fixScript =
MigrationUtils.getFileWithoutComments(bundleContext,
"requestBody/4.0.0/fix_system_item_ids.painless")
-
- // Build the update request using JSONObject to properly escape
the script
- // This is the same approach used in
MigrationUtils.getScriptPart() and other migrations
- JSONObject scriptObj = new JSONObject()
- scriptObj.put("source", fixScript)
- scriptObj.put("lang", "painless")
-
- JSONObject queryObj = new JSONObject()
- JSONObject termObj = new JSONObject()
- termObj.put("itemType", itemType)
- queryObj.put("term", termObj)
-
- JSONObject updateRequestObj = new JSONObject()
- updateRequestObj.put("script", scriptObj)
- updateRequestObj.put("query", queryObj)
-
- String updateRequest = updateRequestObj.toString()
-
+
+ String updateRequest =
MigrationUtils.buildItemTypeUpdateRequest(fixScript, itemType)
+
try {
- MigrationUtils.updateByQuery(context.getHttpClient(),
esAddress, systemItemsIndex, updateRequest)
- context.printMessage("Fixed itemIds for item type:
${itemType}")
+ long updated =
MigrationUtils.updateByQueryAndCount(context.getHttpClient(), esAddress,
systemItemsIndex, updateRequest)
+ totalUpdated += updated
+ context.printMessage("Fixed ${updated} itemId(s) for item
type: ${itemType}")
} catch (Exception e) {
context.printMessage("Warning: Could not fix itemIds for item
type ${itemType}: ${e.getMessage()}")
- // Continue with other item types even if one fails
+ failedItemTypes.add(itemType)
}
}
-
- // Refresh the index to make changes visible
+
HttpUtils.executePostRequest(context.getHttpClient(), esAddress +
"/${systemItemsIndex}/_refresh", null, null)
- context.printMessage("Fixed itemIds in systemitems index")
+ context.printMessage("Fixed ${totalUpdated} itemId(s) in systemitems
index")
+ // Fail the step so it is retried on the next run instead of being
recorded as completed
+ if (!failedItemTypes.isEmpty()) {
+ throw new IllegalStateException("Could not fix itemIds for item
types: " + failedItemTypes)
+ }
} else {
context.printMessage("Systemitems index does not exist, skipping
itemId fix")
}
})
-
diff --git
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
index 9d309d1cc..fdab21556 100644
---
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
+++
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/fix_system_item_ids.painless
@@ -38,34 +38,15 @@ if (ctx._source.itemId != null && ctx._source.itemType !=
null && ctx._id != nul
documentIdWithoutTenant =
documentIdWithoutTenant.substring(firstUnderscoreIndex + 1);
}
- // For system items, the expected format after 2.2.0 migration is itemId =
documentId (with suffix)
- // However, new items created after migrations may have itemId = original
(without suffix)
- // Both work because setMetadata() extracts from document ID, but we
normalize to the migrated format
- // for consistency and to fix items affected by the 4.0.0-00 bug
- def itemTypeSuffix = '_' + ctx._source.itemType.toLowerCase();
- if (documentIdWithoutTenant.endsWith(itemTypeSuffix)) {
- // Document ID has the expected suffix format - itemId should match it
- if (ctx._source.itemId != documentIdWithoutTenant) {
- ctx._source.itemId = documentIdWithoutTenant;
- }
+ // Every case above ends the same way: itemId must equal the document ID
minus the tenant prefix.
+ // When the document ID lost its suffix (4.0.0-00 bug) it cannot be
repaired by update_by_query
+ // (that needs a reindex), but aligning itemId on it still lets
setMetadata() work.
+ // Documents that are already consistent are left untouched so they are
not counted as updated.
+ if (ctx._source.itemId != documentIdWithoutTenant) {
+ ctx._source.itemId = documentIdWithoutTenant;
} else {
- // Document ID doesn't have suffix (pre-2.2.0 format or incorrectly
processed by 4.0.0-00)
- // Check if source itemId has the suffix - this indicates the document
ID is wrong (from buggy 4.0.0-00)
- if (ctx._source.itemId != null &&
ctx._source.itemId.endsWith(itemTypeSuffix)) {
- // Source itemId has suffix but document ID doesn't - document ID
was incorrectly processed
- // We can't fix the document ID with update_by_query (would need
reindexing),
- // but we can at least ensure itemId matches the document ID so
setMetadata() can work
- // Note: This item will need to be reindexed to fix the document
ID properly
- if (ctx._source.itemId != documentIdWithoutTenant) {
- ctx._source.itemId = documentIdWithoutTenant;
- }
- } else {
- // Document ID doesn't have suffix and source itemId doesn't either
- // This is either pre-2.2.0 format or a legitimate case - ensure
they match
- if (ctx._source.itemId != documentIdWithoutTenant) {
- ctx._source.itemId = documentIdWithoutTenant;
- }
- }
+ ctx.op = 'noop';
}
+} else {
+ ctx.op = 'noop';
}
-
diff --git
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
index 7b089252d..97dcd9b38 100644
---
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
+++
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/initialize_tenant_and_audit_fields.painless
@@ -14,37 +14,63 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
+// Decide the tenant for this document (UNOMI-997).
+// Shipped definitions and geonames go to system. User-created
rules/segments/scorings/goals
+// stay in the configured tenant even when their 3.0 scope is "systemscope".
+// A shipped definition is recognised by item type and id only, so a 3.0 item
sharing both
+// (such as a built-in rule edited in place) also goes to system.
+boolean useSystemTenant = false;
+if (params.forceDefaultTenant != true) {
+ def itemTypeLower = ctx._source.itemType != null ?
ctx._source.itemType.toLowerCase(Locale.ROOT) : '';
+ // Shipped ids are grouped by item type, so other types (profiles,
events...) skip the lookup
+ def shippedIds = params.shippedDefinitionIds != null ?
params.shippedDefinitionIds.get(itemTypeLower) : null;
+ if (itemTypeLower == 'geonameentry' || (ctx._index != null &&
ctx._index.toLowerCase(Locale.ROOT).endsWith('-geonameentry'))) {
+ useSystemTenant = true;
+ } else if (shippedIds != null) {
+ def rawId = ctx._source.itemId != null ? ctx._source.itemId : ctx._id;
+ if (rawId != null) {
+ def shippedId = rawId.toLowerCase(Locale.ROOT);
+ if (shippedId.endsWith('_' + itemTypeLower)) {
+ shippedId = shippedId.substring(0, shippedId.length() -
itemTypeLower.length() - 1);
+ }
+ useSystemTenant = shippedIds.contains(shippedId);
+ }
+ }
+ // Literal "system" only — not the 3.0 default "systemscope"
+ if (!useSystemTenant && ctx._source.scope == 'system') {
+ useSystemTenant = true;
+ }
+}
+def assignedTenantId = useSystemTenant ? params.systemTenantId :
params.tenantId;
+
// Update document ID with tenant prefix
if (ctx._id != null) {
// Skip if already has tenant prefix
if (!ctx._id.startsWith(params.tenantId + '_') &&
!ctx._id.startsWith(params.systemTenantId + '_')) {
- // Cluster nodes and unknown indices always stay in the configured
(default) tenant
- if (params.forceDefaultTenant == true) {
- ctx._id = params.tenantId + '_' + ctx._id;
- } else if (ctx._index.endsWith('-geonames') || (ctx._source.scope !=
null && ctx._source.scope == 'system')) {
- // Preserve the entire original document ID (it may contain
underscores, e.g., "dummy_scope")
- // Do NOT split on underscores - this was a bug in the original
migration that caused itemIds
- // like "dummy_scope" to become "dummy" when the document ID was
"dummy_scope_scope"
- def baseId = ctx._id;
- if (ctx._index.endsWith('-systemitems')) {
- // For system items, ensure itemType is lowercase and matches
the format in ElasticSearchPersistenceServiceImpl
- def itemType = ctx._source.itemType != null ?
ctx._source.itemType.toLowerCase() : null;
- if (itemType != null && params.systemItems.contains(itemType))
{
- ctx._id = ctx._source.scope == 'system' ?
params.systemTenantId + '_' + baseId + '_' + itemType : params.tenantId + '_' +
baseId + '_' + itemType;
- } else {
- ctx._id = ctx._source.scope == 'system' ?
params.systemTenantId + '_' + baseId : params.tenantId + '_' + baseId;
- }
+ def baseId = ctx._id;
+ if (ctx._index != null &&
ctx._index.toLowerCase(Locale.ROOT).endsWith('-systemitems')) {
+ // For system items the document ID is tenantId_itemId_itemtype
(see getDocumentIDForItemType).
+ // 3.0 document IDs already carry the lower-case _itemtype suffix,
so only add it when missing.
+ def itemType = ctx._source.itemType != null ?
ctx._source.itemType.toLowerCase(Locale.ROOT) : null;
+ if (itemType != null && params.systemItems != null &&
params.systemItems.contains(itemType)
+ && !baseId.toLowerCase(Locale.ROOT).endsWith('_' +
itemType)) {
+ ctx._id = assignedTenantId + '_' + baseId + '_' + itemType;
} else {
- ctx._id = params.systemTenantId + '_' + baseId;
+ ctx._id = assignedTenantId + '_' + baseId;
}
} else {
- ctx._id = params.tenantId + '_' + ctx._id;
+ ctx._id = assignedTenantId + '_' + baseId;
}
}
}
-// Update audit fields
-if (!ctx._index.endsWith('-systemitems') && !ctx._index.endsWith('-geonames'))
{
+// Update audit fields (skip geonames — not asserted and high volume).
+// systemitems must get audit here: UNOMI-997 puts shipped defs under the
system
+// tenant before Unomi starts, so bundle deploy treats them as updates and
would
+// otherwise leave createdBy unset on docs that never had it.
+def indexLower = ctx._index != null ? ctx._index.toLowerCase(Locale.ROOT) : '';
+if (!indexLower.endsWith('-geonameentry') &&
!indexLower.endsWith('-geonames')) {
// Handle creation date based on item type
if (ctx._source.creationDate == null) {
if (params.itemType == 'profile' && ctx._source.properties != null) {
@@ -59,7 +85,7 @@ if (!ctx._index.endsWith('-systemitems') &&
!ctx._index.endsWith('-geonames')) {
ctx._source.creationDate = params.date;
}
}
-
+
// Handle last modification date based on item type
if (ctx._source.lastModificationDate == null) {
if (params.itemType == 'profile' && ctx._source.properties != null) {
@@ -74,7 +100,7 @@ if (!ctx._index.endsWith('-systemitems') &&
!ctx._index.endsWith('-geonames')) {
ctx._source.lastModificationDate = ctx._source.creationDate;
}
}
-
+
// Set creator fields
if (ctx._source.createdBy == null) {
ctx._source.createdBy = 'system-migration-4.0.0';
@@ -82,7 +108,7 @@ if (!ctx._index.endsWith('-systemitems') &&
!ctx._index.endsWith('-geonames')) {
if (ctx._source.lastModifiedBy == null) {
ctx._source.lastModifiedBy = 'system-migration-4.0.0';
}
-
+
// Initialize source tracking fields
if (ctx._source.sourceInstanceId == null) {
ctx._source.sourceInstanceId = null;
@@ -92,13 +118,7 @@ if (!ctx._index.endsWith('-systemitems') &&
!ctx._index.endsWith('-geonames')) {
}
}
-// Set tenant ID in the source document based on scope for ALL items
+// Set tenant ID in the source document
if (ctx._source.tenantId == null) {
- if (params.forceDefaultTenant == true) {
- ctx._source.tenantId = params.tenantId;
- } else if (ctx._index.endsWith('-geonames') || (ctx._source.scope != null
&& ctx._source.scope == 'system')) {
- ctx._source.tenantId = params.systemTenantId;
- } else {
- ctx._source.tenantId = params.tenantId;
- }
-}
\ No newline at end of file
+ ctx._source.tenantId = assignedTenantId;
+}
diff --git
a/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
new file mode 100644
index 000000000..3d8cd07ad
--- /dev/null
+++
b/tools/shell-commands/src/main/resources/requestBody/4.0.0/shipped_system_definitions.json
@@ -0,0 +1,127 @@
+[
+ "actiontype:addtolistsaction",
+ "actiontype:alleventtoprofilepropertiesaction",
+ "actiontype:cdpsessioneventaction",
+ "actiontype:copypropertiesaction",
+ "actiontype:evaluateprofileageaction",
+ "actiontype:evaluateprofilesegmentsaction",
+ "actiontype:evaluatevisitpropertiesaction",
+ "actiontype:eventtoprofilepropertyaction",
+ "actiontype:incrementpropertyaction",
+ "actiontype:incrementtweetnumberaction",
+ "actiontype:mergeprofilesonpropertyaction",
+ "actiontype:modifyconsentaction",
+ "actiontype:requestheadertoprofilepropertyaction",
+ "actiontype:requestparametertoprofilepropertyaction",
+ "actiontype:sendeventaction",
+ "actiontype:sendmailaction",
+ "actiontype:seteventoccurencecountaction",
+ "actiontype:setpropertyaction",
+ "actiontype:setremotehostinfoaction",
+ "actiontype:sfdccreateorupdateleadaction",
+ "actiontype:sfdcupdateprofilefromleadaction",
+ "actiontype:testgroovyaction",
+ "actiontype:trainingnotifaction",
+ "actiontype:updateconsenteventaction",
+ "actiontype:updatelistseventaction",
+ "actiontype:updatepropertiesaction",
+ "actiontype:weatherupdateaction",
+ "conditiontype:booleancondition",
+ "conditiontype:cdpsessioneventcondition",
+ "conditiontype:eventpropertycondition",
+ "conditiontype:eventtypecondition",
+ "conditiontype:formeventcondition",
+ "conditiontype:geolocationbypointsessioncondition",
+ "conditiontype:geolocationsessioncondition",
+ "conditiontype:goalmatchcondition",
+ "conditiontype:hovereventcondition",
+ "conditiontype:idscondition",
+ "conditiontype:matchallcondition",
+ "conditiontype:modifyanyconsenteventcondition",
+ "conditiontype:modifyconsenteventcondition",
+ "conditiontype:nestedcondition",
+ "conditiontype:newvisitorcondition",
+ "conditiontype:notcondition",
+ "conditiontype:pasteventcondition",
+ "conditiontype:profilealiasespropertycondition",
+ "conditiontype:profilepropertycondition",
+ "conditiontype:profilesegmentcondition",
+ "conditiontype:profileupdatedeventcondition",
+ "conditiontype:profileuserlistcondition",
+ "conditiontype:returningvisitorcondition",
+ "conditiontype:scoringcondition",
+ "conditiontype:sessioncreatedeventcondition",
+ "conditiontype:sessiondurationcondition",
+ "conditiontype:sessionpropertycondition",
+ "conditiontype:sourceeventpropertycondition",
+ "conditiontype:topicpropertycondition",
+ "conditiontype:updateconsenteventcondition",
+ "conditiontype:updatelistseventcondition",
+ "conditiontype:updatepropertieseventcondition",
+ "conditiontype:userlistpropertycondition",
+ "conditiontype:videovieweventcondition",
+ "persona:europeanvisitor",
+ "persona:usvisitor",
+ "personasession:51c8e9a8-3db6-41f8-9569-057c339e3017",
+ "personasession:aa3b04bd-8f4d-4a07-8e96-d33ffa04d3d9",
+ "propertytype:address",
+ "propertytype:age",
+ "propertytype:birthdate",
+ "propertytype:city",
+ "propertytype:company",
+ "propertytype:countryname",
+ "propertytype:devicecategory",
+ "propertytype:email",
+ "propertytype:facebookid",
+ "propertytype:firstname",
+ "propertytype:firstvisit",
+ "propertytype:gender",
+ "propertytype:googleid",
+ "propertytype:income",
+ "propertytype:jobtitle",
+ "propertytype:kids",
+ "propertytype:lastname",
+ "propertytype:lastvisit",
+ "propertytype:latitude",
+ "propertytype:leadassignedto",
+ "propertytype:linkedinid",
+ "propertytype:longitude",
+ "propertytype:maritalstatus",
+ "propertytype:nationality",
+ "propertytype:nbofvisits",
+ "propertytype:operatingsystemfamily",
+ "propertytype:operatingsystemname",
+ "propertytype:pagereferringurl",
+ "propertytype:phonenumber",
+ "propertytype:previousvisit",
+ "propertytype:sessionadminsubdiv1",
+ "propertytype:sessionadminsubdiv2",
+ "propertytype:sessioncity",
+ "propertytype:sessioncountrycode",
+ "propertytype:sessioncountryname",
+ "propertytype:totalnbofvisits",
+ "propertytype:twitterid",
+ "propertytype:useragentname",
+ "propertytype:useragentversion",
+ "propertytype:weatherlike",
+ "propertytype:weathertemperature",
+ "propertytype:weatherwinddirection",
+ "propertytype:weatherwindspeed",
+ "propertytype:zipcode",
+ "rule:_8s3odnl0n_sessionreferrer",
+ "rule:_ajhg9u2s5_sessionassigned",
+ "rule:_ed2tvbd9p_evaluateprofileage.json",
+ "rule:_i6iqbc3x8_sessionpagereferrer",
+ "rule:_t7caz8npj_evaluateprofilesegments",
+ "rule:_z21qiv8b1_newsession",
+ "rule:cdp_sessionevent",
+ "rule:examplelogin",
+ "rule:modifyanyconsent",
+ "rule:optimizationtest",
+ "rule:smp:incrementtweetnumber",
+ "rule:smp:trainedrule",
+ "rule:testgroovyactionrule",
+ "rule:updateconsent",
+ "rule:updatelists",
+ "rule:updateproperties"
+]
diff --git
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
index 92b48578c..f2c1ea1a1 100644
---
a/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
+++
b/tools/shell-commands/src/test/java/org/apache/unomi/shell/migration/utils/MigrationUtilsTest.java
@@ -16,23 +16,36 @@
*/
package org.apache.unomi.shell.migration.utils;
+import org.json.JSONArray;
+import org.json.JSONObject;
import org.junit.Before;
import org.junit.Test;
import org.osgi.framework.Bundle;
import org.osgi.framework.BundleContext;
import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
+import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.TreeSet;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.*;
public class MigrationUtilsTest {
@@ -302,4 +315,98 @@ public class MigrationUtilsTest {
assertEquals("sfdcconfiguration",
MigrationUtils.resolveItemType("context-sfdcconfiguration", "context", types));
assertEquals("generic",
MigrationUtils.resolveItemType("other-profile", "context", types));
}
+
+ private static final String SHIPPED_DEFINITIONS =
"requestBody/4.0.0/shipped_system_definitions.json";
+
+ @Test
+ public void loadShippedDefinitionIdsGroupsLowerCaseIdsByType() throws
Exception {
+ stubShippedDefinitions("[\"conditionType:booleanCondition\",
\"rule:_abc_myRule.json\", \"rule:other\"]");
+ Map<String, Set<String>> shipped =
MigrationUtils.loadShippedDefinitionIds(bundleContext);
+ assertEquals(Set.of("conditiontype", "rule"), shipped.keySet());
+ assertEquals(Set.of("booleancondition"), shipped.get("conditiontype"));
+ assertEquals(Set.of("_abc_myrule.json", "other"), shipped.get("rule"));
+ }
+
+ @Test
+ public void loadShippedDefinitionIdsRejectsEmptyOrMalformedLists() throws
Exception {
+ for (String content : Arrays.asList("[]", "[\"booleanCondition\"]",
"[\":booleanCondition\"]", "[\"rule:\"]", "[42]")) {
+ stubShippedDefinitions(content);
+ assertThrows(content, IllegalStateException.class, () ->
MigrationUtils.loadShippedDefinitionIds(bundleContext));
+ }
+ }
+
+ /**
+ * The shipped list is a snapshot: it must be updated whenever a
definition is added to or
+ * removed from the bundles, or migrated copies land in the wrong tenant.
+ */
+ @Test
+ public void shippedDefinitionsListMatchesTheDefinitionsInTheSourceTree()
throws Exception {
+ Path repositoryRoot =
Path.of("").toAbsolutePath().getParent().getParent();
+ assertTrue("Unexpected source layout: " + repositoryRoot,
Files.isDirectory(repositoryRoot.resolve("plugins")));
+
+ Map<String, String> typeByDirectory = Map.of("conditions",
"conditiontype", "actions", "actiontype",
+ "properties", "propertytype", "rules", "rule", "personas",
"persona");
+ Map<String, Set<String>> expected = new TreeMap<>();
+ Files.walkFileTree(repositoryRoot, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) {
+ String name = dir.getFileName().toString();
+ boolean skip = name.startsWith(".") || name.equals("target")
|| name.equals("node_modules") || name.equals("test");
+ return skip ? FileVisitResult.SKIP_SUBTREE :
FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes
attrs) throws IOException {
+ String path =
repositoryRoot.relativize(file).toString().replace(File.separatorChar, '/');
+ int cxs = path.indexOf("/src/main/resources/META-INF/cxs/");
+ if (cxs < 0 || !path.endsWith(".json")) {
+ return FileVisitResult.CONTINUE;
+ }
+ String directory = path.substring(cxs +
"/src/main/resources/META-INF/cxs/".length()).split("/")[0];
+ String type = typeByDirectory.get(directory);
+ if (type == null) {
+ return FileVisitResult.CONTINUE;
+ }
+ JSONObject definition = new JSONObject(Files.readString(file));
+ if ("persona".equals(type)) {
+ addExpected(expected, type,
definition.getJSONObject("persona").getString("itemId"));
+ JSONArray sessions = definition.getJSONArray("sessions");
+ for (int i = 0; i < sessions.length(); i++) {
+ addExpected(expected, "personasession",
sessions.getJSONObject(i).getString("itemId"));
+ }
+ } else {
+ addExpected(expected, type,
definition.getJSONObject("metadata").getString("id"));
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+
+
when(bundle.getResource(SHIPPED_DEFINITIONS)).thenReturn(getClass().getResource("/"
+ SHIPPED_DEFINITIONS));
+ assertEquals("Update " + SHIPPED_DEFINITIONS + " to match the
definitions shipped under META-INF/cxs",
+ expected,
MigrationUtils.loadShippedDefinitionIds(bundleContext));
+ }
+
+ private static void addExpected(Map<String, Set<String>> expected, String
type, String id) {
+ expected.computeIfAbsent(type, key -> new
TreeSet<>()).add(id.toLowerCase(Locale.ROOT));
+ }
+
+ private void stubShippedDefinitions(String content) throws IOException {
+ Path file = Files.createTempFile("shipped-definitions", ".json");
+ file.toFile().deleteOnExit();
+ Files.writeString(file, content);
+
when(bundle.getResource(SHIPPED_DEFINITIONS)).thenReturn(file.toUri().toURL());
+ }
+
+ @Test
+ public void buildItemTypeUpdateRequestMatchesKeywordAndFoldedItemType() {
+ JSONObject request = new
JSONObject(MigrationUtils.buildItemTypeUpdateRequest("ctx.op = 'noop'",
"conditionType"));
+ assertEquals("ctx.op = 'noop'",
request.getJSONObject("script").getString("source"));
+ assertEquals("painless",
request.getJSONObject("script").getString("lang"));
+ JSONObject bool = request.getJSONObject("query").getJSONObject("bool");
+ assertEquals(1, bool.getInt("minimum_should_match"));
+ JSONArray should = bool.getJSONArray("should");
+ assertEquals(2, should.length());
+ assertEquals("conditionType",
should.getJSONObject(0).getJSONObject("term").getString("itemType.keyword"));
+ assertEquals("conditiontype",
should.getJSONObject(1).getJSONObject("term").getString("itemType"));
+ }
}