This is an automated email from the ASF dual-hosted git repository.

jayblanc 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 c0f145bb0 [UNOMI-922] Inconsistency between value for nbOfVisits in 
Profile and number of Sessions (#744)
c0f145bb0 is described below

commit c0f145bb030a718e7782578c939099c626fb8b69
Author: Jerome Blanchard <[email protected]>
AuthorDate: Wed Jan 7 14:09:59 2026 +0100

    [UNOMI-922] Inconsistency between value for nbOfVisits in Profile and 
number of Sessions (#744)
    
    * UNOMI-922: Incoherency between value for nbOfVisits in Profile and number 
of Sessions
    * UNOMI-922: Add new ES snapshot to test migration
    * UNOMI-922: Upgrade ES version to ensure snapshot can be restored
    * UNOMI-922: Fix migration test
    * feat: improve batch size for profile migration from 100 to 1000
---
 docker/src/main/docker/docker-compose-cluster.yml  |   2 +-
 itests/README.md                                   |  18 ++--
 itests/pom.xml                                     |   2 +-
 .../org/apache/unomi/itests/ProfileServiceIT.java  |  71 ++++++++++++-
 .../migration/Migrate16xToCurrentVersionIT.java    |  57 ++++++++---
 .../resources/migration/snapshots_repository.zip   | Bin 3252696 -> 3294102 
bytes
 manual/src/main/asciidoc/configuration.adoc        |   9 ++
 manual/src/main/asciidoc/datamodel.adoc            |   2 +
 .../resources/META-INF/cxs/mappings/profile.json   |   3 +
 .../resources/META-INF/cxs/mappings/profile.json   |   3 +
 .../resources/META-INF/cxs/expressions/mvel.json   |   3 +-
 .../META-INF/cxs/rules/sessionAssigned.json        |   7 ++
 pom.xml                                            |   4 +-
 .../services/impl/profiles/ProfileServiceImpl.java | 114 +++++++++++++++++++--
 .../META-INF/cxs/painless/decNbOfVisits.painless   |  30 ++++++
 .../cxs/properties/profiles/system/nbOfVisits.json |   2 +-
 .../{nbOfVisits.json => totalNbOfVisits.json}      |   8 +-
 services/src/main/resources/messages_de.properties |   1 +
 services/src/main/resources/messages_en.properties |   1 +
 .../migrate-3.1.0-00-fixProfileNbOfVisits.groovy   |  98 ++++++++++++++++++
 .../copy_nbOfVisits_to_totalNbOfVisits.painless    |  25 +++++
 .../3.1.0/count_sessions_by_profile.json           |  19 ++++
 .../3.1.0/profile_copy_nbOfVisits_request.json     |  25 +++++
 .../requestBody/3.1.0/profile_scroll_query.json    |  23 +++++
 24 files changed, 484 insertions(+), 43 deletions(-)

diff --git a/docker/src/main/docker/docker-compose-cluster.yml 
b/docker/src/main/docker/docker-compose-cluster.yml
index 0eca8d6d5..a088fc082 100644
--- a/docker/src/main/docker/docker-compose-cluster.yml
+++ b/docker/src/main/docker/docker-compose-cluster.yml
@@ -17,7 +17,7 @@
 version: '2.4'
 services:
   elasticsearch:
-    image: docker.elastic.co/elasticsearch/elasticsearch:9.1.3
+    image: docker.elastic.co/elasticsearch/elasticsearch:9.2.1
     volumes:
       - unomi-3-elasticsearch-data:/usr/share/elasticsearch/data
     environment:
diff --git a/itests/README.md b/itests/README.md
index 3c4216660..28bfe6d00 100644
--- a/itests/README.md
+++ b/itests/README.md
@@ -153,12 +153,12 @@ public class Migrate16xTo200IT extends BaseIT {
             // Create snapshot repo
             HttpUtils.executePutRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/";, 
resourceAsString("migration/create_snapshots_repository.json"), null);
             // Get snapshot, insure it exists
-            String snapshot = HttpUtils.executeGetRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_2";, null);
-            if (snapshot == null || !snapshot.contains("snapshot_2")) {
+            String snapshot = HttpUtils.executeGetRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_3";, null);
+            if (snapshot == null || !snapshot.contains("snapshot_3")) {
                 throw new RuntimeException("Unable to retrieve 1.6.x snapshot 
for ES restore");
             }
             // Restore the snapshot
-            HttpUtils.executePostRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_2/_restore?wait_for_completion=true";,
 "{}", null);
+            HttpUtils.executePostRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_3/_restore?wait_for_completion=true";,
 "{}", null);
         } catch (IOException e) {
             throw new RuntimeException(e);
         }
@@ -183,7 +183,7 @@ public class Migrate16xTo200IT extends BaseIT {
 
 ### How to update a migration test ElasticSearch Snapshot ?
 
-In the following example we want to modify the snapshot: `snapshot_2`.
+In the following example we want to modify the snapshot: `snapshot_3`.
 This snapshot has been done on Unomi 1.6.x using ElasticSearch 7.11.0. 
 So we will set up locally those servers in the exact same versions.
 (For now just download them and do not start them yet.)
@@ -233,13 +233,13 @@ Now we have to add the snapshot repository, do the 
following request on your Ela
     }
 
 Now we need to restore the snapshot we want to modify, 
-but first let's try to see if the snapshot with the id `snapshot_2` correctly 
exists:
+but first let's try to see if the snapshot with the id `snapshot_3` correctly 
exists:
 
-    GET /_snapshot/snapshots_repository/snapshot_2
+    GET /_snapshot/snapshots_repository/snapshot_3
 
 If the snapshot exists we can restore it:
 
-    POST 
/_snapshot/snapshots_repository/snapshot_2/_restore?wait_for_completion=true
+    POST 
/_snapshot/snapshots_repository/snapshot_3/_restore?wait_for_completion=true
     {}
 
 At the end of the previous request ElasticSearch should be ready and our Unomi 
snapshot is restored to version `1.6.x`.
@@ -257,11 +257,11 @@ they are probably used by the actual migration tests 
already.)
 
 Once you data updated we need to recreate the snapshot, first we delete the 
old snapshot:
 
-    DELETE /_snapshot/snapshots_repository/snapshot_2
+    DELETE /_snapshot/snapshots_repository/snapshot_3
 
 Then we recreate it:
 
-    PUT /_snapshot/snapshots_repository/snapshot_2
+    PUT /_snapshot/snapshots_repository/snapshot_3
 
 Once the process finished (check the ElasticSearch logs to see that the 
snapshot is correctly created), 
 we need to remove the snapshot repository from our local ElasticSearch
diff --git a/itests/pom.xml b/itests/pom.xml
index 2c31d07ec..98e98e9e5 100644
--- a/itests/pom.xml
+++ b/itests/pom.xml
@@ -252,7 +252,7 @@
                         <!-- REPLACE THE FOLLOWING WITH THE PLUGIN VERSION YOU 
NEED -->
                         <version>6.29</version>
                         <configuration>
-                            <!-- 
<downloadUrl>https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-9.1.3-darwin-aarch64.tar
+                            <!-- 
<downloadUrl>https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-9.2.1-darwin-aarch64.tar
                             .gz</downloadUrl> -->
                             
<clusterName>contextElasticSearchITests</clusterName>
                             <transportPort>9500</transportPort>
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
index 2ff55fc12..353bc49a2 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileServiceIT.java
@@ -19,7 +19,6 @@ package org.apache.unomi.itests;
 import org.apache.unomi.api.*;
 import org.apache.unomi.api.conditions.Condition;
 import org.apache.unomi.api.query.Query;
-import org.apache.unomi.persistence.spi.PersistenceService;
 import org.junit.After;
 import org.junit.Assert;
 import org.junit.Before;
@@ -151,8 +150,7 @@ public class ProfileServiceIT extends BaseIT {
 
     // Relevant only when throwExceptions system property is true
     @Test
-    public void testGetProfileWithWrongScrollerIdThrowException()
-            throws InterruptedException, NoSuchFieldException, 
IllegalAccessException, IOException {
+    public void testGetProfileWithWrongScrollerIdThrowException() throws 
InterruptedException, IOException {
         boolean throwExceptionCurrent = false;
         Configuration searchEngineConfiguration = 
configurationAdmin.getConfiguration("org.apache.unomi.persistence." + 
searchEngine);
         if (searchEngineConfiguration != null && 
searchEngineConfiguration.getProperties().get("throwExceptions") != null) {
@@ -470,4 +468,71 @@ public class ProfileServiceIT extends BaseIT {
         keepTrying("We should not be able to retrieve previous profile based 
on previous value", () -> persistenceService.queryCount(oldProfilesCondition, 
Profile.ITEM_TYPE),
                 (count) -> count == 0, 1000, 100);
     }
+
+    @Test
+    public void testPurgeSessions() throws Exception {
+        Date currentDate = new Date();
+        LocalDateTime minus6Months = 
LocalDateTime.ofInstant(currentDate.toInstant(), 
ZoneId.systemDefault()).minusMonths(6);
+        LocalDateTime minus18Months = 
LocalDateTime.ofInstant(currentDate.toInstant(), 
ZoneId.systemDefault()).minusMonths(18);
+        Date currentDateMinus6Months = 
Date.from(minus6Months.atZone(ZoneId.systemDefault()).toInstant());
+        Date currentDateMinus18Months = 
Date.from(minus18Months.atZone(ZoneId.systemDefault()).toInstant());
+
+        long originalSessionsCount  = 
persistenceService.getAllItemsCount(Session.ITEM_TYPE);
+
+        //Create 10 profiles with sessions
+        Profile[] profiles = new Profile[10];
+        for (int i=0; i < profiles.length; i++) {
+            profiles[i] = new Profile("dummy-profile-session-purge-test-" + i);
+            profiles[i].setProperty("nbOfVisits", 20);
+            profiles[i].setProperty("totalNbOfVisits", 20);
+            persistenceService.save(profiles[i]);
+        }
+
+        // create 6 months old sessions
+        for (int i = 0; i < profiles.length * 10; i++) {
+            Session session = new Session("6-months-old-session-" + i, 
profiles[i%10], currentDateMinus6Months, "dummy-scope");
+            persistenceService.save(session);
+        }
+
+        // create 18 months old sessions
+        for (int i = 0; i < profiles.length * 10; i++) {
+            Session session = new Session("18-months-old-session-" + i, 
profiles[i%10], currentDateMinus18Months, "dummy-scope");
+            persistenceService.save(session);
+        }
+
+        keepTrying("Sessions number should be 200", () -> 
persistenceService.getAllItemsCount(Session.ITEM_TYPE),
+                (count) -> count == (200 + originalSessionsCount), 1000, 100);
+        for (Profile value : profiles) {
+            String profileId = value.getItemId();
+            keepTrying("Profile should have nbOfVisits=20", () -> 
profileService.load(profileId),
+                    (profile) -> (Integer) profile.getProperty("nbOfVisits") 
== 20, 1000, 100);
+            keepTrying("Profile should have totalNbOfVisits=20", () -> 
profileService.load(profileId),
+                    (profile) -> (Integer) 
profile.getProperty("totalNbOfVisits") == 20, 1000, 100);
+        }
+
+        // Should have no effect
+        profileService.purgeSessionItems(0);
+        keepTrying("Sessions number should be 200", () -> 
persistenceService.getAllItemsCount(Session.ITEM_TYPE),
+                (count) -> count == (200 + originalSessionsCount), 1000, 100);
+        for (Profile value : profiles) {
+            String profileId = value.getItemId();
+            keepTrying("Profile should have nbOfVisits=20", () -> 
profileService.load(profileId),
+                    (profile) -> (Integer) profile.getProperty("nbOfVisits") 
== 20, 1000, 100);
+            keepTrying("Profile should have totalNbOfVisits=20", () -> 
profileService.load(profileId),
+                    (profile) -> (Integer) 
profile.getProperty("totalNbOfVisits") == 20, 1000, 100);
+        }
+
+        // Should purge sessions older than 365 days
+        profileService.purgeSessionItems(365);
+        keepTrying("Sessions number should be 100", () -> 
persistenceService.getAllItemsCount(Session.ITEM_TYPE),
+                (count) -> count == (100 + originalSessionsCount), 1000, 100);
+        for (Profile value : profiles) {
+            String profileId = value.getItemId();
+            keepTrying("Profile should have nbOfVisits=10", () -> 
profileService.load(profileId),
+                    (profile) -> (Integer) profile.getProperty("nbOfVisits") 
== 10, 1000, 100);
+            keepTrying("Profile should have totalNbOfVisits=20", () -> 
profileService.load(profileId),
+                    (profile) -> (Integer) 
profile.getProperty("totalNbOfVisits") == 20, 1000, 100);
+        }
+
+    }
 }
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 5656410a7..b60265582 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
@@ -40,7 +40,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
 
     private int eventCount = 0;
     private int sessionCount = 0;
-    private Set<String[]> initialScopes = new HashSet<>();
+    private final Set<String[]> initialScopes = new HashSet<>();
 
     private static final String SCOPE_NOT_EXIST = "SCOPE_NOT_EXIST";
     private static final List<String> oldSystemItemsIndices = 
Arrays.asList("context-actiontype", "context-campaign", 
"context-campaignevent", "context-goal",
@@ -71,12 +71,12 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
             // Create snapshot repo
             HttpUtils.executePutRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/";, 
resourceAsString("migration/create_snapshots_repository.json"), null);
             // Get snapshot, insure it exists
-            String snapshot = HttpUtils.executeGetRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_2";, null);
-            if (snapshot == null || !snapshot.contains("snapshot_2")) {
+            String snapshot = HttpUtils.executeGetRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_3";, null);
+            if (snapshot == null || !snapshot.contains("snapshot_3")) {
                 throw new RuntimeException("Unable to retrieve 1.6.x snapshot 
for ES restore");
             }
             // Restore the snapshot
-            HttpUtils.executePostRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_2/_restore?wait_for_completion=true";,
 "{}", null);
+            HttpUtils.executePostRequest(httpClient, 
"http://localhost:9400/_snapshot/snapshots_repository/snapshot_3/_restore?wait_for_completion=true";,
 "{}", null);
 
             String snapshotStatus = HttpUtils.executeGetRequest(httpClient, 
"http://localhost:9400/_snapshot/_status";, null);
             System.out.println(snapshotStatus);
@@ -134,6 +134,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
         }
         checkMergedProfilesAliases();
         checkProfileInterests();
+        checkProfileTotalNbOfVisits();
         checkScopeHaveBeenCreated();
         checkLoginEventWithScope();
         checkFormEventRestructured();
@@ -224,7 +225,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
         for (Event formEvent : events) {
             Assert.assertEquals(0, formEvent.getProperties().size());
             Map<String, Object> fields = (Map<String, Object>) 
formEvent.getFlattenedProperties().get("fields");
-            Assert.assertTrue(fields.size() > 0);
+            Assert.assertFalse(fields.isEmpty());
 
             if (Objects.equals(formEvent.getItemId(), 
"7b55b4fd-5ff0-4a85-9dc4-ffde322a1de6")) {
                 // check singled valued
@@ -243,14 +244,14 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
         List<String> digitallLoginEvent = 
Arrays.asList("4054a3e0-35ef-4256-999b-b9c05c1209f1", 
"f3f71ff8-2d6d-4b6c-8bdc-cb39905cddfe", "ff24ae6f-5a98-421e-aeb0-e86855b462ff");
         for (Event loginEvent : events) {
             if 
(loginEvent.getItemId().equals("5c4ac1df-f42b-4117-9432-12fdf9ecdf98")) {
-                Assert.assertEquals(loginEvent.getScope(), "systemsite");
-                Assert.assertEquals(loginEvent.getTarget().getScope(), 
"systemsite");
-                Assert.assertEquals(loginEvent.getSource().getScope(), 
"systemsite");
+                Assert.assertEquals("systemsite", loginEvent.getScope());
+                Assert.assertEquals("systemsite", 
loginEvent.getTarget().getScope());
+                Assert.assertEquals("systemsite", 
loginEvent.getSource().getScope());
             }
             if (digitallLoginEvent.contains(loginEvent.getItemId())) {
-                Assert.assertEquals(loginEvent.getScope(), "digitall");
-                Assert.assertEquals(loginEvent.getTarget().getScope(), 
"digitall");
-                Assert.assertEquals(loginEvent.getSource().getScope(), 
"digitall");
+                Assert.assertEquals("digitall", loginEvent.getScope());
+                Assert.assertEquals("digitall", 
loginEvent.getTarget().getScope());
+                Assert.assertEquals("digitall", 
loginEvent.getSource().getScope());
             }
         }
     }
@@ -346,6 +347,38 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
         }
     }
 
+    /**
+     * Data set contains a profile (id: 468ca2bf-7d24-41ea-9ef4-5b96f78207e4) 
with a property named totalNbOfVisits set to 3
+     * --> Because that profile has only one session, the nbOfVisits should be 
set to 1 after migration 3.1.0-00
+     * All other profiles that had an existing nbOfVisits should now have the 
totalNbOfVisits property set.
+     */
+    private void checkProfileTotalNbOfVisits() {
+        // check that totalNbOfVisits have been set for a specific profile
+        Profile profile = 
persistenceService.load("468ca2bf-7d24-41ea-9ef4-5b96f78207e4", Profile.class);
+        Assert.assertEquals("Bill", profile.getProperty("firstName"));
+        Assert.assertNotNull("Profile " + profile.getItemId() + " is missing 
totalNbOfVisits property", profile.getProperty("totalNbOfVisits"));
+        Assert.assertEquals("Profile " + profile.getItemId() + " has not the 
expected value for totalNbOfVisits", 3, profile.getProperty("totalNbOfVisits"));
+        Assert.assertNotNull("Profile " + profile.getItemId() + " is missing 
nbOfVisits property", profile.getProperty("nbOfVisits"));
+        Assert.assertEquals("Profile " + profile.getItemId() + " has not the 
expected value for nbOfVisits",3, profile.getProperty("nbOfVisits"));
+
+        // check that nbOfVisits have been corrected set for a specific profile
+        profile = 
persistenceService.load("ad6dc96a-964e-4f6a-b3dc-2395b6e8a069", Profile.class);
+        Assert.assertEquals("Leonard", profile.getProperty("firstName"));
+        Assert.assertNotNull("Profile " + profile.getItemId() + " is missing 
totalNbOfVisits property", profile.getProperty("totalNbOfVisits"));
+        Assert.assertEquals("Profile " + profile.getItemId() + " has not the 
expected value for totalNbOfVisits", 15, 
profile.getProperty("totalNbOfVisits"));
+        Assert.assertNotNull("Profile " + profile.getItemId() + " is missing 
nbOfVisits property", profile.getProperty("nbOfVisits"));
+        Assert.assertEquals("Profile " + profile.getItemId() + " has not the 
expected value for nbOfVisits",1, profile.getProperty("nbOfVisits"));
+
+        // check that the totalNbOfVisits property has been set for all 
profiles
+        List<Profile> allProfiles = 
persistenceService.getAllItems(Profile.class);
+        Assert.assertFalse("No profiles found in the data set", 
allProfiles.isEmpty());
+        for (Profile p : allProfiles) {
+            if (p.getProperties().containsKey("nbOfVisits")) {
+                Assert.assertNotNull("Profile " + p.getItemId() + " is missing 
totalNbOfVisits property", p.getProperty("totalNbOfVisits"));
+            }
+        }
+    }
+
     /**
      * Data set contains a master profile: 468ca2bf-7d24-41ea-9ef4-5b96f78207e4
      * And two profiles that have been merged with this master profile: 
c33dec90-ffc9-4484-9e61-e42c323f268f and ac5b6b0f-afce-4c4f-9391-4ff0b891b254
@@ -358,7 +391,7 @@ public class Migrate16xToCurrentVersionIT extends BaseIT {
             // control the created alias
             ProfileAlias alias = persistenceService.load(mergedProfile, 
ProfileAlias.class);
             Assert.assertNotNull(alias);
-            Assert.assertEquals(alias.getProfileID(), masterProfile);
+            Assert.assertEquals(masterProfile, alias.getProfileID());
 
             // control the merged profile do not exist anymore
             Assert.assertNull(persistenceService.load(mergedProfile, 
Profile.class));
diff --git a/itests/src/test/resources/migration/snapshots_repository.zip 
b/itests/src/test/resources/migration/snapshots_repository.zip
index d8af55cd4..58060633b 100644
Binary files a/itests/src/test/resources/migration/snapshots_repository.zip and 
b/itests/src/test/resources/migration/snapshots_repository.zip differ
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 4d923ad88..4b191e5d2 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -322,6 +322,14 @@ From 
https://github.com/apache/unomi/blob/unomi-1.5.x/plugins/baseplugin/src/mai
         "storeInSession": false
       },
       "type": "setPropertyAction"
+    },
+    {
+      "parameterValues": {
+        "setPropertyName": "properties.totalNbOfVisits",
+        "setPropertyValue": "script::profile.properties.?totalNbOfVisits != 
null ? (profile.properties.totalNbOfVisits + 1) : 1",
+        "storeInSession": false
+      },
+      "type": "setPropertyAction"
     }
   ]
 
@@ -344,6 +352,7 @@ Default allowed MVEL expressions (from 
https://github.com/apache/unomi/blob/unom
   "\\QminimumDuration*1000\\E",
   "\\QmaximumDuration*1000\\E",
   "\\Qprofile.properties.?nbOfVisits != null ? (profile.properties.nbOfVisits 
+ 1) : 1\\E",
+  "\\Qprofile.properties.?totalNbOfVisits != null ? 
(profile.properties.totalNbOfVisits + 1) : 1\\E",
   "\\Qsession != null ? session.size + 1 : 0\\E",
   "\\Q'properties.optimizationTest_'+event.target.itemId\\E",
   "\\Qevent.target.properties.variantId\\E",
diff --git a/manual/src/main/asciidoc/datamodel.adoc 
b/manual/src/main/asciidoc/datamodel.adoc
index e45748f47..9c4359b5c 100755
--- a/manual/src/main/asciidoc/datamodel.adoc
+++ b/manual/src/main/asciidoc/datamodel.adoc
@@ -284,6 +284,7 @@ image::profile.png[]
         "lastName": "Galileo",
         "preferredLanguage": "en",
         "nbOfVisits": 2,
+        "totalNbOfVisits": 5,
         "gender": "male",
         "jobTitle": "Vice President",
         "lastVisit": "2020-01-31T08:41:22Z",
@@ -525,6 +526,7 @@ The visitor’s location is also resolve based on the IP 
address that was used t
         "properties": {
             "preferredLanguage": "en",
             "nbOfVisits": 2,
+            "totalNbOfVisits": 5,
             "gender": "male",
             "jobTitle": "Vice President",
             "lastVisit": "2020-01-31T08:41:22Z",
diff --git 
a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
 
b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
index 6e650a178..f54604e3a 100644
--- 
a/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
+++ 
b/persistence-elasticsearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
@@ -35,6 +35,9 @@
         "nbOfVisits": {
           "type": "long"
         },
+        "totalNbOfVisits": {
+          "type": "long"
+        },
         "interests": {
           "type": "nested"
         }
diff --git 
a/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
 
b/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
index 6e650a178..f54604e3a 100644
--- 
a/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
+++ 
b/persistence-opensearch/core/src/main/resources/META-INF/cxs/mappings/profile.json
@@ -35,6 +35,9 @@
         "nbOfVisits": {
           "type": "long"
         },
+        "totalNbOfVisits": {
+          "type": "long"
+        },
         "interests": {
           "type": "nested"
         }
diff --git 
a/plugins/baseplugin/src/main/resources/META-INF/cxs/expressions/mvel.json 
b/plugins/baseplugin/src/main/resources/META-INF/cxs/expressions/mvel.json
index 6c0a5a014..245deaf58 100644
--- a/plugins/baseplugin/src/main/resources/META-INF/cxs/expressions/mvel.json
+++ b/plugins/baseplugin/src/main/resources/META-INF/cxs/expressions/mvel.json
@@ -5,9 +5,10 @@
   "\\QminimumDuration*1000\\E",
   "\\QmaximumDuration*1000\\E",
   "\\Qprofile.properties.?nbOfVisits != null ? (profile.properties.nbOfVisits 
+ 1) : 1\\E",
+  "\\Qprofile.properties.?totalNbOfVisits != null ? 
(profile.properties.totalNbOfVisits + 1) : 1\\E",
   "\\Qsession != null ? session.size + 1 : 0\\E",
   "\\Q'properties.optimizationTest_'+event.target.itemId\\E",
   "\\Qevent.target.properties.variantId\\E",
   "\\Qprofile.properties.?systemProperties.goals.\\E[\\w\\_]*\\QReached != 
null ? (profile.properties.systemProperties.goals.\\E[\\w\\_]*\\QReached) : 
'now'\\E",
   "\\Qprofile.properties.?systemProperties.campaigns.\\E[\\w\\_]*\\QEngaged != 
null ? (profile.properties.systemProperties.campaigns.\\E[\\w\\_]*\\QEngaged) : 
'now'\\E"
-]
\ No newline at end of file
+]
diff --git 
a/plugins/baseplugin/src/main/resources/META-INF/cxs/rules/sessionAssigned.json 
b/plugins/baseplugin/src/main/resources/META-INF/cxs/rules/sessionAssigned.json
index d453f2149..a9714cd24 100644
--- 
a/plugins/baseplugin/src/main/resources/META-INF/cxs/rules/sessionAssigned.json
+++ 
b/plugins/baseplugin/src/main/resources/META-INF/cxs/rules/sessionAssigned.json
@@ -40,6 +40,13 @@
         "storeInSession": false
       },
       "type": "incrementPropertyAction"
+    },
+    {
+      "parameterValues": {
+        "propertyName": "totalNbOfVisits",
+        "storeInSession": false
+      },
+      "type": "incrementPropertyAction"
     }
   ]
 }
diff --git a/pom.xml b/pom.xml
index 109ec9335..25360797d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -64,8 +64,8 @@
         <maven.compiler.release>${java.version}</maven.compiler.release>
 
         <karaf.version>4.4.8</karaf.version>
-        <elasticsearch.version>9.1.3</elasticsearch.version>
-        <elasticsearch.test.version>9.1.3</elasticsearch.test.version>
+        <elasticsearch.version>9.1.4</elasticsearch.version>
+        <elasticsearch.test.version>9.2.1</elasticsearch.test.version>
         <opensearch.version>3.0.0</opensearch.version>
         <opensearch.rest.client.version>3.0.0</opensearch.rest.client.version>
         <httpclient5.version>5.2.1</httpclient5.version>
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
 
b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
index 7dd5db65f..9673a707c 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/profiles/ProfileServiceImpl.java
@@ -29,17 +29,13 @@ import org.apache.unomi.api.services.DefinitionsService;
 import org.apache.unomi.api.services.ProfileService;
 import org.apache.unomi.api.services.SchedulerService;
 import org.apache.unomi.api.services.SegmentService;
+import org.apache.unomi.api.utils.ParserHelper;
 import org.apache.unomi.persistence.spi.CustomObjectMapper;
 import org.apache.unomi.persistence.spi.PersistenceService;
 import org.apache.unomi.persistence.spi.PropertyHelper;
-import org.apache.unomi.api.utils.ParserHelper;
+import org.apache.unomi.persistence.spi.aggregate.TermsAggregate;
 import org.apache.unomi.services.sorts.ControlGroupPersonalizationStrategy;
-import org.osgi.framework.Bundle;
-import org.osgi.framework.BundleContext;
-import org.osgi.framework.BundleEvent;
-import org.osgi.framework.InvalidSyntaxException;
-import org.osgi.framework.ServiceReference;
-import org.osgi.framework.SynchronousBundleListener;
+import org.osgi.framework.*;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -54,13 +50,15 @@ import static 
org.apache.unomi.persistence.spi.CustomObjectMapper.getObjectMappe
 
 public class ProfileServiceImpl implements ProfileService, 
SynchronousBundleListener {
 
+    private static final String DECREMENT_NB_OF_VISITS_SCRIPT = 
"decNbOfVisits";
+
     /**
      * This class is responsible for storing property types and permits 
optimized access to them.
      * In order to assure data consistency, thread-safety and performance, 
this class is immutable and every operation on
      * property types requires creating a new instance (copy-on-write).
      */
     private static class PropertyTypes {
-        private List<PropertyType> allPropertyTypes;
+        private final List<PropertyType> allPropertyTypes;
         private Map<String, PropertyType> propertyTypesById = new HashMap<>();
         private Map<String, List<PropertyType>> propertyTypesByTags = new 
HashMap<>();
         private Map<String, List<PropertyType>> propertyTypesBySystemTags = 
new HashMap<>();
@@ -161,6 +159,7 @@ public class ProfileServiceImpl implements ProfileService, 
SynchronousBundleList
     }
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(ProfileServiceImpl.class.getName());
+    private static final int NB_OF_VISITS_DECREMENT_BATCH_SIZE = 500;
 
     private BundleContext bundleContext;
 
@@ -373,8 +372,105 @@ public class ProfileServiceImpl implements 
ProfileService, SynchronousBundleList
     @Override
     public void purgeSessionItems(int existsNumberOfDays) {
         if (existsNumberOfDays > 0) {
+            ConditionType sessionPropertyConditionType = 
definitionsService.getConditionType("sessionPropertyCondition");
+            if (sessionPropertyConditionType == null) {
+                LOGGER.error("Could not find sessionPropertyCondition type");
+                return;
+            }
+            Condition timeCondition = new 
Condition(sessionPropertyConditionType);
+            timeCondition.setParameter("propertyName", "timeStamp");
+            timeCondition.setParameter("comparisonOperator", 
"lessThanOrEqualTo");
+            timeCondition.setParameter("propertyValueDateExpr", "now-" + 
existsNumberOfDays + "d");
+
+            TermsAggregate profileIdAggregate = new 
TermsAggregate("profileId");
+            Map<String, Long> impactedProfiles = 
persistenceService.aggregateWithOptimizedQuery(timeCondition, 
profileIdAggregate, Session.ITEM_TYPE);
+            // Remove technical aggregation keys like "_filtered" that are not 
actual profile IDs
+            impactedProfiles.remove("_filtered");
+
             LOGGER.info("Purging: Sessions created since more than {} days", 
existsNumberOfDays);
             persistenceService.purgeTimeBasedItems(existsNumberOfDays, 
Session.class);
+
+            LOGGER.info("Syncing profiles: decrementing nbOfVisits for session 
purge's {} impacted profiles", impactedProfiles.size());
+            this.decrementProfilesNbOfVisits(impactedProfiles);
+        }
+    }
+
+    /**
+     * Decrements the nbOfVisits property for profiles based on a map of 
ProfileId, nbVisits.
+     * Profiles are grouped by decrement value and processed in batches for 
optimal performance.
+     *
+     * @param profilesVisits Map of profile IDs to the number of visits to 
decrement
+     */
+    private void decrementProfilesNbOfVisits(Map<String, Long> profilesVisits) 
{
+        if (profilesVisits == null || profilesVisits.isEmpty()) {
+            LOGGER.info("No profiles to update for nbOfVisits decrement");
+            return;
+        }
+        LOGGER.info("Decrementing nbOfVisits for {} profiles", 
profilesVisits.size());
+
+        Map<Long, List<String>> profilesByDecrement = new TreeMap<>();
+        profilesVisits.forEach((profileId, decrement) -> {
+            if (StringUtils.isNotBlank(profileId) && decrement != null && 
decrement > 0) {
+                profilesByDecrement.computeIfAbsent(decrement, k -> new 
ArrayList<>()).add(profileId);
+            }
+        });
+
+        int totalUpdated = 0;
+
+        for (Map.Entry<Long, List<String>> entry : 
profilesByDecrement.entrySet()) {
+            Long decrementValue = entry.getKey();
+            List<String> profileIds = entry.getValue();
+            LOGGER.debug("Processing {} profiles with decrement value {}", 
profileIds.size(), decrementValue);
+            // Split into batches of NB_OF_VISITS_DECREMENT_BATCH_SIZE to 
avoid too large requests
+            for (int i = 0; i < profileIds.size(); i += 
NB_OF_VISITS_DECREMENT_BATCH_SIZE) {
+                int endIndex = Math.min(i + NB_OF_VISITS_DECREMENT_BATCH_SIZE, 
profileIds.size());
+                List<String> batchProfileIds = profileIds.subList(i, endIndex);
+                if (applyNbOfVisitsDecrementForBatch(batchProfileIds, 
decrementValue)) {
+                    totalUpdated += batchProfileIds.size();
+                }
+            }
+        }
+        LOGGER.info("Successfully decremented nbOfVisits for {} profiles", 
totalUpdated);
+    }
+
+    /**
+     * Applies the nbOfVisits decrement for a batch of profiles with the same 
decrement value.
+     *
+     * @param profileIds List of profile IDs to update
+     * @param decrementValue The value to decrement from nbOfVisits
+     * @return true if the update was successful
+     */
+    private boolean applyNbOfVisitsDecrementForBatch(List<String> profileIds, 
Long decrementValue) {
+        if (profileIds == null || profileIds.isEmpty() || decrementValue == 
null || decrementValue <= 0) {
+            return false;
+        }
+
+        try {
+            long startTime = System.currentTimeMillis();
+
+            String[] scripts = new String[1];
+            Map<String, Object>[] scriptParams = new HashMap[1];
+            Condition[] conditions = new Condition[1];
+
+            conditions[0] = new Condition();
+            
conditions[0].setConditionType(definitionsService.getConditionType("profilePropertyCondition"));
+            conditions[0].setParameter("propertyName", "itemId");
+            conditions[0].setParameter("comparisonOperator", "in");
+            conditions[0].setParameter("propertyValues", new 
ArrayList<>(profileIds));
+            scriptParams[0] = new HashMap<>();
+            scriptParams[0].put("decrementValue", decrementValue);
+            scripts[0] = DECREMENT_NB_OF_VISITS_SCRIPT;
+
+            boolean updated = 
persistenceService.updateWithQueryAndStoredScript(Profile.class, scripts, 
scriptParams, conditions);
+            if (!updated) {
+                LOGGER.error("Failed to decrement nbOfVisits for {} profiles 
with decrement value {}", profileIds.size(), decrementValue);
+            } else {
+                LOGGER.info("Updated nbOfVisits for {} profiles in {}ms", 
profileIds.size(), System.currentTimeMillis() - startTime);
+            }
+            return updated;
+        } catch (Exception e) {
+            LOGGER.error("Error while decrementing nbOfVisits for batch of {} 
profiles", profileIds.size(), e);
+            return false;
         }
     }
 
@@ -752,7 +848,7 @@ public class ProfileServiceImpl implements ProfileService, 
SynchronousBundleList
 
         profilesToMerge = filteredProfilesToMerge;
 
-        Set<String> allProfileProperties = new LinkedHashSet<>();
+        Set<String> allProfileProperties = new LinkedHashSet();
         for (Profile profile : profilesToMerge) {
             final Set<String> flatNestedPropertiesKeys = 
PropertyHelper.flatten(profile.getProperties()).keySet();
             allProfileProperties.addAll(flatNestedPropertiesKeys);
diff --git 
a/services/src/main/resources/META-INF/cxs/painless/decNbOfVisits.painless 
b/services/src/main/resources/META-INF/cxs/painless/decNbOfVisits.painless
new file mode 100644
index 000000000..f0666ab7f
--- /dev/null
+++ b/services/src/main/resources/META-INF/cxs/painless/decNbOfVisits.painless
@@ -0,0 +1,30 @@
+/*
+ * 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.
+ */
+
+ if (ctx._source.properties == null) {
+  ctx._source.properties = new HashMap();
+}
+def current = ctx._source.properties.nbOfVisits;
+if (current == null) {
+  current = 0;
+}
+long currentValue = (current instanceof Number) ? current.longValue() : 
Long.parseLong(current.toString());
+long newValue = currentValue - params.decrementValue;
+if (newValue < 0) {
+  newValue = 0;
+}
+ctx._source.properties.nbOfVisits = newValue;
diff --git 
a/services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
 
b/services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
index 25180f15f..2eede0d9e 100644
--- 
a/services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
+++ 
b/services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
@@ -22,4 +22,4 @@
     "mergeStrategy": "addMergeStrategy",
     "rank": "101.0",
     "protected": true
-}
\ No newline at end of file
+}
diff --git 
a/services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
 
b/services/src/main/resources/META-INF/cxs/properties/profiles/system/totalNbOfVisits.json
similarity index 86%
copy from 
services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
copy to 
services/src/main/resources/META-INF/cxs/properties/profiles/system/totalNbOfVisits.json
index 25180f15f..1d1b42a60 100644
--- 
a/services/src/main/resources/META-INF/cxs/properties/profiles/system/nbOfVisits.json
+++ 
b/services/src/main/resources/META-INF/cxs/properties/profiles/system/totalNbOfVisits.json
@@ -1,7 +1,7 @@
 {
     "metadata": {
-      "id": "nbOfVisits",
-      "name": "Number of visits",
+      "id": "totalNbOfVisits",
+      "name": "Total number of visits",
       "systemTags": [
         "properties",
         "profileProperties",
@@ -20,6 +20,6 @@
        {"key":"100_*", "from" : 100 }
     ],
     "mergeStrategy": "addMergeStrategy",
-    "rank": "101.0",
+    "rank": "101.1",
     "protected": true
-}
\ No newline at end of file
+}
diff --git a/services/src/main/resources/messages_de.properties 
b/services/src/main/resources/messages_de.properties
index 40f4807c5..3827d853e 100644
--- a/services/src/main/resources/messages_de.properties
+++ b/services/src/main/resources/messages_de.properties
@@ -56,6 +56,7 @@ profilesProperty.linkedInId=LinkedIn ID
 profilesProperty.mergedWith=Zusammengef�hrt mit
 profilesProperty.name=Name
 profilesProperty.nbOfVisits=Anzahl Besuche
+profilesProperty.totalNbOfVisits=Gesamtzahl der Besuche
 profilesProperty.phoneNumber=Telefonnummer
 profilesProperty.twitterId=Twitter ID
 profilesProperty.usageRate=Nutzungsrate
diff --git a/services/src/main/resources/messages_en.properties 
b/services/src/main/resources/messages_en.properties
index 62d6ba3b2..9334e91de 100644
--- a/services/src/main/resources/messages_en.properties
+++ b/services/src/main/resources/messages_en.properties
@@ -60,6 +60,7 @@ profilesProperty.maritalStatus=Marital status
 profilesProperty.name=Name
 profilesProperty.nationality=Nationality
 profilesProperty.nbOfVisits=Number of visits
+profilesProperty.totalNbOfVisits=Total number of visits
 profilesProperty.phoneNumber=Phone number
 profilesProperty.title=Title
 profilesProperty.twitterId=Twitter ID
diff --git 
a/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-00-fixProfileNbOfVisits.groovy
 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-00-fixProfileNbOfVisits.groovy
new file mode 100644
index 000000000..5dc66b3a8
--- /dev/null
+++ 
b/tools/shell-commands/src/main/resources/META-INF/cxs/migration/migrate-3.1.0-00-fixProfileNbOfVisits.groovy
@@ -0,0 +1,98 @@
+import groovy.json.JsonSlurper
+import org.apache.unomi.shell.migration.service.MigrationContext
+import org.apache.unomi.shell.migration.utils.HttpUtils
+import org.apache.unomi.shell.migration.utils.MigrationUtils
+
+/*
+ * 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.
+ */
+
+MigrationContext context = migrationContext
+String esAddress = context.getConfigString("esAddress")
+String indexPrefix = context.getConfigString("indexPrefix")
+def jsonSlurper = new JsonSlurper()
+
+context.performMigrationStep("3.1.0-fix-profile-nbOfVisits", () -> {
+    String profileIndex = "${indexPrefix}-profile"
+    String sessionIndex = "${indexPrefix}-session-*"
+
+    context.printMessage("Starting migration to fix Profile.nbOfVisits field")
+
+    // First step: Copy nbOfVisits to totalNbOfVisits for all profiles
+    context.printMessage("Step 1: Copying nbOfVisits to totalNbOfVisits")
+    String copyScript = MigrationUtils.getFileWithoutComments(bundleContext, 
"requestBody/3.1.0/copy_nbOfVisits_to_totalNbOfVisits.painless")
+    String copyRequestBody = MigrationUtils.resourceAsString(bundleContext, 
"requestBody/3.1.0/profile_copy_nbOfVisits_request.json")
+    MigrationUtils.updateByQuery(context.getHttpClient(), esAddress, 
profileIndex, copyRequestBody.replace('#painless', copyScript))
+
+    context.printMessage("Step 1 completed: nbOfVisits copied to 
totalNbOfVisits")
+
+    // Second step: Update nbOfVisits with actual session count for each 
profile
+    context.printMessage("Step 2: Updating nbOfVisits with actual session 
count")
+
+    String scrollQuery = MigrationUtils.resourceAsString(bundleContext, 
"requestBody/3.1.0/profile_scroll_query.json")
+    int profilesProcessed = 0
+    int profilesUpdated = 0
+
+    // Scroll through all profiles
+    MigrationUtils.scrollQuery(context.getHttpClient(), esAddress, 
"/${profileIndex}/_search", scrollQuery, "5m", (hits) -> {
+        def hitsArray = jsonSlurper.parseText(hits)
+        StringBuilder bulkUpdate = new StringBuilder()
+
+        hitsArray.each { hit ->
+            String profileId = hit._id
+            profilesProcessed++
+
+            if (profilesProcessed % 10000 == 0) {
+                context.printMessage("Processed ${profilesProcessed} 
profiles...")
+            }
+
+            // Count sessions for this profile
+            String countQuery = MigrationUtils.resourceAsString(bundleContext, 
"requestBody/3.1.0/count_sessions_by_profile.json")
+            String countQueryWithProfileId = countQuery.replace('#profileId', 
profileId)
+
+            try {
+                def countResponse = jsonSlurper.parseText(
+                    HttpUtils.executePostRequest(context.getHttpClient(), 
esAddress + "/${sessionIndex}/_count", countQueryWithProfileId, null)
+                )
+
+                int sessionCount = countResponse.count
+
+                // Prepare bulk update
+                
bulkUpdate.append('{"update":{"_id":"').append(profileId).append('","_index":"').append(profileIndex).append('"}}\n')
+                
bulkUpdate.append('{"doc":{"properties":{"nbOfVisits":').append(sessionCount).append('}}}\n')
+
+                profilesUpdated++
+            } catch (Exception e) {
+                context.printMessage("Error counting sessions for profile 
${profileId}: ${e.message}")
+            }
+        }
+
+        // Execute bulk update if we have updates
+        if (bulkUpdate.length() > 0) {
+            try {
+                MigrationUtils.bulkUpdate(context.getHttpClient(), esAddress + 
"/_bulk", bulkUpdate.toString())
+            } catch (Exception e) {
+                context.printMessage("Error during bulk update: ${e.message}")
+            }
+        }
+    })
+
+    // Refresh the profile index
+    HttpUtils.executePostRequest(context.getHttpClient(), esAddress + 
"/${profileIndex}/_refresh", null, null)
+
+    context.printMessage("Migration completed: Processed ${profilesProcessed} 
profiles, updated ${profilesUpdated} profiles")
+})
+
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/3.1.0/copy_nbOfVisits_to_totalNbOfVisits.painless
 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/copy_nbOfVisits_to_totalNbOfVisits.painless
new file mode 100644
index 000000000..52aec8568
--- /dev/null
+++ 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/copy_nbOfVisits_to_totalNbOfVisits.painless
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+
+/* Copy nbOfVisits to totalNbOfVisits for all profiles */
+
+if (ctx._source.properties != null && 
ctx._source.properties.containsKey('nbOfVisits')) {
+    if (ctx._source.properties.totalNbOfVisits == null) {
+        ctx._source.properties.put('totalNbOfVisits', 
ctx._source.properties.nbOfVisits);
+    }
+}
+
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/3.1.0/count_sessions_by_profile.json
 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/count_sessions_by_profile.json
new file mode 100644
index 000000000..4839ab5fe
--- /dev/null
+++ 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/count_sessions_by_profile.json
@@ -0,0 +1,19 @@
+{
+  "query": {
+    "bool": {
+      "must": [
+        {
+          "match": {
+            "itemType": "session"
+          }
+        },
+        {
+          "term": {
+            "profileId.keyword": "#profileId"
+          }
+        }
+      ]
+    }
+  }
+}
+
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/3.1.0/profile_copy_nbOfVisits_request.json
 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/profile_copy_nbOfVisits_request.json
new file mode 100644
index 000000000..3b417984b
--- /dev/null
+++ 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/profile_copy_nbOfVisits_request.json
@@ -0,0 +1,25 @@
+{
+  "script": {
+    "source": "#painless",
+    "lang": "painless"
+  },
+  "query": {
+    "bool": {
+      "must": [
+        {
+          "match": {
+            "itemType": "profile"
+          }
+        }
+      ],
+      "filter": [
+        {
+          "exists": {
+            "field": "properties.nbOfVisits"
+          }
+        }
+      ]
+    }
+  }
+}
+
diff --git 
a/tools/shell-commands/src/main/resources/requestBody/3.1.0/profile_scroll_query.json
 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/profile_scroll_query.json
new file mode 100644
index 000000000..34d9f1ee2
--- /dev/null
+++ 
b/tools/shell-commands/src/main/resources/requestBody/3.1.0/profile_scroll_query.json
@@ -0,0 +1,23 @@
+{
+  "size": 1000,
+  "query": {
+    "bool": {
+      "must": [
+        {
+          "match": {
+            "itemType": "profile"
+          }
+        }
+      ],
+      "filter": [
+        {
+          "exists": {
+            "field": "properties.nbOfVisits"
+          }
+        }
+      ]
+    }
+  },
+  "_source": ["itemId"]
+}
+

Reply via email to