From 8204269f010aa25adf618c856aa2d3b0f1ace217 Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Thu, 11 Jun 2026 16:33:08 +0200 Subject: [PATCH 1/3] [NAE-2449] ActionDelegate memory leak causes OOM Refactor action handling logic and improve memory management in FieldActionsRunner and related classes. --- .../logic/action/ActionDelegate.groovy | 17 +++++++++ .../logic/action/FieldActionsRunner.groovy | 37 +++++++++++++++---- .../engine/event/GroovyShellFactory.java | 15 +++++--- 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy index 5ff63dcfaea..a9cc574184b 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy @@ -249,6 +249,23 @@ class ActionDelegate { this.Plugin = new PluginHolder() } + void clearAfterExecution() { + this.action = null + this.useCase = null + this.task = null + this.actionsRunner = null + + this.map?.clear() + this.map = null + + this.outcomes?.clear() + this.outcomes = null + + this.Frontend = null + this.NaeModule = null + this.Plugin = null + } + def initFieldsMap(Map fieldIds) { fieldIds.each { name, id -> set(name, fieldFactory.buildFieldWithoutValidation(useCase, id, null)) diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy index 64b567ab4d1..162f13a9c06 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy @@ -24,7 +24,7 @@ abstract class FieldActionsRunner { private static final Logger log = LoggerFactory.getLogger(FieldActionsRunner.class) @Lookup("actionDelegate") - abstract ActionDelegate getActionDeleget() + abstract ActionDelegate getActionDelegate() @Autowired private IOrsrService orsrService @@ -53,6 +53,7 @@ abstract class FieldActionsRunner { log.debug("Action: $action") def code = getActionCode(action, functions) + List outcomes final ActionStartEvent actionStart = new ActionStartEvent(action) try { publisher.publishEvent(actionStart) @@ -63,8 +64,11 @@ abstract class FieldActionsRunner { log.error("Action: $action.definition") publisher.publishEvent(new ActionStopEvent(action, actionStart, false)) throw e + } finally { + outcomes = new ArrayList<>(((ActionDelegate) code.delegate).outcomes) + cleanUp(code) } - return ((ActionDelegate) code.delegate).outcomes + return outcomes } Closure getActionCode(com.netgrif.application.engine.objects.petrinet.domain.dataset.logic.action.Action action, List functions, boolean shouldRewriteCachedActions = false) { @@ -72,19 +76,19 @@ abstract class FieldActionsRunner { } Closure getActionCode(Closure code, List functions) { - def actionDelegate = getActionDeleget() + def actionDelegate = getActionDelegate() actionsCacheService.getCachedFunctions(functions).each { actionDelegate.metaClass."${it.function.name}" << it.code } actionsCacheService.getGlobalFunctionsCache().each { entry -> - def namespace = [:] + def globalFunctions = [:] entry.getValue().each { - namespace["${it.function.name}"] = it.code.rehydrate(actionDelegate, it.code.owner, it.code.thisObject) + globalFunctions["${it.function.name}"] = prepareCode(it.code, actionDelegate) } - actionDelegate.metaClass."${entry.key}" = namespace + actionDelegate.metaClass."${entry.key}" = globalFunctions } - return code.rehydrate(actionDelegate, code.owner, code.thisObject) + return prepareCode(code, actionDelegate) } void addToCache(String key, Object value) { @@ -107,4 +111,23 @@ abstract class FieldActionsRunner { return orsrService } + private void clearGroovyMetaClass(Object... targets) { + targets.each { target -> + if (target == null) { + return + } + GroovySystem.getMetaClassRegistry().removeMetaClass(target.getClass()) + } + } + + private Closure prepareCode(Closure closure, Object delegate) { + Closure hydratedClosure = closure.rehydrate(delegate, closure.owner, closure.thisObject) + hydratedClosure.setResolveStrategy(Closure.DELEGATE_FIRST) + return hydratedClosure + } + + private void cleanUp(Closure code) { + ((ActionDelegate) code.delegate).clearAfterExecution() + clearGroovyMetaClass(code) + } } diff --git a/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java b/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java index 9a392c16ed8..db733bf9ded 100644 --- a/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java +++ b/application-engine/src/main/java/com/netgrif/application/engine/event/GroovyShellFactory.java @@ -19,16 +19,21 @@ public class GroovyShellFactory implements IGroovyShellFactory { @Autowired private CompilerConfiguration configuration; + private GroovyShell shell; + @Override public GroovyShell getGroovyShell() { - ImportCustomizer importCustomizer = new ImportCustomizer(); + if (shell == null) { + ImportCustomizer importCustomizer = new ImportCustomizer(); - Set classNames = findAllClassesUsingClassLoader("com.netgrif.application.engine.workflow.domain"); - importCustomizer.addImports(classNames.toArray(new String[0])); + Set classNames = findAllClassesUsingClassLoader("com.netgrif.application.engine.workflow.domain"); + importCustomizer.addImports(classNames.toArray(new String[0])); - configuration.addCompilationCustomizers(importCustomizer); + configuration.addCompilationCustomizers(importCustomizer); - return new GroovyShell(this.getClass().getClassLoader(), new groovy.lang.Binding(), this.configuration); + shell = new GroovyShell(this.getClass().getClassLoader(), new groovy.lang.Binding(), this.configuration); + } + return shell; } private Set findAllClassesUsingClassLoader(String packageName) { From d71fcbfa650e2703e32e629133fe63e464494c04 Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Thu, 11 Jun 2026 17:43:03 +0200 Subject: [PATCH 2/3] [NAE-2449] ActionDelegate memory leak causes OOM Introduce FunctionExpando as a lightweight proxy for scoped global functions, delegating property and method access to the parent ActionDelegate while rehydrating cached function closures only when invoked. Replace eager global function rehydration in FieldActionsRunner with lazy FunctionExpando-based binding to reduce heap usage during action preparation. --- .../logic/action/FieldActionsRunner.groovy | 9 +- .../action/functions/FunctionExpando.groovy | 176 ++++++++++++++++++ 2 files changed, 181 insertions(+), 4 deletions(-) create mode 100644 application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy index 162f13a9c06..f2c8de2055d 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy @@ -5,6 +5,7 @@ import com.netgrif.application.engine.business.orsr.IOrsrService import com.netgrif.application.engine.importer.service.FieldFactory import com.netgrif.application.engine.objects.event.events.event.ActionStartEvent import com.netgrif.application.engine.objects.event.events.event.ActionStopEvent +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.functions.FunctionExpando import com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService import com.netgrif.application.engine.objects.petrinet.domain.Function import com.netgrif.application.engine.objects.workflow.domain.Case @@ -79,14 +80,14 @@ abstract class FieldActionsRunner { def actionDelegate = getActionDelegate() actionsCacheService.getCachedFunctions(functions).each { - actionDelegate.metaClass."${it.function.name}" << it.code + actionDelegate."${it.function.name}" = it.code } actionsCacheService.getGlobalFunctionsCache().each { entry -> - def globalFunctions = [:] + def functionExpando = new FunctionExpando(actionDelegate) entry.getValue().each { - globalFunctions["${it.function.name}"] = prepareCode(it.code, actionDelegate) + functionExpando."${it.function.name}" = it.code } - actionDelegate.metaClass."${entry.key}" = globalFunctions + actionDelegate."${entry.key}" = functionExpando } return prepareCode(code, actionDelegate) } diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy new file mode 100644 index 00000000000..58335db8631 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy @@ -0,0 +1,176 @@ +package com.netgrif.application.engine.petrinet.domain.dataset.logic.action.functions + +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate + + +/** + * A specialized Expando implementation that provides memory-efficient attachment of global scoped functions + * from business processes to an {@link ActionDelegate}. + *

+ * This class solves a critical memory optimization problem in the execution of process actions. Global functions + * defined in processes need to be accessible during action execution, but storing fully rehydrated closures + * for every function would consume significant heap space, especially in systems with many processes and functions. + *

+ *

+ * Instead of rehydrating all global functions for each action execution, this class acts as a lightweight proxy + * that: + *

    + *
  • Stores function closures as properties on the Expando metaclass
  • + *
  • Delegates method calls to either stored function closures or the parent ActionDelegate
  • + *
  • Delegates property access to the parent ActionDelegate
  • + *
  • Minimizes heap usage by avoiding unnecessary closure rehydration
  • + *
+ *

+ *

+ * The typical usage pattern is: + *

    + *
  1. Global functions are cached as closures in {@link com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService}
  2. + *
  3. During action execution, a FunctionExpando instance is created for each scope (namespace) of global functions
  4. + *
  5. Function closures are attached to the FunctionExpando's metaclass
  6. + *
  7. When a function is invoked, methodMissing intercepts the call and executes the cached closure
  8. + *
  9. When a property is accessed, propertyMissing delegates to the parent ActionDelegate
  10. + *
+ *

+ *

+ * This design allows global functions to be shared across multiple action executions without rehydration, + * significantly reducing memory footprint and improving performance. + *

+ * + * @see ActionDelegate* @see com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService* @see com.netgrif.application.engine.workflow.domain.CachedFunction + */ +class FunctionExpando extends Expando { + + /** + * Reference to the parent ActionDelegate that provides the execution context for actions. + *

+ * This delegate is used to: + *

    + *
  • Access case and task data during function execution
  • + *
  • Delegate method calls that are not function invocations
  • + *
  • Delegate all property read and write operations
  • + *
  • Provide access to the full action execution environment
  • + *
+ *

+ *

+ * By maintaining this reference, the FunctionExpando can act as a transparent proxy that adds + * function invocation capabilities while preserving all original ActionDelegate functionality. + *

+ */ + ActionDelegate parentDelegate + + /** + * Constructs a new FunctionExpando attached to the specified ActionDelegate. + *

+ * The constructor establishes the delegation relationship that allows this FunctionExpando + * to act as a proxy for both function invocations and property access. + *

+ * + * @param parentDelegate the ActionDelegate that provides the execution context and handles + * non-function method calls and all property access + */ + FunctionExpando(ActionDelegate parentDelegate) { + this.parentDelegate = parentDelegate + } + + /** + * Intercepts method calls to provide dynamic resolution of function invocations. + *

+ * This method implements Groovy's dynamic method resolution mechanism to handle calls + * that are not statically defined on this class. It follows this resolution strategy: + *

    + *
  1. Check if a property with the method name exists and is a Closure (cached function)
  2. + *
  3. If found, rehydrate the closure with the parent ActionDelegate as delegate, owner, and thisObject
  4. + *
  5. Set the rehydrated closure's resolve strategy to DELEGATE_FIRST to prioritize ActionDelegate properties
  6. + *
  7. Invoke the rehydrated closure with the provided arguments
  8. + *
  9. Otherwise, delegate the method call to the parent ActionDelegate
  10. + *
+ *

+ *

+ * This approach enables: + *

    + *
  • Execution of cached global functions with proper ActionDelegate context
  • + *
  • Access to case and task data from within function closures
  • + *
  • Transparent fallback to ActionDelegate methods
  • + *
  • Seamless integration of global functions into the action execution context
  • + *
+ *

+ * + * @param name the name of the method being invoked + * @param args the arguments passed to the method (typically an Object array) + * @return the result of the function or method invocation + * @throws groovy.lang.MissingMethodException if neither a cached function nor a delegate method exists + */ + def methodMissing(String name, args) { + def fn = getProperties()[name] + if (fn instanceof Closure) { + def rehydratedClosure = fn.rehydrate(parentDelegate, parentDelegate, parentDelegate) + rehydratedClosure.resolveStrategy = Closure.DELEGATE_FIRST + return rehydratedClosure.call(args) + } + return parentDelegate.invokeMethod(name, args) + } + + /** + * Intercepts property read access with a two-tier resolution strategy. + *

+ * This method handles dynamic property resolution for read operations using the following strategy: + *

    + *
  1. First, attempts to retrieve the property from this FunctionExpando instance using subscript notation
  2. + *
  3. If the property is null or doesn't exist (evaluates to false), delegates to the parent ActionDelegate using the Elvis operator
  4. + *
+ *

+ *

+ * This two-tier approach ensures that: + *

    + *
  • Cached function closures stored on FunctionExpando are directly accessible
  • + *
  • Case and task data fields from ActionDelegate are accessible when not shadowed
  • + *
  • ActionDelegate properties and variables are available as a fallback
  • + *
  • The FunctionExpando acts as a transparent proxy with function overlay capabilities
  • + *
  • Null or falsy values on FunctionExpando cause automatic fallback to parent delegate
  • + *
+ *

+ * + * @param name the name of the property being accessed + * @return the value of the property from this FunctionExpando if it exists and is truthy, otherwise from the parent ActionDelegate + * @throws groovy.lang.MissingPropertyException if the property does not exist on either this instance or the parent delegate + */ + def propertyMissing(String name) { + this[name] ?: parentDelegate."${name}" + } + + /** + * Intercepts property write access with conditional storage strategy for Closures. + *

+ * This method handles dynamic property resolution for write operations with special handling + * for Closure values (cached functions). The behavior is: + *

    + *
  1. If the value is a Closure, it is stored on this FunctionExpando instance for efficient function caching
  2. + *
  3. If the value is NOT a Closure, the property assignment is delegated to the parent ActionDelegate only
  4. + *
+ *

+ *

+ * This conditional storage approach ensures that: + *

    + *
  • Function closures are stored exclusively on FunctionExpando for fast access via propertyMissing(String) and methodMissing
  • + *
  • Non-Closure values (case/task data fields, variables) are stored only on ActionDelegate to avoid duplication
  • + *
  • ActionDelegate variables can be set normally without shadowing on FunctionExpando
  • + *
  • The FunctionExpando maintains both its function caching capability and transparent proxy behavior
  • + *
+ *

+ *

+ * Note: Unlike the dual storage approach, Closure values are stored ONLY on this FunctionExpando instance, + * while non-Closure values are stored ONLY on the parent ActionDelegate, preventing unnecessary duplication + * and ensuring proper separation of concerns between function storage and data storage. + *

+ * + * @param name the name of the property being set + * @param value the value to assign to the property; Closures are stored locally, all other values are delegated + * @throws groovy.lang.MissingPropertyException if the property cannot be set on the parent delegate + */ + def propertyMissing(String name, value) { + if (value instanceof Closure) { + this."${name}" = value + } + parentDelegate."${name}" = value + } +} From fc5e1252e6993e37c0732ae5fd21642462a76bba Mon Sep 17 00:00:00 2001 From: renczesstefan Date: Fri, 12 Jun 2026 16:23:09 +0200 Subject: [PATCH 3/3] Refactor action handling with DelegateExpando and ClosureSignatureMatcher to support dynamic method invocation and improve closure signature matching. --- .../logic/action/ActionDelegate.groovy | 3 +- .../logic/action/FieldActionsRunner.groovy | 2 +- .../action/expando/DelegateExpando.groovy | 181 ++++++++++++++++++ .../action/expando/FunctionExpando.groovy | 124 ++++++++++++ .../action/functions/FunctionExpando.groovy | 176 ----------------- .../engine/utils/ClosureSignatureMatcher.java | 59 ++++++ 6 files changed, 367 insertions(+), 178 deletions(-) create mode 100644 application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy create mode 100644 application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy delete mode 100644 application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy create mode 100644 application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy index a9cc574184b..33e14ce22b7 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/ActionDelegate.groovy @@ -69,6 +69,7 @@ import com.netgrif.application.engine.objects.workflow.domain.menu.dashboard.Das import com.netgrif.application.engine.objects.workflow.domain.menu.dashboard.DashboardManagementBody import com.netgrif.application.engine.pdf.generator.config.PdfResourceConfigurationProperties import com.netgrif.application.engine.pdf.generator.service.interfaces.IPdfGenerator +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando.DelegateExpando import com.netgrif.application.engine.petrinet.service.interfaces.IPetriNetService import com.netgrif.application.engine.plugin.meta.PluginHolder import com.netgrif.application.engine.startup.ImportHelper @@ -107,7 +108,7 @@ import java.util.stream.Collectors * ActionDelegate class contains Actions API methods. */ @SuppressWarnings(["GrMethodMayBeStatic", "GroovyUnusedDeclaration"]) -class ActionDelegate { +class ActionDelegate extends DelegateExpando { static final Logger log = LoggerFactory.getLogger(ActionDelegate) diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy index f2c8de2055d..cac53c6454e 100644 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/FieldActionsRunner.groovy @@ -5,7 +5,7 @@ import com.netgrif.application.engine.business.orsr.IOrsrService import com.netgrif.application.engine.importer.service.FieldFactory import com.netgrif.application.engine.objects.event.events.event.ActionStartEvent import com.netgrif.application.engine.objects.event.events.event.ActionStopEvent -import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.functions.FunctionExpando +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando.FunctionExpando import com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService import com.netgrif.application.engine.objects.petrinet.domain.Function import com.netgrif.application.engine.objects.workflow.domain.Case diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy new file mode 100644 index 00000000000..16f7d210f9a --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/DelegateExpando.groovy @@ -0,0 +1,181 @@ +package com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando + +import com.netgrif.application.engine.utils.ClosureSignatureMatcher + +/** + * A specialized {@link Expando} implementation that enables dynamic method invocation with overloading support for process-scoped actions. + *

+ * This class is designed to attach and execute process-scoped actions to {@link com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate} + * in a proper Groovy-like manner. It extends the standard {@link Expando} behavior by intercepting method calls + * and executing them as closures with proper delegation settings. + *

+ *

+ * Unlike standard {@link Expando}, this implementation supports method overloading by storing multiple closures + * per method name in an internal function map. When a closure is set as a property, it is accumulated in a list + * associated with that property name, allowing multiple method signatures to coexist. + *

+ *

+ * When a method is called that doesn't exist on this object, the {@link DelegateExpando#methodMissing(String, Object)} method + * is invoked, which looks up the method name in the function map and searches for a matching closure based on + * parameter count and type compatibility. If a matching closure is found, it is rehydrated with this instance as + * the delegate, owner, and thisObject, configured with {@link Closure#DELEGATE_FIRST} resolution strategy, and executed. + *

+ *

+ * The closure rehydration process ensures that the closure executes in the context of this {@link DelegateExpando} + * instance, allowing it to access properties and methods defined on this object while maintaining proper delegation + * semantics for the ActionDelegate pattern. + *

+ *

+ * Usage Example: + *

{@code
+ * def expando = new DelegateExpando()
+ * expando.greet = { String name -> "Hello, ${name}!" }
+ * expando.greet = { String name, String title -> "Hello, ${title} ${name}!" }
+ *
+ * expando.greet("John")              // Returns "Hello, John!"
+ * expando.greet("Smith", "Dr.")      // Returns "Hello, Dr. Smith!"
+ *}
+ *

+ *

+ * This class is primarily used by {@link FieldActionsRunner} to dynamically attach process-scoped functions + * to action delegates, enabling flexible and context-aware execution of workflow actions. + *

+ * + * @see Expando* @see com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate* @see FieldActionsRunner + */ +class DelegateExpando extends Expando { + + private static final String DO_CALL = "doCall" + + /** + * Internal storage for closures mapped by method name. + *

+ * Each method name can map to multiple closures, enabling method overloading based on parameter types. + * The list maintains insertion order to preserve the sequence in which overloaded methods are defined. + *

+ */ + private final Map> functionMap = new LinkedHashMap<>() + + /** + * Returns the delegate instance for closure execution. + *

+ * This method provides access to the delegate object that will be set on cloned closures + * during {@link #methodMissing(String, Object)} execution. By default, it returns this + * instance, but can be overridden in subclasses to provide a different delegation target. + *

+ *

+ * The returned delegate is used as the delegate, owner, and thisObject for all dynamically + * invoked closures, ensuring proper scope resolution within the ActionDelegate pattern. + *

+ * + * @return the delegate instance to be used for closure execution (this instance by default) + */ + DelegateExpando getDelegate() { + return this + } + + /** + * Overrides property setting to accumulate closures in the function map for method overloading support. + *

+ * When a closure is assigned to a property, it is added to the list of closures associated with that + * property name in the function map, rather than replacing any existing closure. This accumulation + * mechanism is the foundation for method overloading: each closure represents a different method + * signature, and all are stored under the same method name. + *

+ *

+ * The insertion order is preserved using a {@link LinkedHashMap}, ensuring that when multiple closures + * match during method resolution, the first matching closure (earliest defined) is selected. + *

+ *

+ * Non-closure values are delegated to the parent {@link Expando} implementation, which stores them + * as regular dynamic properties accessible via standard property access syntax. + *

+ * + * @param name the property name; becomes the method name when value is a closure + * @param value the value to set; if a {@link Closure}, it is accumulated in the function map; + * otherwise handled by parent Expando + */ + void setProperty(String name, Object value) { + if (value instanceof Closure) { + functionMap.computeIfAbsent(name, { new ArrayList<>()}) << value + } else { + super.setProperty(name, value) + } + } + + /** + * Intercepts method calls that don't exist on this object and attempts to execute matching closures. + *

+ * This method implements the dynamic method invocation and overload resolution mechanism by: + *

    + *
  1. Normalizing the incoming arguments to a standard Object[] array via {@link #normalize(Object)}
  2. + *
  3. Looking up candidate closures in the function map by method name
  4. + *
  5. Iterating through candidates to find the first closure with matching parameter count and compatible types
  6. + *
  7. Cloning the matching closure to prevent modification of the original
  8. + *
  9. Rehydrating the clone with the result of {@link #getDelegate()} as its delegate
  10. + *
  11. Setting the closure's resolution strategy to {@link Closure#DELEGATE_FIRST} for proper scope resolution
  12. + *
  13. Executing the closure with the normalized arguments
  14. + *
+ *

+ *

+ * Parameter matching is delegated to {@link ClosureSignatureMatcher#matches(Closure, Object [ ])}, which verifies + * that the argument count matches and each argument type is assignable to the corresponding parameter type. + * Null arguments are treated as compatible with any reference parameter type. The first matching closure in + * insertion order is selected and executed. + *

+ *

+ * Closure Isolation: Each method invocation receives a clone of the matched closure, ensuring that + * modifications to delegate, resolveStrategy, or other closure properties don't affect subsequent calls. + *

+ * + * @param name the name of the method being called + * @param args the arguments passed to the method; can be an Object[], List, or single value + * @return the result of executing the matched closure + * @throws MissingMethodException if no closure with the given name exists or no closure matches the argument signature + */ + def methodMissing(String name, args) { + Object[] normalizedArgs = normalize(args) + + def candidates = functionMap[name] + if (candidates) { + Closure match = candidates.find { fn -> + ClosureSignatureMatcher.matches(fn, normalizedArgs) + } + if (match) { + def instance = match.clone() + ((Closure) instance).delegate = getDelegate() + ((Closure) instance).resolveStrategy = Closure.DELEGATE_FIRST + return ((Closure) instance).call(*normalizedArgs) + } + } + throw new MissingMethodException(name, this.class, args as Object[]) + } + + /** + * Normalizes method arguments into a consistent Object[] array format. + *

+ * Groovy's method invocation can pass arguments in various forms depending on the call site. + * This method ensures that arguments are always converted to an Object[] array for consistent + * processing by {@link #methodMissing(String, Object)}. + *

+ *

+ * Normalization rules: + *

    + *
  • If args is already an Object[], it is returned as-is
  • + *
  • If args is a List, it is converted to an array via {@link List#toArray()}
  • + *
  • Otherwise, args is wrapped in a single-element Object[] array
  • + *
+ *

+ * + * @param args the raw arguments passed from Groovy's method invocation mechanism + * @return a normalized Object[] array containing the method arguments + */ + private Object[] normalize(args) { + if (args instanceof Object[]) { + return args + } else if (args instanceof List) { + return args.toArray() + } + return [args] as Object[] + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy new file mode 100644 index 00000000000..9291ffe4e69 --- /dev/null +++ b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/expando/FunctionExpando.groovy @@ -0,0 +1,124 @@ +package com.netgrif.application.engine.petrinet.domain.dataset.logic.action.expando + +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.FieldActionsRunner +import com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService + +/** + * A specialized {@link DelegateExpando} implementation that provides memory-efficient organization of global scoped + * functions from business processes by namespace. + *

+ * This class serves as a namespace container for global functions defined in business processes. It extends + * {@link DelegateExpando} to inherit property-based function storage capabilities while adding delegation + * to a parent {@link ActionDelegate} for non-function method calls. + *

+ *

+ * Key characteristics: + *

    + *
  • Stores function closures as properties (inherited from {@link DelegateExpando})
  • + *
  • Provides namespace-based organization of global functions (e.g., process-specific function scopes)
  • + *
  • Delegates unresolved method calls to the parent {@link ActionDelegate}
  • + *
  • Enables clean separation of global functions by scope while maintaining access to {@link ActionDelegate} context
  • + *
+ *

+ *

+ * Typical usage in {@link FieldActionsRunner}: + *

    + *
  1. Global functions are cached as closures in {@link IFieldActionsCacheService}, grouped by namespace
  2. + *
  3. During action execution, a {@code FunctionExpando} instance is created for each namespace of global functions
  4. + *
  5. Function closures are attached to the {@code FunctionExpando} as properties (e.g., {@code functionExpando.myFunc = closure})
  6. + *
  7. The {@code FunctionExpando} instance is attached to the {@link ActionDelegate} (e.g., {@code actionDelegate.namespace = functionExpando})
  8. + *
  9. Functions can be invoked via namespace (e.g., {@code namespace.myFunc()} in action code)
  10. + *
  11. {@link DelegateExpando}'s {@code methodMissing} handles function invocation by rehydrating closures with {@link ActionDelegate} context
  12. + *
  13. This class's {@code methodMissing} handles fallback to {@link ActionDelegate} for non-function calls
  14. + *
+ *

+ *

+ * This design enables logical grouping of global functions while ensuring they have full access to the + * {@link ActionDelegate} execution environment. + *

+ * + * @see ActionDelegate* @see DelegateExpando* @see IFieldActionsCacheService* @see FieldActionsRunner + */ +class FunctionExpando extends DelegateExpando { + + /** + * Reference to the parent {@link ActionDelegate} that provides the execution context for actions. + *

+ * This delegate is used for: + *

    + *
  • Fallback delegation of method calls that are not stored as function closures
  • + *
  • Providing context to function closures through {@link DelegateExpando}'s rehydration mechanism
  • + *
  • Enabling functions to access case data, task data, and other {@link ActionDelegate} capabilities
  • + *
+ *

+ *

+ * Note: Property access is handled by the parent {@link DelegateExpando} class, not by direct delegation + * to this field. + *

+ */ + ActionDelegate parentDelegate + + /** + * Constructs a new {@code FunctionExpando} associated with the specified {@link ActionDelegate}. + *

+ * The constructor establishes the delegation relationship that enables this {@code FunctionExpando} + * to fall back to the {@link ActionDelegate} for method calls that are not resolved as function closures. + *

+ * + * @param parentDelegate the {@link ActionDelegate} that provides the execution context for functions + * and handles non-function method calls + */ + FunctionExpando(ActionDelegate parentDelegate) { + this.parentDelegate = parentDelegate + } + + /** + * Returns the parent delegate that provides the execution context. + *

+ * This method is overridden from {@link DelegateExpando} to return the {@link ActionDelegate} + * instance, enabling proper delegation and context provision for function closures. + *

+ * + * @return the parent {@link ActionDelegate} instance + */ + @Override + DelegateExpando getDelegate() { + return parentDelegate + } + + /** + * Intercepts method calls to provide two-level dynamic resolution: functions first, then {@link ActionDelegate}. + *

+ * This method implements Groovy's dynamic method resolution mechanism to handle calls + * that are not statically defined on this class. It follows this resolution strategy: + *

    + *
  1. Delegate to parent {@link DelegateExpando}'s {@code methodMissing} to check for function closures stored as properties
  2. + *
  3. {@link DelegateExpando} rehydrates found closures with {@code parentDelegate} as context and invokes them
  4. + *
  5. If no function closure is found ({@code MissingMethodException}), fall back to {@code parentDelegate.invokeMethod()}
  6. + *
  7. If neither resolution succeeds, propagate the {@code MissingMethodException}
  8. + *
+ *

+ *

+ * This two-level approach enables: + *

    + *
  • Execution of namespaced global functions with full {@link ActionDelegate} context
  • + *
  • Access to case and task data from within function closures (via {@link DelegateExpando}'s rehydration)
  • + *
  • Transparent fallback to {@link ActionDelegate} methods when no function matches
  • + *
  • Clean separation of namespace-scoped functions from {@link ActionDelegate} methods
  • + *
+ *

+ * + * @param name the name of the method being invoked + * @param args the arguments passed to the method (typically an {@code Object} array) + * @return the result of the function or method invocation + * @throws groovy.lang.MissingMethodException if neither a function closure nor a delegate method exists + */ + def methodMissing(String name, args) { + try { + return super.methodMissing(name, args) // try own functions first + } catch (MissingMethodException ignored) { + return parentDelegate.invokeMethod(name, args) // fallback to delegate + } + } +} diff --git a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy b/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy deleted file mode 100644 index 58335db8631..00000000000 --- a/application-engine/src/main/groovy/com/netgrif/application/engine/petrinet/domain/dataset/logic/action/functions/FunctionExpando.groovy +++ /dev/null @@ -1,176 +0,0 @@ -package com.netgrif.application.engine.petrinet.domain.dataset.logic.action.functions - -import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.ActionDelegate - - -/** - * A specialized Expando implementation that provides memory-efficient attachment of global scoped functions - * from business processes to an {@link ActionDelegate}. - *

- * This class solves a critical memory optimization problem in the execution of process actions. Global functions - * defined in processes need to be accessible during action execution, but storing fully rehydrated closures - * for every function would consume significant heap space, especially in systems with many processes and functions. - *

- *

- * Instead of rehydrating all global functions for each action execution, this class acts as a lightweight proxy - * that: - *

    - *
  • Stores function closures as properties on the Expando metaclass
  • - *
  • Delegates method calls to either stored function closures or the parent ActionDelegate
  • - *
  • Delegates property access to the parent ActionDelegate
  • - *
  • Minimizes heap usage by avoiding unnecessary closure rehydration
  • - *
- *

- *

- * The typical usage pattern is: - *

    - *
  1. Global functions are cached as closures in {@link com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService}
  2. - *
  3. During action execution, a FunctionExpando instance is created for each scope (namespace) of global functions
  4. - *
  5. Function closures are attached to the FunctionExpando's metaclass
  6. - *
  7. When a function is invoked, methodMissing intercepts the call and executes the cached closure
  8. - *
  9. When a property is accessed, propertyMissing delegates to the parent ActionDelegate
  10. - *
- *

- *

- * This design allows global functions to be shared across multiple action executions without rehydration, - * significantly reducing memory footprint and improving performance. - *

- * - * @see ActionDelegate* @see com.netgrif.application.engine.workflow.service.interfaces.IFieldActionsCacheService* @see com.netgrif.application.engine.workflow.domain.CachedFunction - */ -class FunctionExpando extends Expando { - - /** - * Reference to the parent ActionDelegate that provides the execution context for actions. - *

- * This delegate is used to: - *

    - *
  • Access case and task data during function execution
  • - *
  • Delegate method calls that are not function invocations
  • - *
  • Delegate all property read and write operations
  • - *
  • Provide access to the full action execution environment
  • - *
- *

- *

- * By maintaining this reference, the FunctionExpando can act as a transparent proxy that adds - * function invocation capabilities while preserving all original ActionDelegate functionality. - *

- */ - ActionDelegate parentDelegate - - /** - * Constructs a new FunctionExpando attached to the specified ActionDelegate. - *

- * The constructor establishes the delegation relationship that allows this FunctionExpando - * to act as a proxy for both function invocations and property access. - *

- * - * @param parentDelegate the ActionDelegate that provides the execution context and handles - * non-function method calls and all property access - */ - FunctionExpando(ActionDelegate parentDelegate) { - this.parentDelegate = parentDelegate - } - - /** - * Intercepts method calls to provide dynamic resolution of function invocations. - *

- * This method implements Groovy's dynamic method resolution mechanism to handle calls - * that are not statically defined on this class. It follows this resolution strategy: - *

    - *
  1. Check if a property with the method name exists and is a Closure (cached function)
  2. - *
  3. If found, rehydrate the closure with the parent ActionDelegate as delegate, owner, and thisObject
  4. - *
  5. Set the rehydrated closure's resolve strategy to DELEGATE_FIRST to prioritize ActionDelegate properties
  6. - *
  7. Invoke the rehydrated closure with the provided arguments
  8. - *
  9. Otherwise, delegate the method call to the parent ActionDelegate
  10. - *
- *

- *

- * This approach enables: - *

    - *
  • Execution of cached global functions with proper ActionDelegate context
  • - *
  • Access to case and task data from within function closures
  • - *
  • Transparent fallback to ActionDelegate methods
  • - *
  • Seamless integration of global functions into the action execution context
  • - *
- *

- * - * @param name the name of the method being invoked - * @param args the arguments passed to the method (typically an Object array) - * @return the result of the function or method invocation - * @throws groovy.lang.MissingMethodException if neither a cached function nor a delegate method exists - */ - def methodMissing(String name, args) { - def fn = getProperties()[name] - if (fn instanceof Closure) { - def rehydratedClosure = fn.rehydrate(parentDelegate, parentDelegate, parentDelegate) - rehydratedClosure.resolveStrategy = Closure.DELEGATE_FIRST - return rehydratedClosure.call(args) - } - return parentDelegate.invokeMethod(name, args) - } - - /** - * Intercepts property read access with a two-tier resolution strategy. - *

- * This method handles dynamic property resolution for read operations using the following strategy: - *

    - *
  1. First, attempts to retrieve the property from this FunctionExpando instance using subscript notation
  2. - *
  3. If the property is null or doesn't exist (evaluates to false), delegates to the parent ActionDelegate using the Elvis operator
  4. - *
- *

- *

- * This two-tier approach ensures that: - *

    - *
  • Cached function closures stored on FunctionExpando are directly accessible
  • - *
  • Case and task data fields from ActionDelegate are accessible when not shadowed
  • - *
  • ActionDelegate properties and variables are available as a fallback
  • - *
  • The FunctionExpando acts as a transparent proxy with function overlay capabilities
  • - *
  • Null or falsy values on FunctionExpando cause automatic fallback to parent delegate
  • - *
- *

- * - * @param name the name of the property being accessed - * @return the value of the property from this FunctionExpando if it exists and is truthy, otherwise from the parent ActionDelegate - * @throws groovy.lang.MissingPropertyException if the property does not exist on either this instance or the parent delegate - */ - def propertyMissing(String name) { - this[name] ?: parentDelegate."${name}" - } - - /** - * Intercepts property write access with conditional storage strategy for Closures. - *

- * This method handles dynamic property resolution for write operations with special handling - * for Closure values (cached functions). The behavior is: - *

    - *
  1. If the value is a Closure, it is stored on this FunctionExpando instance for efficient function caching
  2. - *
  3. If the value is NOT a Closure, the property assignment is delegated to the parent ActionDelegate only
  4. - *
- *

- *

- * This conditional storage approach ensures that: - *

    - *
  • Function closures are stored exclusively on FunctionExpando for fast access via propertyMissing(String) and methodMissing
  • - *
  • Non-Closure values (case/task data fields, variables) are stored only on ActionDelegate to avoid duplication
  • - *
  • ActionDelegate variables can be set normally without shadowing on FunctionExpando
  • - *
  • The FunctionExpando maintains both its function caching capability and transparent proxy behavior
  • - *
- *

- *

- * Note: Unlike the dual storage approach, Closure values are stored ONLY on this FunctionExpando instance, - * while non-Closure values are stored ONLY on the parent ActionDelegate, preventing unnecessary duplication - * and ensuring proper separation of concerns between function storage and data storage. - *

- * - * @param name the name of the property being set - * @param value the value to assign to the property; Closures are stored locally, all other values are delegated - * @throws groovy.lang.MissingPropertyException if the property cannot be set on the parent delegate - */ - def propertyMissing(String name, value) { - if (value instanceof Closure) { - this."${name}" = value - } - parentDelegate."${name}" = value - } -} diff --git a/application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java b/application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java new file mode 100644 index 00000000000..1b065c904b0 --- /dev/null +++ b/application-engine/src/main/java/com/netgrif/application/engine/utils/ClosureSignatureMatcher.java @@ -0,0 +1,59 @@ +package com.netgrif.application.engine.utils; + +import com.netgrif.application.engine.petrinet.domain.dataset.logic.action.FieldActionsRunner; +import groovy.lang.Closure; + +import java.lang.reflect.Method; + +/** + * Utility class for matching Groovy Closure signatures against provided arguments. + *

+ * This class provides functionality to determine whether a given Groovy {@link Closure} can accept + * a specific set of arguments based on its method signature. It inspects the closure's {@code doCall} + * methods and verifies if any of them match the provided argument types and count. + *

+ *

+ * This is particularly useful when working with dynamic Groovy closures in a Java context, + * where compile-time type checking is not available and runtime signature matching is required. + *

+ * + * @see Closure + * @see FieldActionsRunner + */ +public class ClosureSignatureMatcher { + + /** + * Checks whether the provided Groovy closure can accept the given arguments. + *

+ * This method iterates through all methods of the closure's class, looking for methods named {@code doCall}. + * For each {@code doCall} method found, it verifies: + *

    + *
  • The parameter count matches the argument count
  • + *
  • Each argument's type is assignable to the corresponding parameter type
  • + *
  • Null arguments are allowed for any parameter type
  • + *
+ *

+ * + * @param fn the Groovy closure to check for signature compatibility + * @param args the array of arguments to match against the closure's signature; + * null values in the array are considered compatible with any parameter type + * @return {@code true} if at least one {@code doCall} method in the closure matches the provided arguments; + * {@code false} otherwise + */ + public static boolean matches(Closure fn, Object[] args) { + for (Method method : fn.getClass().getMethods()) { + if (!method.getName().equals("doCall")) continue; + Class[] params = method.getParameterTypes(); + if (params.length != args.length) continue; + boolean allMatch = true; + for (int i = 0; i < params.length; i++) { + if (args[i] != null && !params[i].isAssignableFrom(args[i].getClass())) { + allMatch = false; + break; + } + } + if (allMatch) return true; + } + return false; + } +}