Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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.
*
* <p>
* 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<ClientClassLoader> ref = new AtomicReference<>();

private final List<Runnable> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,26 @@
* resolve to a class compiled from a {@code .java} file in the user's project.
*
* <p>
* Resolution order on {@link #findClass(String)}:
* <ol>
* <li>Defer to the parent (platform) classloader — every {@code dirigible-*} class and every
* Flowable / Spring class is reachable here.</li>
* <li>If the parent does not have it, consult the current {@link ClientClassLoader} (or fail with
* {@link ClassNotFoundException} if no rebuild has happened yet).</li>
* </ol>
* {@link #loadClass(String)} always delegates to the <em>current</em> {@link ClientClassLoader}
* (whose own parent is the platform classloader, so every {@code dirigible-*} / Flowable / Spring
* class stays reachable). This is deliberately <em>not</em> 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.
*
* <p>
* 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 <em>initiating
* loader</em> 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 {

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>
* {@link ClientAwareClassLoader} already resolves the delegate <em>class</em> 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.");
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 """
<?xml version="1.0" encoding="UTF-8"?>
Expand Down
Loading