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
23 changes: 23 additions & 0 deletions components/engine/engine-java/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,19 @@ One Spring-singleton container, rebuilt per `ClientClassLoader` generation.
Ambiguity **refuses** rather than guessing, which is stricter than `Beans.get(SomeInterface.class)`
(that answers empty and falls through to the platform context, surfacing as "no such bean").
Unit coverage: `ComponentContainerUnmanagedTest`; end-to-end: `JavaDelegateInjectionIT`.
- **"A `JavaDelegate` must NOT be a `@Component`" is checked at publish, not per execution** (#7291).
`rebuild` flags a bean that implements `org.flowable.engine.delegate.JavaDelegate` — matched by
interface **name**, since `engine-java` cannot see the Flowable type — as a `wiringWarnings()` entry,
which `JavaSynchronizer` projects onto the Problems view while leaving the artefact `CREATED` (the
bean works; the annotation is the mistake). `createUnmanaged` still WARNs, because a delegate can
reach it from an AOT module the synchronizer never saw, but **once per class per generation and then
at DEBUG**: the `${JavaTask}` path wires a fresh delegate for every execution, so an unconditional
WARN restated the same fact on every tick of a step that runs all day. The suppression set is cleared
by `rebuild`, so a republish says it again. Its message says "instantiated outside the container"
rather than "is a JavaDelegate", because the check there is `isBean` on whatever class the caller
asked to wire — it must not claim more than it looked at. Coverage:
`ComponentContainerDelegateRuleTest` (with a name-only `org.flowable.engine.delegate.JavaDelegate`
stand-in under `src/test/java`, which is how the by-name match is testable without the dependency).

## Behaviour consumers (`JavaClassConsumer` SPI)

Expand Down Expand Up @@ -248,6 +261,9 @@ through `ClientBeanFactory.createUnmanaged` (see the container section):
new generation.
- **`${JavaTask}` + a `handler` field** — `DirigibleJavaCallDelegate`, fresh per execution.

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.

Three properties worth keeping: a delegate stays **lazy** (nothing is built at publish, so an
unsatisfiable dependency is a *step* failure routed by the step's `retry:` / `onError:`, never a
publish-time wiring error); a class declaring **no injection point is built exactly as before**; and
Expand Down Expand Up @@ -306,6 +322,13 @@ view and mark the `JavaFile` artefact `FAILED` (see `JavaSynchronizer.recordComp
`ComponentContainer.wiringErrors()` carried on `RebuildResult`). Don't regress this — it's how a
browser-IDE developer sees what's wrong without reading the server log.

**Bean-wiring warnings** (`ComponentContainer.wiringWarnings()`, also on `RebuildResult`) take the same
route to the Problems view but leave the artefact `CREATED`: the class compiled and wired, it just
breaks a container rule. Today there is one — a bean that is also a `JavaDelegate` (#7291). Reach for a
warning rather than an error whenever the code still runs correctly enough that failing the artefact
would be a lie; reach for the Problems view rather than a log line whenever the audience is the
developer who wrote the line, not the operator who happened to run the process.

## Conventions / gotchas

- `@Roles` mirrors `UserFacade.isInRole` without pulling `api-security` (which would drag
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

import org.eclipse.dirigible.engine.java.runtime.ClientBeanFactory;
import org.eclipse.dirigible.engine.java.runtime.ClientBeansHolder;
Expand Down Expand Up @@ -62,6 +63,9 @@ public class ComponentContainer implements ClientBeanFactory {

private static final Logger LOGGER = LoggerFactory.getLogger(ComponentContainer.class);

/** Flowable's delegate interface, referenced by name — see {@link #isJavaDelegate(Class)}. */
private static final String FLOWABLE_JAVA_DELEGATE = "org.flowable.engine.delegate.JavaDelegate";

/** Definitions of the live generation, in registration order. */
private volatile List<BeanDefinition> definitions = List.of();

Expand All @@ -74,6 +78,16 @@ public class ComponentContainer implements ClientBeanFactory {
/** client class FQN → wiring error from the last rebuild (so the synchronizer can surface it). */
private volatile Map<String, String> wiringErrors = Map.of();

/** client class FQN → wiring warning from the last rebuild (surfaced, but not a failure). */
private volatile Map<String, String> wiringWarnings = Map.of();

/**
* Classes already warned about on the {@link #createUnmanaged(Class)} path in this generation, so
* the same fact is stated once and then only at DEBUG. Cleared by {@link #rebuild(Collection)}: a
* republish is a new generation, and the developer who just changed the class should hear it again.
*/
private final Set<String> reportedUnmanagedBeans = ConcurrentHashMap.newKeySet();

public ComponentContainer(ClientBeansHolder holder) {
holder.swap(this);
}
Expand All @@ -90,6 +104,19 @@ public Map<String, String> wiringErrors() {
return wiringErrors;
}

/**
* Wiring <em>warnings</em> from the last {@link #rebuild(Collection)} keyed by client class FQN —
* today exactly one: a bean that is also a Flowable {@code JavaDelegate}. The class still works, so
* this is deliberately not a {@link #wiringErrors() wiring error} (the artefact stays healthy); it
* is surfaced on the Problems view at publish because that is where the developer who annotated the
* class looks, whereas the execution-time WARN only reaches whoever happens to run the process.
*
* @return an immutable FQN → message map (empty if the last rebuild had nothing to warn about)
*/
public Map<String, String> wiringWarnings() {
return wiringWarnings;
}

/**
* Re-create the whole client bean set for a new generation. Builds and instantiates the new beans
* first, publishes them atomically, then tears down the previous generation (so reads transition
Expand All @@ -105,6 +132,7 @@ public synchronized void rebuild(Collection<LoadedClass> loaded) {
Map<String, BeanDefinition> byName = new LinkedHashMap<>();
List<BeanDefinition> ordered = new ArrayList<>();
Map<String, String> errors = new LinkedHashMap<>();
Map<String, String> warnings = new LinkedHashMap<>();
ClassLoader loader = null;
for (LoadedClass info : loaded) {
if (info == null) {
Expand All @@ -115,6 +143,14 @@ public synchronized void rebuild(Collection<LoadedClass> loaded) {
continue;
}
loader = info.loader();
if (isJavaDelegate(type)) {
// The bean is still registered - the annotation is the mistake, not the class - so this
// rebuild behaves exactly as it did before the check existed, and the only new effect is
// the Problems entry the synchronizer projects from wiringWarnings().
String message = componentOnDelegateMessage(type.getName());
LOGGER.warn(message);
warnings.put(type.getName(), message);
}
try {
String name = beanName(type);
BeanDefinition existing = byName.get(name);
Expand Down Expand Up @@ -173,6 +209,8 @@ public synchronized void rebuild(Collection<LoadedClass> loaded) {
this.singletons = java.util.Collections.unmodifiableMap(snapshot);
this.instancesByType = java.util.Collections.unmodifiableMap(byType);
this.wiringErrors = Map.copyOf(errors);
this.wiringWarnings = Map.copyOf(warnings);
reportedUnmanagedBeans.clear();

destroy(previousDefinitions, previousSingletons);
LOGGER.info("Client bean container rebuilt: {} bean(s).", snapshot.size());
Expand Down Expand Up @@ -436,9 +474,17 @@ public <T> List<T> getAll(Class<T> type) {
@Override
public <T> Optional<T> createUnmanaged(Class<T> type) {
if (isBean(type)) {
LOGGER.warn(
"[{}] is a JavaDelegate annotated @Component. A JavaDelegate must NOT be a @Component: Flowable instantiates the delegate itself, so the annotation additionally builds a container-managed singleton the engine never runs — a stray candidate for every List<JavaDelegate> injection. Remove @Component from the delegate.",
type.getName());
// Once per class per generation, then DEBUG: on the ${JavaTask} path a fresh delegate is
// wired for every execution, so an unconditional WARN would restate the same fact on every
// tick of a step that runs all day. The publish-time entry in wiringWarnings() is the one a
// developer is meant to read; this line only serves whoever is already reading the log.
if (reportedUnmanagedBeans.add(type.getName())) {
LOGGER.warn(componentOnUnmanagedMessage(type.getName()));
} else if (LOGGER.isDebugEnabled()) {
// Guarded because this runs per step execution: with DEBUG off, the suppressed repeat
// must not even build its message.
LOGGER.debug(componentOnUnmanagedMessage(type.getName()));
}
}
BeanDefinition definition = new BeanDefinition(type.getName(), type);
if (!declaresInjectionPoint(definition)) {
Expand Down Expand Up @@ -472,6 +518,44 @@ private static boolean isBean(Class<?> type) {
return AnnotatedElementUtils.hasAnnotation(type, Component.class);
}

/**
* Whether {@code type} is a Flowable {@code JavaDelegate}, matched by interface <em>name</em>:
* {@code engine-java} cannot see the Flowable type ({@code engine-bpm-flowable} depends on this
* module, not the other way round), which is also why the {@link #createUnmanaged(Class)} check is
* the broader {@code isBean}.
*/
private static boolean isJavaDelegate(Class<?> type) {
for (Class<?> current = type; current != null && current != Object.class; current = current.getSuperclass()) {
for (Class<?> implemented : current.getInterfaces()) {
if (FLOWABLE_JAVA_DELEGATE.equals(implemented.getName()) || isJavaDelegate(implemented)) {
return true;
}
}
}
return false;
}

/** The publish-time wording: the rebuild knows the bean is a delegate, so it says so. */
private static String componentOnDelegateMessage(String className) {
return "[" + className + "] implements " + FLOWABLE_JAVA_DELEGATE + " and is annotated @Component. A JavaDelegate"
+ " must NOT be a @Component: Flowable instantiates the delegate itself, so the annotation additionally builds"
+ " a container-managed singleton the engine never runs — a stray candidate for every List<JavaDelegate>"
+ " injection. Remove @Component from the delegate.";
}

/**
* The execution-time wording. It says {@code instantiated outside the container} rather than
* {@code JavaDelegate}, because the detection here is {@code isBean} on whatever class the caller
* asked to wire unmanaged - true of a delegate today, but the message must not claim more than it
* actually checked.
*/
private static String componentOnUnmanagedMessage(String className) {
return "[" + className + "] is annotated @Component but is instantiated outside the container (which is what a"
+ " JavaDelegate is: Flowable instantiates it itself). Such a class must NOT be a @Component: the annotation"
+ " additionally builds a container-managed singleton nothing ever runs — a stray candidate for every"
+ " collection injection point of its type. Remove @Component from it.";
}

private static String beanName(Class<?> type) {
Component component = AnnotatedElementUtils.findMergedAnnotation(type, Component.class);
if (component != null && !component.value()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ public synchronized RebuildResult rebuild(List<ClientSource> sources) {
currentBytecode.putAll(effectiveBytecode);

RebuildResult result = new RebuildResult(Collections.unmodifiableSet(succeeded), Collections.unmodifiableMap(failures),
Collections.unmodifiableSet(removed), Collections.unmodifiableMap(batch.diagnostics()), componentContainer.wiringErrors());
Collections.unmodifiableSet(removed), Collections.unmodifiableMap(batch.diagnostics()), componentContainer.wiringErrors(),
componentContainer.wiringWarnings());

// Writes this cycle's fresh bytecode and deletes only source-removed FQNs. Carried-over
// (failed-to-recompile) classes keep their existing .class files untouched.
Expand Down Expand Up @@ -392,9 +393,12 @@ public record ClientSource(String project, String fqn, String source) {
* @param wiringErrors per FQN → a bean-container wiring error (unsatisfied/ambiguous dependency,
* construction cycle, duplicate bean name, throwing constructor) for classes that compiled
* but could not be wired
* @param wiringWarnings per FQN → a bean-container wiring warning for classes that compiled and
* wired fine but break a rule (today: a bean that is also a Flowable {@code JavaDelegate}).
* Surfaced on the Problems view like an error, but it does not fail the artefact
*/
public record RebuildResult(Set<String> succeededFqns, Map<String, String> failures, Set<String> unloadedFqns,
Map<String, List<CompileDiagnostic>> diagnostics, Map<String, String> wiringErrors) {
Map<String, List<CompileDiagnostic>> diagnostics, Map<String, String> wiringErrors, Map<String, String> wiringWarnings) {
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -292,17 +292,28 @@ private boolean rebuildAll() {
file.setLifecycle(ArtefactLifecycle.CREATED);
file.setError(null);
javaFileService.save(file);
clearCompilationProblems(file.getLocation());
String warning = result.wiringWarnings()
.get(fqn);
if (warning != null) {
// Compiled and wired, but breaks a container rule (today: a bean that is also a
// JavaDelegate). The artefact stays CREATED - it works - yet the entry lands in the
// Problems view at publish, in front of the developer who wrote the annotation,
// rather than only in a WARN whoever runs the process later may or may not read.
recordCompilationProblems(file.getLocation(), List.of(), warning);
} else {
clearCompilationProblems(file.getLocation());
}
}
}
return true;
}

/**
* Project a file's compile failure onto the Problems view: replace its previous compilation
* problems (so resolved errors disappear), then add one entry per structured diagnostic at its
* line/column - or a single entry with the formatted message when no positioned diagnostic is
* available (e.g. a read failure or a class that compiled but failed to load).
* Project a file's compile failure - or a wiring error/warning - onto the Problems view: replace
* its previous compilation problems (so resolved ones disappear), then add one entry per structured
* diagnostic at its line/column - or a single entry with the formatted message when no positioned
* diagnostic is available (e.g. a read failure, a class that compiled but failed to load, or a bean
* that broke a container rule).
*/
private void recordCompilationProblems(String location, List<CompileDiagnostic> diagnostics, String message) {
try {
Expand Down
Loading
Loading