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

shuber pushed a commit to branch UNOMI-897-groovy-fixes
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit b6f3781ea756f4d828be87c2389be0a2bb13250d
Author: Serge Huber <shu...@jahia.com>
AuthorDate: Sat Jul 12 21:03:31 2025 +0200

      UNOMI-897: Fix data corruption and performance issues in 
GroovyActionDispatcher
    
      - Fixed data corruption issue by removing shared GroovyShell instance 
with separate script instances for separate variables
      - Improved performance by moving to pre-compilation of scripts to avoid 
on-the-fly and previously systematic compilation of Groovy scripts
      - Add ScriptMetadata class for script management
---
 .../unomi/groovy/actions/ScriptMetadata.java       | 156 +++++++++++++++++++++
 .../unomi/itests/GroovyActionsServiceIT.java       |  18 +--
 2 files changed, 165 insertions(+), 9 deletions(-)

diff --git 
a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/ScriptMetadata.java
 
b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/ScriptMetadata.java
new file mode 100644
index 000000000..724dd9098
--- /dev/null
+++ 
b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/ScriptMetadata.java
@@ -0,0 +1,156 @@
+/*
+ * 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.groovy.actions;
+
+import groovy.lang.Script;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * Metadata container for compiled Groovy scripts with hash-based change 
detection.
+ * <p>
+ * This class encapsulates all metadata associated with a compiled Groovy 
script,
+ * including content hash for efficient change detection and the compiled class
+ * for direct execution without recompilation.
+ * </p>
+ * 
+ * <p>
+ * Thread Safety: This class is immutable and thread-safe. All fields are final
+ * and the class provides no methods to modify its state after construction.
+ * </p>
+ * 
+ * @since 2.7.0
+ */
+public final class ScriptMetadata {
+    private final String actionName;
+    private final String scriptContent;
+    private final String contentHash;
+    private final long lastModified;
+    private final Class<? extends Script> compiledClass;
+    
+    /**
+     * Constructs a new ScriptMetadata instance.
+     * 
+     * @param actionName the unique name/identifier of the action
+     * @param scriptContent the raw Groovy script content
+     * @param compiledClass the compiled Groovy script class
+     * @throws IllegalArgumentException if any parameter is null
+     */
+    public ScriptMetadata(String actionName, String scriptContent, Class<? 
extends Script> compiledClass) {
+        if (actionName == null) {
+            throw new IllegalArgumentException("Action name cannot be null");
+        }
+        if (scriptContent == null) {
+            throw new IllegalArgumentException("Script content cannot be 
null");
+        }
+        if (compiledClass == null) {
+            throw new IllegalArgumentException("Compiled class cannot be 
null");
+        }
+        
+        this.actionName = actionName;
+        this.scriptContent = scriptContent;
+        this.contentHash = calculateHash(scriptContent);
+        this.lastModified = System.currentTimeMillis();
+        this.compiledClass = compiledClass;
+    }
+    
+    /**
+     * Calculates SHA-256 hash of the given content.
+     * 
+     * @param content the content to hash
+     * @return Base64 encoded SHA-256 hash
+     * @throws RuntimeException if SHA-256 algorithm is not available
+     */
+    private String calculateHash(String content) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            byte[] hash = 
digest.digest(content.getBytes(StandardCharsets.UTF_8));
+            return Base64.getEncoder().encodeToString(hash);
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException("SHA-256 algorithm not available", e);
+        }
+    }
+    
+    /**
+     * Determines if the script content has changed compared to new content.
+     * <p>
+     * This method uses SHA-256 hash comparison for efficient change detection
+     * without storing or comparing the full script content.
+     * </p>
+     * 
+     * @param newContent the new script content to compare against
+     * @return {@code true} if content has changed, {@code false} if unchanged
+     * @throws IllegalArgumentException if newContent is null
+     */
+    public boolean hasChanged(String newContent) {
+        if (newContent == null) {
+            throw new IllegalArgumentException("New content cannot be null");
+        }
+        return !contentHash.equals(calculateHash(newContent));
+    }
+    
+    /**
+     * Returns the action name/identifier.
+     * 
+     * @return the action name, never null
+     */
+    public String getActionName() { 
+        return actionName; 
+    }
+    
+    /**
+     * Returns the original script content.
+     * 
+     * @return the script content, never null
+     */
+    public String getScriptContent() { 
+        return scriptContent; 
+    }
+    
+    /**
+     * Returns the SHA-256 hash of the script content.
+     * 
+     * @return Base64 encoded content hash, never null
+     */
+    public String getContentHash() { 
+        return contentHash; 
+    }
+    
+    /**
+     * Returns the timestamp when this metadata was created.
+     * 
+     * @return creation timestamp in milliseconds since epoch
+     */
+    public long getLastModified() { 
+        return lastModified; 
+    }
+    
+    /**
+     * Returns the compiled Groovy script class.
+     * <p>
+     * This class can be used to create new script instances for execution
+     * without requiring recompilation.
+     * </p>
+     * 
+     * @return the compiled script class, never null
+     */
+    public Class<? extends Script> getCompiledClass() { 
+        return compiledClass; 
+    }
+}
\ No newline at end of file
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/GroovyActionsServiceIT.java 
b/itests/src/test/java/org/apache/unomi/itests/GroovyActionsServiceIT.java
index 85d7d3385..5af563e3d 100644
--- a/itests/src/test/java/org/apache/unomi/itests/GroovyActionsServiceIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/GroovyActionsServiceIT.java
@@ -17,7 +17,7 @@
 
 package org.apache.unomi.itests;
 
-import groovy.lang.GroovyCodeSource;
+import groovy.lang.Script;
 import org.apache.commons.io.IOUtils;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Profile;
@@ -95,7 +95,7 @@ public class GroovyActionsServiceIT extends BaseIT {
         groovyActionsService.save(UPDATE_ADDRESS_ACTION, 
loadGroovyAction(UPDATE_ADDRESS_ACTION_GROOVY_FILE));
 
         keepTrying("Failed waiting for the creation of the GroovyAction for 
the trigger action test",
-                () -> 
groovyActionsService.getGroovyCodeSource(UPDATE_ADDRESS_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+                () -> 
groovyActionsService.getCompiledScript(UPDATE_ADDRESS_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
 
         ActionType actionType = keepTrying("Failed waiting for the creation of 
the GroovyAction for trigger action test",
                 () -> 
definitionsService.getActionType(UPDATE_ADDRESS_GROOVY_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
@@ -114,10 +114,10 @@ public class GroovyActionsServiceIT extends BaseIT {
         ActionType actionType = keepTrying("Failed waiting for the creation of 
the GroovyAction for the save test",
                 () -> 
definitionsService.getActionType(UPDATE_ADDRESS_GROOVY_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
 
-        GroovyCodeSource groovyCodeSource = keepTrying("Failed waiting for the 
creation of the GroovyAction for the save test",
-                () -> 
groovyActionsService.getGroovyCodeSource(UPDATE_ADDRESS_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+        Class<? extends Script> compiledScript = keepTrying("Failed waiting 
for the creation of the GroovyAction for the save test",
+                () -> 
groovyActionsService.getCompiledScript(UPDATE_ADDRESS_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
 
-        Assert.assertEquals(UPDATE_ADDRESS_ACTION, 
groovyActionsService.getGroovyCodeSource(UPDATE_ADDRESS_ACTION).getName());
+        Assert.assertEquals(UPDATE_ADDRESS_ACTION, 
compiledScript.getSimpleName());
 
         
Assert.assertTrue(actionType.getMetadata().getId().contains(UPDATE_ADDRESS_GROOVY_ACTION));
         Assert.assertEquals(2, 
actionType.getMetadata().getSystemTags().size());
@@ -133,14 +133,14 @@ public class GroovyActionsServiceIT extends BaseIT {
     public void testGroovyActionsService_removeGroovyAction() throws 
IOException, InterruptedException {
         groovyActionsService.save(UPDATE_ADDRESS_ACTION, 
loadGroovyAction(UPDATE_ADDRESS_ACTION_GROOVY_FILE));
 
-        GroovyCodeSource groovyCodeSource = keepTrying("Failed waiting for the 
creation of the GroovyAction for the remove test",
-                () -> 
groovyActionsService.getGroovyCodeSource(UPDATE_ADDRESS_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+        Class<? extends Script> compiledScript = keepTrying("Failed waiting 
for the creation of the GroovyAction for the remove test",
+                () -> 
groovyActionsService.getCompiledScript(UPDATE_ADDRESS_ACTION), 
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
 
-        Assert.assertNotNull(groovyCodeSource);
+        Assert.assertNotNull(compiledScript);
 
         groovyActionsService.remove(UPDATE_ADDRESS_ACTION);
 
-        waitForNullValue("Groovy action is still present", () -> 
groovyActionsService.getGroovyCodeSource(UPDATE_ADDRESS_ACTION),
+        waitForNullValue("Groovy action is still present", () -> 
groovyActionsService.getCompiledScript(UPDATE_ADDRESS_ACTION),
                 DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
 
         waitForNullValue("Action type is still present", () -> 
definitionsService.getActionType(UPDATE_ADDRESS_GROOVY_ACTION),

Reply via email to