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),