From f8a07441d5c603d423a8c11e4e2341c2ac4721e0 Mon Sep 17 00:00:00 2001 From: delchev Date: Fri, 11 Sep 2026 15:13:02 +0300 Subject: [PATCH] bpm: a flowable:class execution or task listener reaches the client bean container too (#7222) #7058 wired a client JavaDelegate through the bean container on both service-task paths, but only the activity-behaviour factory was registered. Flowable creates a listener through a different factory: ProcessEngineConfigurationImpl.initListenerFactory builds its own DefaultListenerFactory carrying a stock DefaultClassDelegateFactory, and createClassDelegateExecutionListener / createClassDelegateTaskListener call that one. So a or written the way the docs teach - a constructor collaborator, or an @Inject field - either failed at reflection (no no-arg constructor) or ran with every injected field null, silently: the #7058 symptom one artefact type over. ResilientClassDelegateFactory.create(String, List), the overload the listener path uses, was dead code under this configuration. BpmFlowableConfig now also registers setListenerFactory(new DefaultListenerFactory( classDelegateFactory)) with the same factory instance, so both listener kinds come out as ResilientClassDelegates and share instantiateDelegate - the client-bean seam. The engine keeps a pre-set listener factory and only injects the expression manager into it, so expression listeners are unaffected. A listener deliberately gets no intent step resilience: execute() is the service-task entry point and Flowable's notify paths never reach it, and a listener is not a step. Verified: new ResilientListenerFactoryTest (both listener kinds against a real engine, plus the defect case - a stock factory does not yield the resilient delegate), the engine-bpm-flowable unit suite (38 tests), and a listener process added to JavaDelegateInjectionIT, which is green with the fix and fails without it with "couldn't instantiate class delegateinjection.CtorInjectedExecutionListener / NoSuchMethodException: ()". The IT's runtime-variable assertion was reading the historic endpoint's "variableName" key against the runtime endpoint's "name", which made assertNoRuntimeVariable vacuous; fixed with it. Docs in engine-java/CLAUDE.md and engine-intent/CLAUDE.md, whose sentences over-stated the delegate seam's reach, now scope and extend it. Fixes #7222 Co-Authored-By: Claude Opus 5 --- .../flowable/config/BpmFlowableConfig.java | 11 +- .../delegate/ClientDelegateBeans.java | 12 +- .../delegate/ResilientClassDelegate.java | 21 ++-- .../ResilientClassDelegateFactory.java | 17 ++- .../ResilientListenerFactoryTest.java | 104 ++++++++++++++++++ components/engine/engine-intent/CLAUDE.md | 2 +- components/engine/engine-java/CLAUDE.md | 23 +++- .../tests/api/JavaDelegateInjectionIT.java | 38 ++++++- .../CtorInjectedExecutionListener.java | 35 ++++++ .../FieldInjectedTaskListener.java | 32 ++++++ .../JavaDelegateInjectionIT/listeners.bpmn | 21 ++++ 11 files changed, 289 insertions(+), 27 deletions(-) create mode 100644 components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java create mode 100644 tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java create mode 100644 tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java create mode 100644 tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn 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 d2194ff8a70..22575f29594 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 @@ -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; @@ -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; } diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java index 80e7f788569..4991f238852 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ClientDelegateBeans.java @@ -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. * *

- * 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. @@ -40,7 +42,7 @@ private ClientDelegateBeans() {} /** * The container-wired instance of {@code type}, or empty when there is nothing to wire. * - * @param the delegate type + * @param 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 diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java index 9b9153f6932..969f4ffc62f 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegate.java @@ -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}). + * + *

+ * 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). * *

- * 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 or + * listener 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 { diff --git a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java index ba35344eaa8..84e17a5c687 100644 --- a/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java +++ b/components/engine/engine-bpm-flowable/src/main/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientClassDelegateFactory.java @@ -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. + * + *

+ * {@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 { diff --git a/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java new file mode 100644 index 00000000000..45ebc30c5b2 --- /dev/null +++ b/components/engine/engine-bpm-flowable/src/test/java/org/eclipse/dirigible/components/engine/bpm/flowable/delegate/ResilientListenerFactoryTest.java @@ -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. + * + *

+ * 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 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(); + } + } +} diff --git a/components/engine/engine-intent/CLAUDE.md b/components/engine/engine-intent/CLAUDE.md index e5a2bf6ca62..0c7579f6472 100644 --- a/components/engine/engine-intent/CLAUDE.md +++ b/components/engine/engine-intent/CLAUDE.md @@ -408,7 +408,7 @@ Semantics worth knowing: - **Decision steps**: `if` + `then` are mandatory; `else` is optional and receives the gateway-default flow (so the conditioned branch can actually be skipped - without `else` the default falls through to the next step in the chain). `then`/`else` must name a declared step or the literal `end`, and **neither may name the decision itself** - an exclusive gateway has no wait state, so a branch back to the gateway emits a self-targeting sequence flow Flowable spins on, exactly as a step's `next: ` does (#7226 / #7292); it is refused with the same sentence. A multi-step cycle THROUGH a wait state is legitimate and untouched, as are `onError: ` (an unbounded retry) and a timer boundary's `then: ` (re-open the task). The parser validates all of this so a typo - or a spin - fails at parse time instead of producing BPMN Flowable rejects or spins on. - **`setField` service task + `next` step routing (declarative field-set glue).** A `serviceTask` with `args: { setField: , value: }` sets a `string`/`text` field of the process's **trigger entity** to a literal value, generated as a `gen/events//.java` `JavaDelegate` (`SetFieldSupport` → the `setters` glue collection → `SetField.java.template`) instead of scaffolding a hand-written `custom.` stub - it persists the set column via the targeted single-column `updateProperty(id, "", value)` (a workflow write, not a user edit, so it must not re-fire `onUpdate` reactions; only the set column is in the UPDATE statement, so a concurrent write to any other column cannot be reverted). The canonical use is an approve/reject outcome: the form completes the task with the chosen `action` as a process variable, a `decision` branches on `action == 'approve'`, and the two branches are `setField` tasks (`status=ACTIVE` / `status=REJECTED`). **`args: { next: }`** on any step overrides its linear successor - needed because the BPMN generator builds a **linear** chain, so without it the first branch (`activate`) would fall through into the second (`reject`); `next: done` makes the branches converge. The `then`/`else` fall-through is deliberately NOT auto-converted to a diamond (LoanApproval's `curatorReview` relies on falling through to `notifyMember`), so convergence is explicit via `next`. Scope: literal string values only (the parser validates `setField` is a string/text field of the trigger entity and that `value` is present; `next` must name a declared step or `end`). Non-string fields and expression values are future work. - **`setRelationField` (set a status modelled as a to-one relation).** The generic counterpart to `setField` for a status that is a **FK to a settings/nomenclature entity** (e.g. `Status`) rather than a string column: `args: { setRelationField: , value: }` sets the relation's FK property to the integer **seed id** (unquoted — `entity. = ;`), via the **same** `SetFieldSupport` → `setters` glue → `SetField.java.template` path (the `Setter` carries a `relation` flag; the template branches `#if($relation == "true")` to emit the unquoted assignment). Unlike `setField` (serviceTask only), `setRelationField` is allowed on a **serviceTask** (bound directly by `appendServiceTask`, like `setField`) **and** on a **userTask** (the BPMN inserts the setter `JavaDelegate` right after the task — exactly like the Writer — so e.g. the Approve user task sets `Status=APPROVED` the moment it completes; `BpmnIntentGenerator` builds a `setterByProcessTask` map of user-task setters and `augmentWithResolvers` appends them in a `[writer, setter]` after-task chain, carrying `next` onto the last delegate). The parser validates the relation is a `manyToOne`/`oneToOne` of the trigger entity and that `value` is an integer id. This replaced an unimplemented `setStatus` idea — there is no `setStatus` keyword. **The `-transitioned` topic:** both setter shapes persist via the targeted `updateProperty` (no `-updated` re-fire, deliberately) but DO publish the fresh entity JSON (re-loaded after the write) on `---transitioned` — the dedicated status-reached channel. **Publication is deferred to the end of the synchronous BPMN chain** (`Process.executeAfterCommit` — a Flowable COMMITTED transaction listener): service tasks that follow the setter in the same chain (a number-generation delegate) complete before any consumer can react, so a consumer that re-loads the source by id observes their writes instead of racing them (an auto-posted journal entry used to catch the create-time UUID placeholder as `documentNumber`). **On a mid-chain FAILURE this is correct by design, not a lost event:** the status write commits in its own session (client-Java writes are per-operation — cloud-native, no ambient cross-step transaction), while the deferred publish only fires on the Flowable COMMITTED event; if a later task rolls the chain back, the publish deliberately does NOT fire, and the transition is unwound by the flow's compensation / error path (a compensating status set), never by a DB rollback. Checks + Saga-style compensation, NOT distributed transactions, are the consistency model here — do not "fix" this by enrolling the entity write in the BPMN transaction. Reactions/notifications keep binding `-updated`/create topics and never see it (no loops); a posting-glue or integration consumer binds `-transitioned` to observe workflow transitions that are otherwise event-silent (the Wave-0 accounting spike's core finding: an Issue step was invisible to every entity event). Emitted by `SetField.java.template`; covered by the `set_field_glue_...` IT assertion. **Placement rule (applies to `setField` too, documented for the editor AI in `intent-assistant-guide.md`):** when a task is **followed by a decision** (Approve/Reject), put the status set on a **`serviceTask` on the chosen branch**, NOT on the user task — setting it on the task makes a Reject transition `DRAFT → APPROVED → CANCELLED` (an artificial APPROVED hop) before the cancel branch overrides it. Set on the task **only** for a **single-action** task with no following decision (no branch ⇒ no transient state). The `sales-invoices` showcase follows this (Approve task has no set; an `activate` serviceTask sets APPROVED on the approve branch; single-action `issue`/`send` set on the task). -- **`delegate` service task (call a reusable, author-named client `JavaDelegate`).** `args: { delegate: , fields: { : , ... } }` binds a serviceTask to a hand-written client delegate via **`flowable:class`** (NOT the `${JavaTask}` dispatcher). This is the *fourth* service-task shape alongside `setField`/`setRelationField` (→ generated `gen.events..` via `${JavaTask}`+`handler`), `call` (→ `${JSTask}` TS handler), and the bare fallback (→ `custom.` + a scaffolded stub). Why `flowable:class` and not `${JavaTask}`: `DirigibleJavaCallDelegate` (`${JavaTask}`) reads only its `handler` field, so it can't pass parameters; `flowable:class` (resolved through `BpmFlowableConfig`'s `ClientAwareClassLoader`, which consults the client class loader) lets Flowable **inject** the declared `fields` as delegate fields — so one *general* delegate serves many steps. (Since #7058 the delegate's own **collaborators** are wired by the client bean container on BOTH paths — a constructor or `@Inject` field over the project's `@Component`s — so `fields:` is about per-step *parameters*, not about reaching services; a delegate must still never be a `@Component` itself.) `BpmnIntentGenerator.appendServiceTask` branches to `appendDelegateServiceTask` (emitting `flowable:class` + one `` per `fields` entry, in declaration order); `ServiceTaskHandlerGenerator` **skips** delegate steps (no `custom/` stub — the developer owns the class, which may live in *another* published project since client Java compiles in one cross-project batch). Parser: `delegate` is serviceTask-only, mutually exclusive with `setField`/`setRelationField`/`call`, and `fields` values must be scalars. **Worked example:** `sample-intent-multi-model` — `sales-invoices`' `generateNumber` step (after `issue`) binds `custom.sales_invoices.DocumentNumberGeneratorDelegate` with `fields: { type: "Sales Invoice" }`. **The delegate lives in the document's OWN project** (sales-invoices), because it must load/save the invoice through the generated `SalesInvoiceRepository` (validations, events, i18n — NEVER the generic `Store`; see the engine-java guide's repository-only rule): it reads the record id from the `Id` process variable, `findById`s it, asks the reusable `custom.numbers.DocumentNumberGenerator` (in the `numbers` project — a codbex-number-generator port over its own `NumberRepository`, entity-agnostic) for the next formatted number of the injected `type`, sets `entity.Number`, and persists via `updateWithoutEvent` (workflow write). Only the entity-agnostic generator is shared; the entity-touching delegate is per-project. Covered by `IntentEngineIT.delegate_service_task_binds_a_client_java_delegate_via_flowable_class_with_injected_fields`. +- **`delegate` service task (call a reusable, author-named client `JavaDelegate`).** `args: { delegate: , fields: { : , ... } }` binds a serviceTask to a hand-written client delegate via **`flowable:class`** (NOT the `${JavaTask}` dispatcher). This is the *fourth* service-task shape alongside `setField`/`setRelationField` (→ generated `gen.events..` via `${JavaTask}`+`handler`), `call` (→ `${JSTask}` TS handler), and the bare fallback (→ `custom.` + a scaffolded stub). Why `flowable:class` and not `${JavaTask}`: `DirigibleJavaCallDelegate` (`${JavaTask}`) reads only its `handler` field, so it can't pass parameters; `flowable:class` (resolved through `BpmFlowableConfig`'s `ClientAwareClassLoader`, which consults the client class loader) lets Flowable **inject** the declared `fields` as delegate fields — so one *general* delegate serves many steps. (Since #7058 the delegate's own **collaborators** are wired by the client bean container on BOTH paths — a constructor or `@Inject` field over the project's `@Component`s — so `fields:` is about per-step *parameters*, not about reaching services; a delegate must still never be a `@Component` itself. #7222 wires the same seam into Flowable's separate `ListenerFactory` registration, so a `flowable:class` **execution or task listener** is injected too - a listener is not a step, so it gets no `onError` conversion.) `BpmnIntentGenerator.appendServiceTask` branches to `appendDelegateServiceTask` (emitting `flowable:class` + one `` per `fields` entry, in declaration order); `ServiceTaskHandlerGenerator` **skips** delegate steps (no `custom/` stub — the developer owns the class, which may live in *another* published project since client Java compiles in one cross-project batch). Parser: `delegate` is serviceTask-only, mutually exclusive with `setField`/`setRelationField`/`call`, and `fields` values must be scalars. **Worked example:** `sample-intent-multi-model` — `sales-invoices`' `generateNumber` step (after `issue`) binds `custom.sales_invoices.DocumentNumberGeneratorDelegate` with `fields: { type: "Sales Invoice" }`. **The delegate lives in the document's OWN project** (sales-invoices), because it must load/save the invoice through the generated `SalesInvoiceRepository` (validations, events, i18n — NEVER the generic `Store`; see the engine-java guide's repository-only rule): it reads the record id from the `Id` process variable, `findById`s it, asks the reusable `custom.numbers.DocumentNumberGenerator` (in the `numbers` project — a codbex-number-generator port over its own `NumberRepository`, entity-agnostic) for the next formatted number of the injected `type`, sets `entity.Number`, and persists via `updateWithoutEvent` (workflow write). Only the entity-agnostic generator is shared; the entity-touching delegate is per-project. Covered by `IntentEngineIT.delegate_service_task_binds_a_client_java_delegate_via_flowable_class_with_injected_fields`. - **`wait` step = park the process on an entity event (message catch + correlation glue).** `- { name: awaitReply, kind: wait, args: { onCreate: CaseMessage, via: case, when: "internal == 0", next: work } }` — the process parks on a BPMN `` + `intermediateCatchEvent` (message name = `` PascalCase) until the named entity event resumes it. Exactly one of `onCreate`/`onUpdate` (never `onDelete` — a deleted record cannot resume a wait) naming a declared entity; `via:` is the **event** entity's to-one relation walking to the trigger entity (required when they differ, forbidden when the event entity IS the trigger entity; same-model only); `when:` is the single-comparison guard grammar over the EVENT record (`NotificationSupport.guard`). Glue: `ProcessWaitSupport` → the `waits` collection in `.glue` → `Wait.java.template` — a self-describing `MessageHandler` on the event entity's topic that resolves the ProcessId-carrying record (via the FK, or `findById` of the event record itself in the direct case — re-loaded because the payload snapshot may predate the trigger's ProcessId write-back), and calls `Process.correlateMessageEvent(processId, message, Map.of())` wrapped fail-soft (an instance not parked on the message is a no-op, never an error). The process MUST have a trigger entity — its `ProcessIds` stamp for THIS process is the correlation contract (`ProcessStamps.idFor`; the single `ProcessId` holds whichever flow started last, so correlating on it resumes a stranger's wait once a record carries several - #6862). Requested/shaped in upstream discussion #6327. - **`timeout:` / `expire:` on a userTask = boundary timers (reminders, SLAs, date-driven expiry).** Two optional map args on a user task, each with a `then:` routed like a decision branch (declared step or `end`; route the main flow around the branch steps with `next`, as with decision branches): `timeout: { after: P3D, then: remind }` emits a **non-cancelling** `boundaryEvent` (`cancelActivity="false"`) with a literal `timeDuration` — the task stays claimable while the reminder/escalation branch runs; `expire: { until: validUntil, then: markExpired }` emits a **cancelling** one (`cancelActivity="true"`) with `timeDate` bound to the `${__ExpireDate}` process variable — the task is withdrawn and the flow continues at `then`. The expire date is **re-read at task entry**: `ProcessTimerSupport` derives a loader `JavaDelegate` (the `timerLoaders` glue collection → `TimerLoader.java.template`) that `augmentWithResolvers` inserts right before the task, loading the trigger entity by id and publishing the field as a `java.util.Date` (Flowable arms the timer from the object directly — no string parsing). A `date` field expires at the start of the day AFTER it (the field names the last valid day); a `timestamp` at its instant; a `null` field or missing record arms 9999-12-31 so the timer never effectively fires. Parser: userTask-only, `after:` must parse as an ISO-8601 `Duration`/`Period`, `until:` must be a `date`/`timestamp` field of the trigger entity, `then:` validated like a decision target. The boundary shapes ride the host task's bottom edge in the DI; the layered layout ranks each boundary branch through a pseudo-flow from the HOST task. The Flowable async executor is already active (`BpmFlowableConfig.setAsyncExecutorActivate(true)`), so timer jobs fire with no new runtime. Caveat: a loop/jump straight into the user task (a reject-loop `else: approve`) re-enters the task WITHOUT re-running the inserted loader — the expire variable keeps its previous value for that pass. Requested/shaped in upstream discussion #6328. - **`abortOn:` on a process = cancel the in-flight instance when the document transitions into a terminal status (BPM events wave 2).** `abortOn: { status: [4, 5], then: markVoid }` — a `-transitioned` of the trigger entity into any listed EntityStatus seed id cancels the whole running instance (pending user tasks, parked waits, armed boundary timers). Emitted as an **interrupting message event subprocess** (`` with an `isInterrupting="true"` message start on `Abort` → optional cleanup serviceTask → `terminateEventDefinition`), NOT by wrapping the main flow — chosen over the proposal's subProcess-wrap sketch because it needs no restructuring of the flat step layout and still kills everything in scope. Glue: `ProcessAbortSupport` → the `aborts` collection in `.glue` → `Abort.java.template` — a `MessageHandler` on the entity's `-transitioned` topic (the channel transitions/setters already publish) that matches the status list (`entity. == || …`) and correlates `Abort` on the instance THIS process stamped in `ProcessIds` (`ProcessStamps.idFor`, falling back to `ProcessId` for records stamped before that column existed), fail-soft. `then:` omitted or `end` = terminate; a declared `serviceTask` cleanup (setField/setRelationField) is **abort-only** — `BpmnIntentGenerator` filters it out of the main linear chain (`steps.removeIf`) and re-emits it inside the event subprocess (its setter glue is still generated by `SetFieldSupport`). Parser (`validateAbortOn`): integer `status` (scalar or list), trigger entity with a `function: EntityStatus` relation, `then` = `end`/a setField-setRelationField serviceTask that is NOT explicitly routed to from the main flow (`next`/`then`/`else`). DI: the event subprocess is a fixed-placement container box below the main lane (BPMN-2.0 expanded-subprocess children carry absolute plane coordinates). Requested/shaped in upstream discussion #6340. **Consumers:** the orphaned-Inbox-task hole (cancel a SalesOrder mid-confirm), and the structural replacement for a cancelling `expire:` guard (kf quotations drops its `custom/` guard delegate). Caveat: `then` cleanup is one serviceTask (a multi-step cleanup chain is future work). diff --git a/components/engine/engine-java/CLAUDE.md b/components/engine/engine-java/CLAUDE.md index babefddab01..f458223adaf 100644 --- a/components/engine/engine-java/CLAUDE.md +++ b/components/engine/engine-java/CLAUDE.md @@ -97,8 +97,8 @@ One Spring-singleton container, rebuilt per `ClientClassLoader` generation. use the platform-internal `BeanProvider` (that's core-only; `JavaRepository.store()` uses it because it is platform code). - **`createUnmanaged(Class)` wires a client class the container does NOT own** — today exactly one - thing: a client `JavaDelegate`, which Flowable instantiates itself and which therefore never becomes - a bean (#7058). Same rules as a `@Component` (constructor / field `@Inject` / collection, by type + family: a client class **Flowable** instantiates itself and which therefore never becomes a bean — + a `JavaDelegate` (#7058) and a `flowable:class` execution / task listener (#7222). Same rules as a `@Component` (constructor / field `@Inject` / collection, by type with the parameter or field name disambiguating, `@PostConstruct`), resolved against the **live** singletons — it constructs no bean, so a cycle is impossible on this path — and the instance is **not registered** (it never appears in `get` / `getAll` / `instanceOf`, and a failure here is not a @@ -245,7 +245,7 @@ then `JavaClassRegistry` + `JavaHandler.handle`. A `JavaHandler` that is also `@ as the container-built (injected) singleton; a plain `JavaHandler` (no `@Component`) is instantiated per request via its no-arg constructor. -## `JavaDelegate` (BPMN service tasks) — injected, but never a bean +## `JavaDelegate` and BPMN listeners — injected, but never a bean A client `JavaDelegate` is created by **Flowable**, not by the container, so it is not a `@Component` and must not be annotated as one: that would build a fully-injected singleton the engine never runs, @@ -261,6 +261,23 @@ through `ClientBeanFactory.createUnmanaged` (see the container section): new generation. - **`${JavaTask}` + a `handler` field** — `DirigibleJavaCallDelegate`, fresh per execution. +**The same is true of a `flowable:class` execution or task listener, and it takes a SECOND engine +registration (#7222).** A listener is not created by the activity-behaviour factory: Flowable's +`ProcessEngineConfigurationImpl.initListenerFactory` builds its own `DefaultListenerFactory` carrying a +stock `DefaultClassDelegateFactory`, and `createClassDelegateExecutionListener` / +`createClassDelegateTaskListener` call **that**. So configuring only +`setActivityBehaviorFactory(new ResilientActivityBehaviorFactory(new ResilientClassDelegateFactory()))` +left the listener path on plain reflection — a constructor collaborator failed to instantiate and an +`@Inject` field silently read `null`, the #7058 symptom one artefact type over. `BpmFlowableConfig` +therefore also does `setListenerFactory(new DefaultListenerFactory(classDelegateFactory))` with the +**same** factory instance (the engine keeps a pre-set listener factory and only injects the expression +manager into it), so both listener kinds come out as `ResilientClassDelegate`s and share +`instantiateDelegate`. What a listener does **not** get is the intent step resilience: `execute` is the +service-task entry point and Flowable's `notify` paths never reach it, so a listener failure keeps the +stock behaviour — a listener is not a step, and nothing in the DSL emits one. Pinned by +`ResilientListenerFactoryTest` (both listener kinds, plus the defect case) and +`JavaDelegateInjectionIT.both_listener_kinds_wire_their_collaborators`. + A delegate annotated `@Component` is reported at **publish** as a Problems entry on its source (`ComponentContainer.wiringWarnings()`), not as a WARN per step execution — see the container section. diff --git a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java index 0b5f654d5e1..63ff35feb70 100644 --- a/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java +++ b/tests/tests-integrations/src/main/java/org/eclipse/dirigible/integration/tests/api/JavaDelegateInjectionIT.java @@ -41,10 +41,11 @@ * delegate paths in one process - {@code flowable:class} and * {@code flowable:delegateExpression="${JavaTask}"} - with constructor, field and collection * injection, a {@code @PostConstruct}-only delegate, and the two delegates that declare no - * injection point at all (which must keep being built exactly as before). Two further processes pin - * the behaviour that is easiest to regress: an unsatisfiable dependency must fail the STEP and not - * the deployment, and a recompiled collaborator must reach the delegate Flowable caches on the - * parsed activity. + * injection point at all (which must keep being built exactly as before). Three further processes + * pin the behaviour that is easiest to regress: an unsatisfiable dependency must fail the STEP and + * not the deployment, a recompiled collaborator must reach the delegate Flowable caches on the + * parsed activity, and a {@code flowable:class} execution or task listener - created through a + * different engine registration from the service-task one - must reach the same seam (#7222). */ // One Dirigible boot for the whole class: the fixture is deployed once and every method starts its // own process instance, so the per-method context reset inherited from IntegrationTest would only @@ -56,6 +57,7 @@ class JavaDelegateInjectionIT extends IntegrationTest { private static final String INJECTION_PROCESS = "java-delegate-injection"; private static final String UNSATISFIED_PROCESS = "java-delegate-unsatisfied"; private static final String VERSION_PROCESS = "java-delegate-version"; + private static final String LISTENER_PROCESS = "java-delegate-listener-injection"; private static final String VERSION_PROVIDER_PATH = IRepositoryStructure.PATH_REGISTRY_PUBLIC + "/" + PROJECT + "/delegateinjection/VersionProvider.java"; @@ -134,6 +136,17 @@ void a_recompiled_collaborator_reaches_the_cached_delegate() { assertHistoricVariable(startProcess(VERSION_PROCESS), "version", "v2"); } + @Test + void both_listener_kinds_wire_their_collaborators() { + String instanceId = startProcess(LISTENER_PROCESS); + + // flowable:executionListener, single constructor taking the @Component collaborator. Before + // #7222 the listener factory instantiated it reflectively, so this one could not even be + // built - and the @Inject task listener below ran with its field reading null. + assertRuntimeVariable(instanceId, "executionListenerRate", "42"); + assertRuntimeVariable(instanceId, "taskListenerRate", "42"); + } + private String startProcess(String processDefinitionKey) { String body = "{\"processDefinitionKey\":\"" + processDefinitionKey + "\",\"businessKey\":\"" + processDefinitionKey + "\",\"parameters\":\"{}\"}"; @@ -157,12 +170,27 @@ private void assertHistoricVariable(String processInstanceId, String name, Strin ASSERTION_TIMEOUT_SECONDS); } + /** + * The instance waits at its user task, so the listeners' writes are read from the RUNTIME set. Note + * the key: the runtime endpoint serves Flowable's own variable entities, whose property is + * {@code name} - {@code variableName} is the HISTORIC endpoint's spelling. + */ + private void assertRuntimeVariable(String processInstanceId, String name, String expectedValue) { + restAssuredExecutor.execute(() -> given().when() + .get("/services/bpm/bpm-processes/instance/" + processInstanceId + "/variables") + .then() + .statusCode(200) + .body("name", hasItem(name)) + .body("find { it.name == '" + name + "' }.value", equalTo(expectedValue)), + ASSERTION_TIMEOUT_SECONDS); + } + private void assertNoRuntimeVariable(String processInstanceId, String name) { restAssuredExecutor.execute(() -> given().when() .get("/services/bpm/bpm-processes/instance/" + processInstanceId + "/variables") .then() .statusCode(200) - .body("variableName", not(hasItem(name)))); + .body("name", not(hasItem(name)))); } private void write(String path, String content) { diff --git a/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java new file mode 100644 index 00000000000..dc2d381d3c4 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/CtorInjectedExecutionListener.java @@ -0,0 +1,35 @@ +/* + * 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 delegateinjection; + +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.delegate.ExecutionListener; + +/** + * Constructor injection in a {@code flowable:executionListener class="..."}. Like a delegate, a + * listener is created by the engine and is therefore never a container-owned bean - and until + * #7222 the listener path did not reach the container at all, so this class could not even be + * instantiated (no no-arg constructor). + */ +public class CtorInjectedExecutionListener implements ExecutionListener { + + private final RateProvider rates; + + public CtorInjectedExecutionListener(RateProvider rates) { + this.rates = rates; + } + + @Override + public void notify(DelegateExecution execution) { + execution.setVariable("executionListenerRate", rates.rate()); + } +} diff --git a/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java new file mode 100644 index 00000000000..abe1a1d1eb5 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/delegateinjection/FieldInjectedTaskListener.java @@ -0,0 +1,32 @@ +/* + * 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 delegateinjection; + +import org.eclipse.dirigible.sdk.component.Inject; +import org.flowable.task.service.delegate.DelegateTask; +import org.flowable.task.service.delegate.TaskListener; + +/** + * Field injection in a {@code flowable:taskListener class="..."} - the failure mode #7222 is about: + * the class instantiates either way, and before the fix the injected field simply read + * {@code null}. + */ +public class FieldInjectedTaskListener implements TaskListener { + + @Inject + private RateProvider rates; + + @Override + public void notify(DelegateTask delegateTask) { + delegateTask.setVariable("taskListenerRate", rates.rate()); + } +} diff --git a/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn new file mode 100644 index 00000000000..8061d8b7509 --- /dev/null +++ b/tests/tests-integrations/src/main/resources/JavaDelegateInjectionIT/listeners.bpmn @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + +