Skip to content

Commit 374dd0c

Browse files
authored
plugin-startup || Add single-threaded executor for plugin loading (#2789)
1 parent e5a9f86 commit 374dd0c

5 files changed

Lines changed: 127 additions & 14 deletions

File tree

build.gradle

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,16 +156,34 @@ sourceSets {
156156
java {
157157
srcDirs "$buildDir/generated/src/main/java"
158158
}
159+
resources {
160+
srcDir "$buildDir/generated/spring-modulith"
161+
}
159162
}
160163
}
161164

165+
tasks.register('generateModulithMetadata', JavaExec) {
166+
dependsOn tasks.named('compileJava')
167+
group = 'build'
168+
description = 'Precomputes Spring Modulith application module metadata (ArchUnit classpath scan) at build ' +
169+
'time, so the app loads it from the classpath instead of repeating the scan on every startup.'
170+
mainClass.set('com.epam.reportportal.base.modulith.ApplicationModulesMetadataGenerator')
171+
classpath = files(sourceSets.main.java.classesDirectory) + configurations.runtimeClasspath
172+
def outputFile = layout.buildDirectory.file(
173+
'generated/spring-modulith/META-INF/spring-modulith/application-modules.json')
174+
outputs.file(outputFile)
175+
args = [outputFile.get().asFile.absolutePath]
176+
}
177+
178+
processResources.dependsOn tasks.named('generateModulithMetadata')
179+
162180
openApiGenerate {
163181
generatorName.set("spring")
164182
inputSpec.set(file("$rootDir/api-registry/api/openapi/reportportal-api.yaml"))
165183
outputDir.set(layout.buildDirectory.dir("generated"))
166184
configFile.set(file("$rootDir/src/main/resources/openapi/config.json"))
167185
skipOverwrite.set(false)
168-
cleanupOutput.set(true)
186+
cleanupOutput.set(false)
169187
workerIsolation.set("process")
170188
// verbose.set(true)
171189
}

src/main/java/com/epam/reportportal/base/core/configs/ExecutorConfiguration.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,4 +169,21 @@ public TaskExecutor eventListenerExecutor(
169169
return threadPoolTaskExecutor;
170170
}
171171

172+
/**
173+
* Single-threaded on purpose: plugin loading already runs one plugin at a time
174+
* ({@link com.epam.reportportal.base.plugin.Pf4jPluginManager#startUp()}), and running several
175+
* plugin classloaders concurrently would only raise the peak memory this executor is meant to
176+
* avoid.
177+
*/
178+
@Bean(name = "pluginStartupExecutor")
179+
public TaskExecutor pluginStartupExecutor() {
180+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
181+
executor.setCorePoolSize(1);
182+
executor.setMaxPoolSize(1);
183+
executor.setQueueCapacity(1);
184+
executor.setThreadNamePrefix("plugin-startup-exec");
185+
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
186+
return executor;
187+
}
188+
172189
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2026 EPAM Systems
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.epam.reportportal.base.modulith;
18+
19+
import com.epam.reportportal.ReportPortalApp;
20+
import java.io.IOException;
21+
import java.nio.file.Files;
22+
import java.nio.file.Path;
23+
import org.springframework.modulith.core.ApplicationModules;
24+
import org.springframework.modulith.core.util.ApplicationModulesExporter;
25+
26+
/**
27+
* Build-time-only entry point (invoked by the {@code generateModulithMetadata} Gradle task, never packaged as a
28+
* running part of the application). Runs the same {@link ApplicationModules#of(Class)} ArchUnit classpath scan that
29+
* Spring Modulith would otherwise perform on every application startup, and writes the result to
30+
* {@link ApplicationModulesExporter#DEFAULT_LOCATION} on the compiled resources path. When that resource is present
31+
* on the runtime classpath, Spring Modulith's {@code PrecomputedApplicationModuleInitializerInvoker} uses it
32+
* directly instead of re-running the scan at boot, removing a memory/CPU spike from application startup.
33+
*/
34+
public final class ApplicationModulesMetadataGenerator {
35+
36+
private ApplicationModulesMetadataGenerator() {
37+
}
38+
39+
public static void main(String[] args) throws IOException {
40+
Path output = Path.of(args[0]);
41+
Files.createDirectories(output.getParent());
42+
43+
ApplicationModules modules = ApplicationModules.of(ReportPortalApp.class);
44+
Files.writeString(output, new ApplicationModulesExporter(modules).toFullJson());
45+
}
46+
}

src/main/java/com/epam/reportportal/base/plugin/Pf4jPluginManager.java

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -145,19 +145,26 @@ public <T> Optional<T> getInstance(Class<T> extension) {
145145
@Override
146146
public void startUp() {
147147
// load and start all enabled plugins of application
148-
integrationTypeRepository.findAll()
148+
List<IntegrationType> pluginsToLoad = integrationTypeRepository.findAll()
149149
.stream()
150150
.filter(IntegrationType::isEnabled)
151151
.filter(it -> it.getPluginType() == EXTENSION)
152-
.forEach(integrationType -> ofNullable(integrationType.getDetails()).ifPresent(
153-
integrationTypeDetails -> {
154-
try {
155-
loadPlugin(integrationType.getName(), integrationTypeDetails);
156-
} catch (Exception ex) {
157-
LOGGER.error("Unable to load plugin '{}'", integrationType.getName());
158-
}
159-
}));
152+
.toList();
160153

154+
LOGGER.info("Plugin startup: loading {} enabled extension plugin(s)", pluginsToLoad.size());
155+
long startUpBegin = System.currentTimeMillis();
156+
157+
pluginsToLoad.forEach(integrationType -> ofNullable(integrationType.getDetails()).ifPresent(
158+
integrationTypeDetails -> {
159+
try {
160+
loadPlugin(integrationType.getName(), integrationTypeDetails);
161+
} catch (Exception ex) {
162+
LOGGER.error("Unable to load plugin '{}'", integrationType.getName());
163+
}
164+
}));
165+
166+
LOGGER.info("Plugin startup: finished loading {} plugin(s) in {} ms", pluginsToLoad.size(),
167+
System.currentTimeMillis() - startUpBegin);
161168
}
162169

163170
@Override
@@ -180,6 +187,7 @@ public PluginState startUpPlugin(String pluginId) {
180187

181188
@Override
182189
public boolean loadPlugin(String pluginId, IntegrationTypeDetails integrationTypeDetails) {
190+
long loadBegin = System.currentTimeMillis();
183191
return ofNullable(integrationTypeDetails.getDetails()).map(details -> {
184192
String fileName = IntegrationTypeProperties.FILE_NAME.getValue(details)
185193
.map(String::valueOf)
@@ -189,7 +197,8 @@ public boolean loadPlugin(String pluginId, IntegrationTypeDetails integrationTyp
189197
));
190198

191199
Path pluginPath = Paths.get(pluginsDir, fileName);
192-
if (Files.notExists(pluginPath)) {
200+
boolean cached = Files.exists(pluginPath);
201+
if (!cached) {
193202
String fileId = IntegrationTypeProperties.FILE_ID.getValue(details)
194203
.map(String::valueOf)
195204
.orElseThrow(() -> new ReportPortalException(ErrorType.PLUGIN_UPLOAD_ERROR,
@@ -208,7 +217,7 @@ public boolean loadPlugin(String pluginId, IntegrationTypeDetails integrationTyp
208217
copyPluginResources(pluginPath, pluginId);
209218
}
210219

211-
return ofNullable(pluginManager.loadPlugin(pluginPath)).map(id -> {
220+
boolean result = ofNullable(pluginManager.loadPlugin(pluginPath)).map(id -> {
212221
if (PluginState.STARTED == pluginManager.startPlugin(pluginId)) {
213222
initPlugin(pluginId);
214223
applicationEventPublisher.publishEvent(
@@ -219,10 +228,25 @@ public boolean loadPlugin(String pluginId, IntegrationTypeDetails integrationTyp
219228
return false;
220229
}
221230
}).orElse(Boolean.FALSE);
231+
232+
LOGGER.info(
233+
"Plugin '{}' load finished: success={} cached={} sizeBytes={} tookMs={}",
234+
pluginId, result, cached, fileSizeOrUnknown(pluginPath),
235+
System.currentTimeMillis() - loadBegin
236+
);
237+
return result;
222238
}).orElse(Boolean.FALSE);
223239

224240
}
225241

242+
private long fileSizeOrUnknown(Path path) {
243+
try {
244+
return Files.size(path);
245+
} catch (IOException e) {
246+
return -1;
247+
}
248+
}
249+
226250
private void initPlugin(String pluginId) {
227251
try {
228252
Optional<org.pf4j.ExtensionPoint> extensionPoint = this.getInstance(pluginId,

src/main/java/com/epam/reportportal/base/plugin/PluginStartUpService.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
import com.epam.reportportal.base.infrastructure.persistence.dao.IntegrationTypeRepository;
2323
import com.epam.reportportal.extension.common.IntegrationTypeProperties;
2424
import com.google.common.collect.Lists;
25-
import jakarta.annotation.PostConstruct;
2625
import java.io.IOException;
2726
import java.net.URI;
2827
import java.nio.file.Files;
@@ -39,11 +38,19 @@
3938
import org.pf4j.update.UpdateManager;
4039
import org.pf4j.update.UpdateRepository;
4140
import org.springframework.beans.factory.annotation.Value;
41+
import org.springframework.boot.context.event.ApplicationReadyEvent;
42+
import org.springframework.context.event.EventListener;
43+
import org.springframework.scheduling.annotation.Async;
4244
import org.springframework.stereotype.Component;
4345

4446
/**
4547
* Optional download and load of default plugins on application startup.
4648
*
49+
* <p>Runs after the application context has finished refreshing and readiness probes can pass,
50+
* rather than blocking context startup ({@code @PostConstruct}) - loading every enabled plugin
51+
* eagerly on the same thread as the rest of the bean graph stacks its memory footprint on top of
52+
* Spring's own startup peak.
53+
*
4754
* @author <a href="mailto:pavel_bortnik@epam.com">Pavel Bortnik</a>
4855
*/
4956
@Slf4j
@@ -57,7 +64,8 @@ public class PluginStartUpService {
5764
@Value("${rp.plugins.default.load}")
5865
private boolean defaultPluginsLoad;
5966

60-
@PostConstruct
67+
@Async("pluginStartupExecutor")
68+
@EventListener(ApplicationReadyEvent.class)
6169
public void loadPlugins() {
6270
pluginBox.startUp();
6371
if (defaultPluginsLoad) {

0 commit comments

Comments
 (0)