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) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-25 13:37:00 +02:00
parent f6a968ab34
commit 6cb62c22ea
1 changed files with 19 additions and 4 deletions

View File

@ -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<String, Object> 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<String, Object> target, Map<String, Object> 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()