diff --git a/components/core/core-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientClassLoaderHolder.java b/components/core/core-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientClassLoaderHolder.java index 061717b4525..ffed3a61a24 100644 --- a/components/core/core-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientClassLoaderHolder.java +++ b/components/core/core-java/src/main/java/org/eclipse/dirigible/engine/java/runtime/ClientClassLoaderHolder.java @@ -9,8 +9,12 @@ */ package org.eclipse.dirigible.engine.java.runtime; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; /** @@ -21,20 +25,49 @@ * dispatch. {@link AtomicReference} gives lock-free reads + linearizable swaps. The previous loader * is intentionally not closed — it must remain reachable until any in-flight code that holds a * {@code Class} loaded from it returns. Once those references drop, GC reclaims it. + * + *
+ * Modules that cache anything derived from the client classloader can register a
+ * {@link #addSwapListener(Runnable) swap listener} to be notified on every rebuild. This lets a
+ * consumer that lives in another module react to a recompilation without depending on
+ * {@code engine-java} (e.g. {@code engine-bpm-flowable} refreshing the classloader it hands to the
+ * Flowable engine).
*/
@Component
public class ClientClassLoaderHolder {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ClientClassLoaderHolder.class);
+
private final AtomicReference
- * Resolution order on {@link #findClass(String)}:
- *
- * The holder is hot-swapped on every client rebuild, so {@link #findClass(String)} sees the latest
- * generation on first resolution. The JVM, however, records this loader as an initiating
- * loader for every name it has resolved through {@code findClass}; subsequent
- * {@code Class.forName(name, true, this)} calls from Flowable bypass {@code findClass} and return
- * the cached class from the previous generation. Hot-reload safety for the {@code class=} path
- * therefore requires a process restart. Users that need bulletproof hot-reload should use the
- * {@code ${JavaTask}} delegate-expression path (see {@code DirigibleJavaCallDelegate}) which
- * resolves through the holder every execution.
+ * Two collaborators make this effective: {@code BpmFlowableConfig} sets
+ * {@code useClassForNameClassLoading = false} so Flowable resolves delegates via
+ * {@code ClassLoader.loadClass} (this override) rather than {@code Class.forName} (which the JVM
+ * caches against the loader instance); and {@code FlowableClientClassLoaderRefresher} evicts the
+ * parsed-process cache on every rebuild, because Flowable caches the instantiated delegate on the
+ * parsed service task. The {@code ${JavaTask}} delegate-expression path (see
+ * {@code DirigibleJavaCallDelegate}) resolves through the holder on every execution and is
+ * hot-reload-safe by construction.
*/
final class ClientAwareClassLoader extends ClassLoader {
@@ -44,6 +47,16 @@ final class ClientAwareClassLoader extends ClassLoader {
this.holder = holder;
}
+ @Override
+ public Class> loadClass(String name) throws ClassNotFoundException {
+ ClientClassLoader current = holder.current();
+ if (current == null) {
+ // No client code compiled yet — fall back to the platform delegation.
+ return super.loadClass(name);
+ }
+ return current.loadClass(name);
+ }
+
@Override
protected Class> findClass(String name) throws ClassNotFoundException {
ClientClassLoader current = holder.current();
diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/FlowableClientClassLoaderRefresher.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/FlowableClientClassLoaderRefresher.java
new file mode 100644
index 00000000000..40d08d6ee4b
--- /dev/null
+++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/FlowableClientClassLoaderRefresher.java
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2010-2026 Eclipse Dirigible contributors
+ *
+ * All rights reserved. This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License v2.0 which accompanies this distribution, and is available at
+ * http://www.eclipse.org/legal/epl-v20.html
+ *
+ * SPDX-FileCopyrightText: Eclipse Dirigible contributors SPDX-License-Identifier: EPL-2.0
+ */
+package org.eclipse.dirigible.components.engine.bpm.flowable.config;
+
+import org.eclipse.dirigible.engine.java.runtime.ClientClassLoaderHolder;
+import org.flowable.engine.ProcessEngine;
+import org.flowable.engine.ProcessEngineConfiguration;
+import org.flowable.engine.impl.cfg.ProcessEngineConfigurationImpl;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import jakarta.annotation.PostConstruct;
+
+/**
+ * Makes a recompiled {@code flowable:class} service-task delegate take effect without a server
+ * restart by evicting Flowable's parsed-process cache on every client Java rebuild.
+ *
+ *
+ * {@link ClientAwareClassLoader} already resolves the delegate class against the current
+ * client generation on each call, but Flowable caches the instantiated delegate on the parsed
+ * service task ({@code ClassDelegate.activityBehaviorInstance}), which lives in the
+ * process-definition cache; an unchanged {@code .bpmn} is not redeployed, so that cached instance —
+ * of the previous class version — would keep running. Clearing the cache forces a re-parse and a
+ * fresh delegate instance (of the recompiled class) on the next process start.
+ */
+@Component
+class FlowableClientClassLoaderRefresher {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FlowableClientClassLoaderRefresher.class);
+
+ private final ProcessEngine processEngine;
+ private final ClientClassLoaderHolder clientClassLoaderHolder;
+
+ FlowableClientClassLoaderRefresher(ProcessEngine processEngine, ClientClassLoaderHolder clientClassLoaderHolder) {
+ this.processEngine = processEngine;
+ this.clientClassLoaderHolder = clientClassLoaderHolder;
+ }
+
+ @PostConstruct
+ void register() {
+ clientClassLoaderHolder.addSwapListener(this::onClientRebuild);
+ }
+
+ private void onClientRebuild() {
+ ProcessEngineConfiguration configuration = processEngine.getProcessEngineConfiguration();
+ if (configuration instanceof ProcessEngineConfigurationImpl impl) {
+ impl.getProcessDefinitionCache()
+ .clear();
+ LOGGER.debug("Evicted the Flowable process-definition cache after a client Java rebuild.");
+ }
+ }
+
+}
diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaBpmnIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaBpmnIT.java
index 3165bae7632..a9ba7588c17 100644
--- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaBpmnIT.java
+++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaBpmnIT.java
@@ -82,6 +82,29 @@ void start_process_executes_both_java_delegate_styles() {
assertHistoricVariable(processInstanceId, "pureTaskRan", "yes");
}
+ /**
+ * A {@code flowable:class} delegate must pick up a recompiled version without a server restart.
+ * Deploys a v1 delegate, runs the process, then overwrites the same {@code .java} with v2 (the
+ * {@code .bpmn} is untouched) and runs again — the second run must observe v2. Without the
+ * per-rebuild classloader refresh, the JVM's initiating-loader cache keeps returning v1.
+ */
+ @Test
+ void pure_class_delegate_reflects_a_recompiled_version_without_restart() {
+ write(JAVA_TASK_REGISTRY_PATH, javaTaskSource(), "text/x-java");
+ write(PURE_TASK_REGISTRY_PATH, versionedPureTaskSource("v1"), "text/x-java");
+ write(BPMN_REGISTRY_PATH, bpmnSource(), "application/xml");
+ synchronizationProcessor.forceProcessSynchronizers();
+
+ String firstInstanceId = startProcess();
+ assertHistoricVariable(firstInstanceId, "pureVersion", "v1");
+
+ write(PURE_TASK_REGISTRY_PATH, versionedPureTaskSource("v2"), "text/x-java");
+ synchronizationProcessor.forceProcessSynchronizers();
+
+ String secondInstanceId = startProcess();
+ assertHistoricVariable(secondInstanceId, "pureVersion", "v2");
+ }
+
@AfterEach
void cleanup() {
boolean removed = false;
@@ -150,6 +173,20 @@ public void execute(DelegateExecution execution) {
""";
}
+ private static String versionedPureTaskSource(String version) {
+ return """
+ package com.acme;
+ import org.flowable.engine.delegate.DelegateExecution;
+ import org.flowable.engine.delegate.JavaDelegate;
+ public class PureJavaTask implements JavaDelegate {
+ @Override
+ public void execute(DelegateExecution execution) {
+ execution.setVariable("pureVersion", "%s");
+ }
+ }
+ """.formatted(version);
+ }
+
private static String bpmnSource() {
return """
- *
+ * {@link #loadClass(String)} always delegates to the current {@link ClientClassLoader}
+ * (whose own parent is the platform classloader, so every {@code dirigible-*} / Flowable / Spring
+ * class stays reachable). This is deliberately not the standard
+ * {@code findLoadedClass → parent → findClass} delegation: this loader instance is captured once by
+ * Flowable's {@code CommandContextInterceptor} at engine boot and cannot be swapped at runtime, so
+ * if it cached resolutions itself (or was used as the initiating loader for
+ * {@code Class.forName(name, true, this)}) it would keep returning the class from the first
+ * generation and a recompiled delegate would need a server restart. Routing every call to
+ * {@code holder.current().loadClass(name)} makes the fixed instance a transparent pass-through to
+ * the latest generation instead.
*
*