From 6cb62c22ea88ed9a1df9cdaff8e52b804f285c3d Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 25 Sep 2026 13:37:00 +0200 Subject: [PATCH] Update execution variables in place instead of emptying them each time Every step's completion emptied the execution's variable maps and filled them again, while steps running on other threads were reading them. Two branches running side by side could fail with a ConcurrentModificationException copying the map, or read it in the moment it was empty. Only what differs is now changed. Variables are declared up front, so usually nothing is written at all. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manager/executions/ExecutionContext.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java index 359abc8..670dd82 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionContext.java @@ -899,8 +899,7 @@ public class ExecutionContext implements ExecutionListener { } private void refreshRuntimeExecutionVariables() { - this.runtimeExecutionVariables.clear(); - this.runtimeExecutionVariables.putAll(ExecutionRuntimeContextSupport.runtimeValues( + replaceContents(this.runtimeExecutionVariables, ExecutionRuntimeContextSupport.runtimeValues( this.executionVariables, this.globalInputs, this.projectContext, @@ -908,13 +907,29 @@ public class ExecutionContext implements ExecutionListener { } private void synchronizeExecutionVariablesFromDescriptors() { - this.executionVariables.clear(); + Map declared = new HashMap<>(); this.executionVariableDescriptors.values().stream() .map(ExecutionVariableRegistry::normalize) - .forEach(descriptor -> this.executionVariables.put(descriptor.getName(), descriptor.getValue())); + .forEach(descriptor -> declared.put(descriptor.getName(), descriptor.getValue())); + replaceContents(this.executionVariables, declared); refreshRuntimeExecutionVariables(); } + /** + * Brings {@code target} to {@code desired} touching only what differs. Steps running on other + * threads read these maps while this runs on every step's completion: emptied and refilled, + * they would read a map with nothing in it, or fail copying one that changed under them. With + * the same keys - the usual case, variables being declared up front - nothing is written at all. + */ + private static void replaceContents(Map target, Map desired) { + target.keySet().removeIf(key -> !desired.containsKey(key)); + desired.forEach((key, value) -> { + if (!target.containsKey(key) || !java.util.Objects.equals(target.get(key), value)) { + target.put(key, value); + } + }); + } + private synchronized void abortAfterFailure(String failedStepId) { this.waitingSteps.clear(); this.steps.values().stream()