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
@@ -0,0 +1,145 @@
/*
* 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.engine.java.runtime;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

import org.eclipse.dirigible.engine.java.spi.LoadedClass;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;

/**
* Discovers AOT-packaged {@code compiled} modules on the application classpath and registers their
* already-compiled classes through {@link JavaLoader#installCompiledModules(List)} - <b>without</b>
* any runtime compilation.
*
* <p>
* A compiled module jar carries a marker at {@code META-INF/dirigible/<project>/.compiled} - a
* UTF-8 text file listing the module's top-level class binary names (its controllers / repositories
* / delegates / listeners / …), one per line (blank lines and {@code #} comments ignored). The
* build emits it; it names exactly the classes the engine should surface to the
* {@code JavaClassConsumer}s.
*
* <p>
* Discovery reads the markers directly from the classpath (so it does not depend on the
* {@code ClasspathExpander} having laid the module's registry payload down first) and loads each
* listed class through the <em>application</em> classloader - the same one that already holds the
* compiled module jar. It runs once, on {@link ApplicationReadyEvent}; a later registry rebuild
* unions its classes back in (see {@link JavaLoader}).
*/
@Component
public class CompiledModuleClassProvider {

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

/**
* Marker location pattern: one path segment ({@code <project>}) under {@code META-INF/dirigible}.
*/
private static final String MARKER_PATTERN = "classpath*:META-INF/dirigible/*/.compiled";

private static final String DIRIGIBLE_ROOT = "META-INF/dirigible/";

private final JavaLoader javaLoader;

@Autowired
public CompiledModuleClassProvider(JavaLoader javaLoader) {
this.javaLoader = javaLoader;
}

/** Discover and register the compiled modules once the application context is ready. */
@EventListener(ApplicationReadyEvent.class)
public void registerCompiledModules() {
List<LoadedClass> classes = discover();
if (classes.isEmpty()) {
return;
}
javaLoader.installCompiledModules(classes);
LOGGER.info("Registered [{}] class(es) from AOT compiled module(s) on the classpath", classes.size());
}

/**
* Scan the classpath for {@code .compiled} markers and load every listed class through the
* application classloader. Package-visible for testing. Never throws: an unreadable marker or an
* unloadable class is logged and skipped so one bad module cannot block the rest.
*/
List<LoadedClass> discover() {
List<LoadedClass> result = new ArrayList<>();
ClassLoader classLoader = getClass().getClassLoader();
Resource[] markers;
try {
markers = new PathMatchingResourcePatternResolver(classLoader).getResources(MARKER_PATTERN);
} catch (IOException e) {
LOGGER.error("Failed to scan the classpath for AOT compiled-module markers", e);
return result;
}
for (Resource marker : markers) {
String project = projectOf(marker);
for (String fqn : readClassNames(marker)) {
try {
Class<?> type = Class.forName(fqn, true, classLoader);
result.add(new LoadedClass(project, fqn, type, classLoader));
} catch (ClassNotFoundException | LinkageError e) {
LOGGER.error("Compiled-module class [{}] (project [{}]) could not be loaded: {}", fqn, project, e.getMessage(), e);
}
}
}
return result;
}

/**
* The {@code <project>} path segment immediately under {@code META-INF/dirigible/} in the marker
* URL.
*/
private static String projectOf(Resource marker) {
try {
String url = marker.getURL()
.toString();
int at = url.indexOf(DIRIGIBLE_ROOT);
if (at < 0) {
return "";
}
String rest = url.substring(at + DIRIGIBLE_ROOT.length());
int slash = rest.indexOf('/');
return slash < 0 ? rest : rest.substring(0, slash);
} catch (IOException e) {
LOGGER.error("Failed to resolve the project of compiled-module marker [{}]: {}", marker, e.getMessage(), e);
return "";
}
}

/** Non-blank, non-comment lines of a marker, trimmed. */
private static List<String> readClassNames(Resource marker) {
List<String> names = new ArrayList<>();
try (BufferedReader reader = new BufferedReader(new InputStreamReader(marker.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
String trimmed = line.trim();
if (!trimmed.isEmpty() && !trimmed.startsWith("#")) {
names.add(trimmed);
}
}
} catch (IOException e) {
LOGGER.error("Failed to read compiled-module marker [{}]: {}", marker, e.getMessage(), e);
}
return names;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,19 @@ public class JavaLoader {
private final JavaCompiledOutputDirectory outputDirectory;
private final ApplicationEventPublisher eventPublisher;

/** FQN → {@link LoadedClass} record for the currently-installed generation. */
/** FQN → {@link LoadedClass} record for the currently-installed generation (the union below). */
private final Map<String, LoadedClass> currentGeneration = new HashMap<>();

/** Registry-compiled sub-generation (produced by {@link #rebuild}). */
private final Map<String, LoadedClass> registryGeneration = new HashMap<>();

/**
* Classpath-discovered sub-generation for AOT-packaged {@code compiled} modules (produced by
* {@link #installCompiledModules}). These classes are already loaded by the application classloader
* - never compiled at runtime. The installed generation is the union of the two.
*/
private final Map<String, LoadedClass> compiledGeneration = new HashMap<>();

/**
* Binary class name (top-level + nested) → bytecode for the currently-installed generation.
* Retained so a class that fails to recompile this cycle can fall back to its last-good bytecode
Expand Down Expand Up @@ -160,6 +170,42 @@ public synchronized RebuildResult rebuild(List<ClientSource> sources) {
}
}

// Install the new generation as the union of the registry-compiled classes (this rebuild) and
// any classpath-discovered compiled modules. Extracted (applyGeneration) so both producers
// drive the identical consumer/container dispatch.
registryGeneration.clear();
registryGeneration.putAll(nextGeneration);
Set<String> removed = applyGeneration(mergedGeneration(), nextLoader);

currentBytecode.clear();
currentBytecode.putAll(effectiveBytecode);

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

// Writes this cycle's fresh bytecode and deletes only source-removed FQNs. Carried-over
// (failed-to-recompile) classes keep their existing .class files untouched.
writeClassFiles(batch, result.unloadedFqns());
eventPublisher.publishEvent(new JavaCompiledEvent(this, result.succeededFqns(), result.unloadedFqns()));

return result;
}

/**
* Install a freshly-built generation into the running state: diff it against the current
* generation, notify every {@link JavaClassConsumer} ({@code onClassUnloaded} for removed/replaced
* FQNs, then {@code onClassLoaded} for the new set), swap the client {@link ClassLoader}, and
* rebuild the client bean container so behaviour consumers wire over ready instances.
*
* <p>
* <b>Source-agnostic by design.</b> The generation is just a {@code FQN -> LoadedClass} map plus
* the loader to install; it may originate from compiled registry sources (the {@link #rebuild}
* path) or, for AOT-packaged {@code compiled} modules, from classes discovered on the application
* classpath. Both drive the identical consumer/container dispatch here.
*
* @return the FQNs removed from the generation (present before, absent now)
*/
private Set<String> applyGeneration(Map<String, LoadedClass> nextGeneration, ClientClassLoader nextLoader) {
// Diff against the previous generation: notify consumers of removals first, then additions.
Set<String> previousFqns = new HashSet<>(currentGeneration.keySet());
Set<String> nextFqns = new HashSet<>(nextGeneration.keySet());
Expand Down Expand Up @@ -195,18 +241,51 @@ public synchronized RebuildResult rebuild(List<ClientSource> sources) {

currentGeneration.clear();
currentGeneration.putAll(nextGeneration);
currentBytecode.clear();
currentBytecode.putAll(effectiveBytecode);

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

// Writes this cycle's fresh bytecode and deletes only source-removed FQNs. Carried-over
// (failed-to-recompile) classes keep their existing .class files untouched.
writeClassFiles(batch, result.unloadedFqns());
eventPublisher.publishEvent(new JavaCompiledEvent(this, result.succeededFqns(), result.unloadedFqns()));
/**
* Install the classes of AOT-packaged {@code compiled} modules discovered on the application
* classpath. Unlike {@link #rebuild}, these are <b>not compiled at runtime</b> - they are already
* loaded by the application classloader; this only records them as the compiled sub-generation and
* installs the union with the current registry-compiled generation through {@link #applyGeneration}
* (the same consumer/container dispatch). Idempotent: re-invoking replaces the compiled set.
*
* @param classes the compiled modules' top-level classes to surface to consumers
* @return the FQNs removed from the installed generation as a result (compiled classes dropped
* since the previous compiled install; never registry-source classes)
*/
public synchronized Set<String> installCompiledModules(List<LoadedClass> classes) {
compiledGeneration.clear();
for (LoadedClass info : classes) {
if (info != null) {
compiledGeneration.put(info.fqn(), info);
}
}
// No runtime-compiled client code yet → give the holder an empty ClientClassLoader whose parent
// (the platform / application classloader) already resolves the compiled-module classes.
ClientClassLoader loader = loaderHolder.current();
if (loader == null) {
loader = new ClientClassLoader(JavaHandler.class.getClassLoader(), Map.of());
}
return applyGeneration(mergedGeneration(), loader);
}

return result;
/**
* The installed generation: the union of the registry-compiled and classpath-compiled
* sub-generations. On an FQN clash (a capability provided by both a registry source and a compiled
* module - a mis-assembled image) the registry source wins and the clash is logged; a clean
* "exactly one provider" assembly check is expected to catch this earlier.
*/
private Map<String, LoadedClass> mergedGeneration() {
Map<String, LoadedClass> union = new LinkedHashMap<>(compiledGeneration);
for (Map.Entry<String, LoadedClass> entry : registryGeneration.entrySet()) {
if (union.put(entry.getKey(), entry.getValue()) != null) {
LOGGER.warn("FQN [{}] is provided by BOTH a compiled module and a registry source; using the registry source",
entry.getKey());
}
}
return union;
}

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

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.nio.file.Path;
import java.util.List;
import java.util.Optional;

import org.eclipse.dirigible.engine.java.component.ComponentContainer;
import org.eclipse.dirigible.engine.java.runtime.ClientBeansHolder;
import org.eclipse.dirigible.engine.java.runtime.ClientClassLoaderHolder;
import org.eclipse.dirigible.engine.java.runtime.JavaCompiledOutputDirectory;
import org.eclipse.dirigible.engine.java.runtime.JavaLoader;
import org.eclipse.dirigible.engine.java.runtime.JavaSourceCompiler;
import org.eclipse.dirigible.engine.java.spi.LoadedClass;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.context.ApplicationEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.http.HttpMethod;

/**
* End-to-end (within engine-java): a controller shipped by an AOT-packaged {@code compiled} module
* - installed through {@code JavaLoader.installCompiledModules} rather than compiled from registry
* sources - registers a real route in the {@link ControllerRouter} via the standard
* {@link ControllerClassConsumer}. No runtime {@code javac} is involved.
*/
class CompiledModuleControllerRegistrationTest {

private static final ApplicationEventPublisher NOOP_PUBLISHER = new ApplicationEventPublisher() {
@Override
public void publishEvent(ApplicationEvent event) {
// intentionally empty
}

@Override
public void publishEvent(Object event) {

Check notice

Code scanning / CodeQL

Confusing overloading of methods Note test

Method new ApplicationEventPublisher(...) { ... }.publishEvent(..) could be confused with overloaded method
publishEvent
, since dispatch depends on static types.
// intentionally empty
}
};

@TempDir
Path tempDir;

@Test
void compiled_module_controller_registers_a_route_end_to_end() {
ControllerRouter router = new ControllerRouter();
ComponentContainer container = new ComponentContainer(new ClientBeansHolder());
ControllerClassConsumer controllerConsumer = new ControllerClassConsumer(router, Optional.empty(), container);

JavaCompiledOutputDirectory outputDirectory = mock(JavaCompiledOutputDirectory.class);
when(outputDirectory.get()).thenReturn(tempDir);
JavaLoader loader = new JavaLoader(new JavaSourceCompiler(), new ClientClassLoaderHolder(), container, List.of(controllerConsumer),
outputDirectory, NOOP_PUBLISHER);

// A compiled-module controller already on the classpath (no registry .java, no javac).
Class<?> type = CompiledSampleController.class;
String project = "acme-mod";
loader.installCompiledModules(List.of(new LoadedClass(project, type.getName(), type, type.getClassLoader())));

// The route is resolvable through the router - base path is /<fqn-with-slashes>, suffix /ping.
String base = "/" + type.getName()
.replace('.', '/');
RouteMatch match = router.match(HttpMethod.GET, project, base + "/ping")
.orElseThrow(() -> new AssertionError("compiled-module controller route not registered"));
assertEquals(type.getName(), match.entry()
.fqn());
assertEquals(HttpMethod.GET, match.route()
.httpMethod());
assertTrue(router.match(HttpMethod.POST, project, base + "/ping")
.isEmpty(),
"only the declared GET route matches");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* 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.engine.java.controller;

import org.eclipse.dirigible.sdk.http.Controller;
import org.eclipse.dirigible.sdk.http.Get;

/**
* Stand-in for a controller shipped by an AOT-packaged {@code compiled} module (already on the
* classpath). Registered end-to-end via {@code JavaLoader.installCompiledModules} in
* {@link CompiledModuleControllerRegistrationTest}.
*/
@Controller
public class CompiledSampleController {

@Get("/ping")
public String ping() {
return "pong";
}
}
Loading
Loading