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 @@ -20,6 +20,7 @@
import org.eclipse.dirigible.engine.java.runtime.ClientClassLoaderHolder;
import org.flowable.engine.ProcessEngine;
import org.flowable.engine.ProcessEngineConfiguration;
import org.flowable.engine.impl.bpmn.parser.factory.DefaultListenerFactory;
import org.flowable.spring.SpringProcessEngineConfiguration;
import org.flowable.spring.boot.actuate.endpoint.ProcessEngineEndpoint;
import org.flowable.spring.boot.actuate.info.FlowableInfoContributor;
Expand Down Expand Up @@ -127,7 +128,15 @@ private SpringProcessEngineConfiguration createProcessEngineConfig(DataSource da
// error (message published for {error}) instead of dead-lettering; everything else is
// untouched. The engine's initBehaviorFactory injects the expression manager into this factory
// later.
config.setActivityBehaviorFactory(new ResilientActivityBehaviorFactory(new ResilientClassDelegateFactory()));
ResilientClassDelegateFactory classDelegateFactory = new ResilientClassDelegateFactory();
config.setActivityBehaviorFactory(new ResilientActivityBehaviorFactory(classDelegateFactory));

// The same ClassDelegate seam for the listener path: Flowable builds its own
// DefaultListenerFactory carrying a stock DefaultClassDelegateFactory, so a flowable:class
// execution or task listener would be instantiated reflectively and never reach the client bean
// container - a constructor collaborator fails, an @Inject field silently reads null (#7222).
// The engine keeps a pre-set listener factory and only injects the expression manager into it.
config.setListenerFactory(new DefaultListenerFactory(classDelegateFactory));

return config;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@
import org.flowable.engine.delegate.JavaDelegate;

/**
* Wires a client {@link JavaDelegate} through the client bean container, for the two paths that
* instantiate one: {@code flowable:class} ({@link ResilientClassDelegate}) and
* {@code flowable:delegateExpression="${JavaTask}"} ({@link DirigibleJavaCallDelegate}).
* Wires a client class Flowable instantiates through the client bean container: a
* {@link JavaDelegate} on either of its two paths - {@code flowable:class}
* ({@link ResilientClassDelegate}) and {@code flowable:delegateExpression="${JavaTask}"}
* ({@link DirigibleJavaCallDelegate}) - and a {@code flowable:class} execution or task listener,
* which shares the first one.
*
* <p>
* A delegate is created by Flowable, so it is never a container-owned bean and {@code @Inject}
* Such a class is created by Flowable, so it is never a container-owned bean and {@code @Inject}
* could not reach it; {@link ClientBeanFactory#createUnmanaged(Class)} constructs it with the
* container's own injection rules without registering it. Empty means the class declares no
* injection point, and the caller keeps its own plain instantiation.
Expand All @@ -40,7 +42,7 @@ private ClientDelegateBeans() {}
/**
* The container-wired instance of {@code type}, or empty when there is nothing to wire.
*
* @param <T> the delegate type
* @param <T> the client type
* @param type the client class Flowable is about to instantiate
* @return the wired instance, or empty when the class declares no injection point, Spring is not
* initialized (a standalone engine), or no client generation has been built yet
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,24 @@
import org.flowable.engine.impl.bpmn.parser.FieldDeclaration;

/**
* The {@link ClassDelegate} every {@code flowable:class} service task runs through (created by
* {@link ResilientClassDelegateFactory}), adding the intent DSL's step resilience: when the
* delegate's FINAL failed attempt happens on a task carrying an intent {@code onError} error
* boundary, the failure is converted into the caught BPMN error instead of dead-lettering - see
* The {@link ClassDelegate} every {@code flowable:class} element runs through - a service task, and
* since #7222 an execution or task listener too (all created by
* {@link ResilientClassDelegateFactory}).
*
* <p>
* On a service task it adds the intent DSL's step resilience: when the delegate's FINAL failed
* attempt happens on a task carrying an intent {@code onError} error boundary, the failure is
* converted into the caught BPMN error instead of dead-lettering - see
* {@link IntentStepResilience}. A {@code BpmnError} the delegate throws itself, and any failure on
* a task without the intent boundary, keep the stock behaviour (the superclass handles both).
*
* <p>
* It is also where a client delegate gets its collaborators: {@link #instantiateDelegate} routes
* the class through the client bean container, so a {@code flowable:class} delegate is wired like
* every other client class - see {@link ClientDelegateBeans}.
* It is also where a client class gets its collaborators: {@link #instantiateDelegate} routes the
* class through the client bean container, so a {@code flowable:class} delegate <em>or
* listener</em> is wired like every other client class - see {@link ClientDelegateBeans}. A
* listener's failure keeps the stock behaviour: {@link #execute} is the service-task entry point,
* and Flowable's own {@code notify} paths never reach it, so nothing about a listener is converted
* into a step error.
*/
class ResilientClassDelegate extends ClassDelegate {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,18 @@
import org.flowable.engine.impl.bpmn.parser.FieldDeclaration;

/**
* Creates {@link ResilientClassDelegate}s for every {@code flowable:class} service task (the shape
* of Flowable's own {@code DefaultClassDelegateFactory}), so the intent DSL's {@code onError} error
* routing has its conversion hook on the one path all {@code delegate:} steps run through. Wired
* into the engine by {@code BpmFlowableConfig} via a {@code ResilientActivityBehaviorFactory}
* carrying this factory.
* Creates {@link ResilientClassDelegate}s for every {@code flowable:class} element (the shape of
* Flowable's own {@code DefaultClassDelegateFactory}), so the intent DSL's {@code onError} error
* routing has its conversion hook on the one path all {@code delegate:} steps run through, and so
* every client class the engine instantiates reaches the client bean container.
*
* <p>
* {@code BpmFlowableConfig} wires this one factory into both places Flowable creates a
* {@code ClassDelegate} from: the service-task path, through a
* {@code ResilientActivityBehaviorFactory} carrying it, and the execution- / task-listener path,
* through a {@code DefaultListenerFactory} carrying it - which is what the second {@code create}
* overload below serves. Without that second registration a {@code flowable:class} listener is
* built by stock reflection and its collaborators read {@code null} (#7222).
*/
public class ResilientClassDelegateFactory implements ClassDelegateFactory {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* 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.delegate;

import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.flowable.bpmn.model.FlowableListener;
import org.flowable.bpmn.model.ImplementationType;
import org.flowable.engine.ProcessEngine;
import org.flowable.engine.delegate.DelegateExecution;
import org.flowable.engine.delegate.JavaDelegate;
import org.flowable.engine.impl.bpmn.parser.factory.DefaultListenerFactory;
import org.flowable.engine.impl.bpmn.parser.factory.ListenerFactory;
import org.flowable.engine.impl.cfg.ProcessEngineConfigurationImpl;
import org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration;
import org.junit.jupiter.api.Test;

/**
* A {@code flowable:class} execution or task listener must reach the same client-bean seam a
* {@code flowable:class} service task does (#7222). Flowable builds its own
* {@link DefaultListenerFactory} carrying a stock {@code DefaultClassDelegateFactory}, so without
* the registration {@code BpmFlowableConfig} makes, a client listener is instantiated reflectively
* and its collaborators read {@code null} - the #7058 symptom, one artefact type over.
*
* <p>
* This pins the mechanism against a real engine: that a pre-set listener factory survives the
* engine's own {@code initListenerFactory} (and gets its expression manager), and that both
* listener kinds then come out as {@link ResilientClassDelegate}s, whose
* {@code instantiateDelegate} is the seam. The defect case is pinned alongside, so a dropped
* registration fails here. The end-to-end proof that the collaborators are really injected is
* {@code JavaDelegateInjectionIT}.
*/
class ResilientListenerFactoryTest {

/** A client-shaped listener class; only the type Flowable creates for it is under test here. */
public static class SampleListener implements JavaDelegate {

@Override
public void execute(DelegateExecution execution) {
// Never invoked: the factory call under test only creates the ClassDelegate.
}
}

@Test
void aClassListenerIsCreatedThroughTheClientBeanSeam() {
withEngine(true, configuration -> {
ListenerFactory factory = configuration.getListenerFactory();

assertInstanceOf(ResilientClassDelegate.class, factory.createClassDelegateExecutionListener(classListener()),
"a flowable:class execution listener must be built by the resilient delegate, which wires the client bean container");
assertInstanceOf(ResilientClassDelegate.class, factory.createClassDelegateTaskListener(classListener()),
"a flowable:class task listener must be built by the resilient delegate, which wires the client bean container");
});
}

@Test
void theConfiguredListenerFactoryIsKeptAndGetsTheExpressionManager() {
withEngine(true, configuration -> {
assertInstanceOf(DefaultListenerFactory.class, configuration.getListenerFactory(),
"the engine must keep the configured listener factory instead of building its own");
assertNotNull(((DefaultListenerFactory) configuration.getListenerFactory()).getExpressionManager(),
"the engine injects the expression manager into a pre-set listener factory - an expression listener needs it");
});
}

@Test
void withoutTheRegistrationTheListenerIsAStockClassDelegate() {
withEngine(false, configuration -> assertTrue(!(configuration.getListenerFactory()
.createClassDelegateExecutionListener(
classListener()) instanceof ResilientClassDelegate),
"the defect this pins: Flowable's own listener factory bypasses the client bean seam"));
}

private static FlowableListener classListener() {
FlowableListener listener = new FlowableListener();
listener.setImplementationType(ImplementationType.IMPLEMENTATION_TYPE_CLASS);
listener.setImplementation(SampleListener.class.getName());
return listener;
}

private static void withEngine(boolean registerListenerFactory,
java.util.function.Consumer<ProcessEngineConfigurationImpl> assertions) {
StandaloneInMemProcessEngineConfiguration configuration = new StandaloneInMemProcessEngineConfiguration();
configuration.setJdbcUrl("jdbc:h2:mem:listener-factory-test-" + registerListenerFactory + ";DB_CLOSE_DELAY=1000");
if (registerListenerFactory) {
configuration.setListenerFactory(new DefaultListenerFactory(new ResilientClassDelegateFactory()));
}
ProcessEngine engine = configuration.buildProcessEngine();
try {
assertions.accept((ProcessEngineConfigurationImpl) engine.getProcessEngineConfiguration());
} finally {
engine.close();
}
}
}
Loading
Loading