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 ref = new AtomicReference<>(); + private final List swapListeners = new CopyOnWriteArrayList<>(); + /** The currently-active classloader, or {@code null} before the first rebuild. */ public ClientClassLoader current() { return ref.get(); } - /** Replace the active classloader. */ + /** Replace the active classloader and notify the registered swap listeners. */ public void swap(ClientClassLoader next) { ref.set(next); + for (Runnable listener : swapListeners) { + try { + listener.run(); + } catch (RuntimeException ex) { + LOGGER.error("Client classloader swap listener [{}] failed", listener, ex); + } + } + } + + /** + * Register a listener invoked (synchronously, on the swapping thread) after every + * {@link #swap(ClientClassLoader)}. A throwing listener is logged and does not affect the swap or + * the other listeners. + * + * @param listener the callback to run on each rebuild + */ + public void addSwapListener(Runnable listener) { + swapListeners.add(listener); } } diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java index f2a81f7408d..02c75087d33 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/BpmFlowableConfig.java @@ -110,6 +110,14 @@ private SpringProcessEngineConfiguration createProcessEngineConfig(DataSource da ClassLoader parent = BpmFlowableConfig.class.getClassLoader(); config.setClassLoader(new ClientAwareClassLoader(parent, clientClassLoaderHolder)); + // Resolve delegate classes via ClassLoader.loadClass (which ClientAwareClassLoader overrides to + // consult the current client generation) instead of Class.forName. Class.forName caches the + // resolved class in the JVM against the loader instance Flowable captured at boot, so a + // recompiled flowable:class delegate would keep running its old version until a restart; the + // loadClass path lets each resolution pick up the freshly compiled bytecode + // (FlowableClientClassLoaderRefresher additionally evicts the parsed-process cache on rebuild). + config.setUseClassForNameClassLoading(false); + return config; } diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ClientAwareClassLoader.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ClientAwareClassLoader.java index 755a3d28459..ec9861387cb 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ClientAwareClassLoader.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/ClientAwareClassLoader.java @@ -17,23 +17,26 @@ * resolve to a class compiled from a {@code .java} file in the user's project. * *

- * Resolution order on {@link #findClass(String)}: - *

    - *
  1. Defer to the parent (platform) classloader — every {@code dirigible-*} class and every - * Flowable / Spring class is reachable here.
  2. - *
  3. If the parent does not have it, consult the current {@link ClientClassLoader} (or fail with - * {@link ClassNotFoundException} if no rebuild has happened yet).
  4. - *
+ * {@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. * *

- * 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 """