From 51cee0ec72203c920c7af43ccbac833ce59cf6af Mon Sep 17 00:00:00 2001 From: Iliyan Velichkov Date: Tue, 14 Jul 2026 17:31:23 +0300 Subject: [PATCH] fix(bpm): hot-reload flowable:class client-Java delegates without a restart A client-Java class used as a Flowable service-task delegate via flowable:class="my.fqn.Delegate" (e.g. the intent `delegate` step) kept running its previously compiled version after a republish, until the server was restarted. Republish does compile a fresh ClientClassLoader, but Flowable resolved the delegate with Class.forName(name, true, loader) against the ClientAwareClassLoader instance it captured once at engine boot (via CommandContextInterceptor), and the JVM caches the resolved class against that fixed instance; it also caches the instantiated delegate on the parsed service task in the process-definition cache. Make the fixed, boot-captured loader resolve against the current compiled generation on every call, and drop the cached delegate instance on rebuild: - ClientClassLoaderHolder: add a swap-listener hook fired on every rebuild so another module can react without depending on engine-java. - BpmFlowableConfig: setUseClassForNameClassLoading(false) so Flowable resolves delegates via ClassLoader.loadClass (overridable) instead of Class.forName (JVM-cached against the loader instance). - ClientAwareClassLoader: override loadClass to pass through to holder.current(), turning the fixed instance into a transparent view of the latest generation. - FlowableClientClassLoaderRefresher: evict the process-definition cache on every client rebuild so a fresh delegate instance of the recompiled class is created on the next process start. The ${JavaTask} delegate-expression path was already hot-reload-safe and is unchanged. Adds JavaBpmnIT.pure_class_delegate_reflects_a_recompiled_version_without_restart (v1 -> recompile -> v2, .bpmn untouched) reproducing the bug and locking in the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../java/runtime/ClientClassLoaderHolder.java | 35 ++++++++++- .../flowable/config/BpmFlowableConfig.java | 8 +++ .../config/ClientAwareClassLoader.java | 43 ++++++++----- .../FlowableClientClassLoaderRefresher.java | 61 +++++++++++++++++++ .../integration/tests/api/JavaBpmnIT.java | 37 +++++++++++ 5 files changed, 168 insertions(+), 16 deletions(-) create mode 100644 components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/config/FlowableClientClassLoaderRefresher.java 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 """