From 583a6e2301b276c02cedf6cf49203bd2ec56a2d7 Mon Sep 17 00:00:00 2001 From: MalickBurger Date: Fri, 13 Mar 2026 13:26:52 +0000 Subject: [PATCH] perf: batch POM resolution and cache parent POM lookups Resolve all component POM files in a single Gradle artifact resolution query instead of N individual calls. Share a single ModelResolver instance per project so parent POM resolutions are cached across components. Signed-off-by: MalickBurger --- .../GradleAssistedMavenModelResolverImpl.java | 15 +++-- .../org/cyclonedx/gradle/MavenHelper.java | 9 +-- .../cyclonedx/gradle/MavenProjectLookup.java | 67 ++++++++++++------- .../cyclonedx/gradle/SbomGraphProvider.java | 7 +- .../gradle/DependencyResolutionSpec.groovy | 56 ++++++++++++++++ 5 files changed, 117 insertions(+), 37 deletions(-) diff --git a/src/main/java/org/cyclonedx/gradle/GradleAssistedMavenModelResolverImpl.java b/src/main/java/org/cyclonedx/gradle/GradleAssistedMavenModelResolverImpl.java index 8847795c..86f602a7 100644 --- a/src/main/java/org/cyclonedx/gradle/GradleAssistedMavenModelResolverImpl.java +++ b/src/main/java/org/cyclonedx/gradle/GradleAssistedMavenModelResolverImpl.java @@ -23,6 +23,8 @@ import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.util.HashMap; +import java.util.Map; import org.apache.maven.model.Dependency; import org.apache.maven.model.Parent; import org.apache.maven.model.Repository; @@ -33,21 +35,26 @@ import org.jspecify.annotations.Nullable; public class GradleAssistedMavenModelResolverImpl implements ModelResolver { + private final Project project; + private final Map resolvedPomCache; public GradleAssistedMavenModelResolverImpl(Project project) { super(); this.project = project; + this.resolvedPomCache = new HashMap<>(); } @Override public ModelSource2 resolveModel(String groupId, String artifactId, String version) { String depNotation = String.format("%s:%s:%s@pom", groupId, artifactId, version); - org.gradle.api.artifacts.Dependency dependency = - project.getDependencies().create(depNotation); - Configuration config = project.getConfigurations().detachedConfiguration(dependency); - File pomXml = config.getSingleFile(); + File pomXml = resolvedPomCache.computeIfAbsent(depNotation, notation -> { + org.gradle.api.artifacts.Dependency dependency = + project.getDependencies().create(notation); + Configuration config = project.getConfigurations().detachedConfiguration(dependency); + return config.getSingleFile(); + }); return new ModelSource2() { @Override public InputStream getInputStream() throws IOException { diff --git a/src/main/java/org/cyclonedx/gradle/MavenHelper.java b/src/main/java/org/cyclonedx/gradle/MavenHelper.java index e3a2d89c..2de03584 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenHelper.java +++ b/src/main/java/org/cyclonedx/gradle/MavenHelper.java @@ -48,7 +48,6 @@ import org.cyclonedx.model.ExternalReference; import org.cyclonedx.model.LicenseChoice; import org.cyclonedx.util.LicenseResolver; -import org.gradle.api.Project; import org.gradle.api.artifacts.ModuleVersionIdentifier; import org.gradle.api.artifacts.ResolvedArtifact; import org.gradle.api.logging.Logger; @@ -381,13 +380,11 @@ private boolean doesComponentHaveExternalReference(final Component component, fi * * @param pomFile * the dependency pomFile - * @param gradleProject - * the current gradle project which gets used as the base resolver + * @param modelResolver + * the model resolver used to resolve parent POMs * @return model for effective pom */ - static @Nullable Model resolveEffectivePom(final @Nullable File pomFile, final Project gradleProject) { - // force the parent POMs and BOMs to be resolved - final ModelResolver modelResolver = new GradleAssistedMavenModelResolverImpl(gradleProject); + static @Nullable Model resolveEffectivePom(final @Nullable File pomFile, final ModelResolver modelResolver) { final ModelBuildingRequest req = new DefaultModelBuildingRequest(); req.setModelResolver(modelResolver); req.setPomFile(pomFile); diff --git a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java index 0de35f70..5c452f70 100644 --- a/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java +++ b/src/main/java/org/cyclonedx/gradle/MavenProjectLookup.java @@ -19,14 +19,14 @@ package org.cyclonedx.gradle; import java.io.File; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; -import java.util.Iterator; import java.util.Map; import org.apache.maven.model.Model; import org.apache.maven.project.MavenProject; import org.gradle.api.Project; import org.gradle.api.artifacts.component.ComponentIdentifier; -import org.gradle.api.artifacts.result.ArtifactResolutionResult; import org.gradle.api.artifacts.result.ArtifactResult; import org.gradle.api.artifacts.result.ComponentArtifactsResult; import org.gradle.api.artifacts.result.ResolvedArtifactResult; @@ -45,10 +45,32 @@ class MavenProjectLookup { private static final Logger LOGGER = Logging.getLogger(MavenProjectLookup.class); private final Project project; private final Map cache; + private final Map pomFileCache; + private final GradleAssistedMavenModelResolverImpl modelResolver; MavenProjectLookup(final Project project) { this.project = project; this.cache = new HashMap<>(); + this.pomFileCache = new HashMap<>(); + this.modelResolver = new GradleAssistedMavenModelResolverImpl(project); + } + + /** + * Resolves POM files for all provided component identifiers in a single batch query. + * This is significantly faster than resolving them one at a time, as it avoids + * N individual Gradle artifact resolution API calls. + * + * @param componentIds the component identifiers to resolve POM files for + */ + void batchResolvePomFiles(final Collection componentIds) { + if (componentIds.isEmpty()) { + return; + } + + LOGGER.info("CycloneDX: Batch resolving {} POM files", componentIds.size()); + resolvePomFiles(componentIds); + LOGGER.info( + "CycloneDX: Batch resolved {} POM files out of {} requested", pomFileCache.size(), componentIds.size()); } /** @@ -74,7 +96,7 @@ class MavenProjectLookup { final MavenProject mavenProject = MavenHelper.readPom(pomFile); if (mavenProject != null) { LOGGER.debug("CycloneDX: parse queried pom file for component {}", result.getId()); - final Model model = MavenHelper.resolveEffectivePom(pomFile, project); + final Model model = MavenHelper.resolveEffectivePom(pomFile, modelResolver); if (model != null) { mavenProject.setLicenses(model.getLicenses()); } @@ -89,32 +111,25 @@ class MavenProjectLookup { } @Nullable File buildMavenProject(final ComponentIdentifier id) { + if (!pomFileCache.containsKey(id)) { + resolvePomFiles(Collections.singletonList(id)); + } + return pomFileCache.get(id); + } - final ArtifactResolutionResult result = project.getDependencies() + private void resolvePomFiles(final Collection componentIds) { + for (final ComponentArtifactsResult componentResult : project.getDependencies() .createArtifactResolutionQuery() - .forComponents(id) + .forComponents(componentIds) .withArtifacts(MavenModule.class, MavenPomArtifact.class) - .execute(); - - final Iterator componentIt = - result.getResolvedComponents().iterator(); - if (!componentIt.hasNext()) { - return null; - } - - final Iterator artifactIt = - componentIt.next().getArtifacts(MavenPomArtifact.class).iterator(); - if (!artifactIt.hasNext()) { - return null; - } - - final ArtifactResult artifact = artifactIt.next(); - if (artifact instanceof ResolvedArtifactResult) { - LOGGER.debug("CycloneDX: found pom file for component {}", id); - final ResolvedArtifactResult resolvedArtifact = (ResolvedArtifactResult) artifact; - return resolvedArtifact.getFile(); + .execute() + .getResolvedComponents()) { + for (final ArtifactResult artifact : componentResult.getArtifacts(MavenPomArtifact.class)) { + if (artifact instanceof ResolvedArtifactResult) { + pomFileCache.put(componentResult.getId(), ((ResolvedArtifactResult) artifact).getFile()); + LOGGER.debug("CycloneDX: found pom file for component {}", componentResult.getId()); + } + } } - - return null; } } diff --git a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java index 81f82757..08ebf0ac 100644 --- a/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java +++ b/src/main/java/org/cyclonedx/gradle/SbomGraphProvider.java @@ -136,7 +136,12 @@ private SbomGraph buildSbomGraph(final Map graph } private Stream> traverseProject() { - final DependencyGraphTraverser traverser = new DependencyGraphTraverser(getArtifacts(), mavenLookup, task); + final Map artifacts = getArtifacts(); + + // Batch resolve all POM files in a single query before traversal + mavenLookup.batchResolvePomFiles(artifacts.keySet()); + + final DependencyGraphTraverser traverser = new DependencyGraphTraverser(artifacts, mavenLookup, task); return getInScopeConfigurations() .map(config -> traverser.traverseGraph( config.getIncoming().getResolutionResult().getRoot(), project.getName(), config.getName())); diff --git a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy index 38bbcb39..90584fc6 100644 --- a/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy +++ b/src/test/groovy/org/cyclonedx/gradle/DependencyResolutionSpec.groovy @@ -545,6 +545,62 @@ class DependencyResolutionSpec extends Specification { javaVersion = JavaVersion.current() } + def "should resolve licenses across subprojects sharing transitive dependencies"() { + given: + File testDir = File.createTempDir("multi-deps-") + new File(testDir, "settings.gradle").text = """ + rootProject.name = 'multi-deps' + include 'lib-a', 'lib-b' + """ + new File(testDir, "build.gradle").text = """ + plugins { + id 'org.cyclonedx.bom' + id 'java' + } + group = 'com.example' + version = '1.0.0' + allprojects { repositories { mavenCentral() } } + subprojects { group = 'com.example'; version = '1.0.0' } + """ + ['lib-a', 'lib-b'].each { name -> + def dir = new File(testDir, name) + dir.mkdirs() + new File(dir, "build.gradle").text = """ + plugins { id 'java-library' } + dependencies { + implementation 'com.google.guava:guava:33.4.0-jre' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.3' + } + """ + } + + when: + def result = GradleRunner.create() + .withProjectDir(testDir) + .withArguments(TestUtils.arguments("cyclonedxBom")) + .withPluginClasspath() + .build() + + then: + result.task(":cyclonedxBom").outcome == TaskOutcome.SUCCESS + File jsonBom = new File(testDir, "build/reports/cyclonedx/bom.json") + Bom bom = new ObjectMapper().readValue(jsonBom, Bom.class) + + def guava = bom.getComponents().find { it.name == 'guava' } + def jackson = bom.getComponents().find { it.name == 'jackson-databind' } + + assert guava != null + assert jackson != null + assert guava.getLicenses() != null && !guava.getLicenses().getLicenses().isEmpty() + assert jackson.getLicenses() != null && !jackson.getLicenses().getLicenses().isEmpty() + + and: "POM files were batch-resolved rather than individually" + result.output.contains("Batch resolved") + + where: + javaVersion = JavaVersion.current() + } + private static def loadJsonBom(File file) { return new JsonSlurper().parse(file) }