From f3f479919032786e5e667a44b433cb536b011974 Mon Sep 17 00:00:00 2001 From: Geert Bevin Date: Sun, 12 Jul 2026 21:33:49 -0400 Subject: [PATCH] Parallelize transitive dependency resolution with speculative POM prefetching --- .../bld/dependencies/ArtifactRetriever.java | 9 ++ .../bld/dependencies/DependencyResolver.java | 57 +++++++++++ .../bld/dependencies/DependencyScopes.java | 8 +- .../rife/bld/dependencies/DependencySet.java | 44 +-------- .../bld/dependencies/ParallelExecution.java | 64 ++++++++++++ .../rife/bld/dependencies/PomPrefetcher.java | 75 ++++++++++++++ .../bld/dependencies/VersionResolution.java | 37 ++++++- .../dependencies/TestDependencyResolver.java | 99 +++++++++++++++++++ 8 files changed, 343 insertions(+), 50 deletions(-) create mode 100644 src/main/java/rife/bld/dependencies/ParallelExecution.java create mode 100644 src/main/java/rife/bld/dependencies/PomPrefetcher.java diff --git a/src/main/java/rife/bld/dependencies/ArtifactRetriever.java b/src/main/java/rife/bld/dependencies/ArtifactRetriever.java index aa69070..f477900 100644 --- a/src/main/java/rife/bld/dependencies/ArtifactRetriever.java +++ b/src/main/java/rife/bld/dependencies/ArtifactRetriever.java @@ -45,6 +45,10 @@ public abstract class ArtifactRetriever { void cache(RepositoryArtifact artifact, String content) { } + + boolean isCaching() { + return false; + } }; /** @@ -76,6 +80,9 @@ public abstract class ArtifactRetriever { artifactCache.put(artifact, content); } + boolean isCaching() { + return true; + } }; } @@ -86,6 +93,8 @@ public abstract class ArtifactRetriever { abstract void cache(RepositoryArtifact artifact, String content); + abstract boolean isCaching(); + /** * Reads the contents of an artifact as a string. * diff --git a/src/main/java/rife/bld/dependencies/DependencyResolver.java b/src/main/java/rife/bld/dependencies/DependencyResolver.java index b671f90..d9c0c1c 100644 --- a/src/main/java/rife/bld/dependencies/DependencyResolver.java +++ b/src/main/java/rife/bld/dependencies/DependencyResolver.java @@ -9,6 +9,7 @@ import rife.tools.exceptions.FileUtilsErrorException; import java.io.*; import java.util.*; +import java.util.function.Supplier; import java.util.stream.Collectors; import static rife.bld.dependencies.Dependency.*; @@ -121,6 +122,17 @@ public class DependencyResolver { * @since 1.5 */ public DependencySet getAllDependencies(Scope... scopes) { + var prefetcher = PomPrefetcher.create(resolution_, retriever_, repositories_); + try { + return getAllDependencies(prefetcher, scopes); + } finally { + if (prefetcher != null) { + prefetcher.shutdown(); + } + } + } + + DependencySet getAllDependencies(PomPrefetcher prefetcher, Scope... scopes) { var result = new DependencySet(); var overridden = resolution_.overrideDependency(dependency_); result.add(overridden); @@ -138,6 +150,11 @@ public class DependencyResolver { next_dependencies.removeIf(it -> matchesExclusions(exclusion_context, it)); // add all next dependencies to the queue dependency_queue.addAll(next_dependencies); + // speculatively retrieve the POMs of the queued dependencies in + // parallel so that they are cached when they're processed in order + if (prefetcher != null) { + prefetcher.prefetch(next_dependencies); + } // unless we find a next set of dependencies to add, stop resolving parent = null; @@ -163,6 +180,46 @@ public class DependencyResolver { return result; } + /** + * Resolves the transitive dependencies of multiple root dependencies, + * merging the results in the order of the provided roots. + *

+ * The roots are resolved in parallel according to the resolution + * parallelism, while a shared {@code PomPrefetcher} speculatively warms + * the retriever cache across all of them. The merged result is identical + * to resolving each root sequentially. + * + * @param resolution the version resolution state that can be cached + * @param retriever the retriever to use to get artifacts + * @param repositories the repositories to use for the resolution + * @param roots the root dependencies to resolve + * @param scopes the scopes to return the transitive dependencies for + * @return the merged transitive dependencies of all the roots + * @since 2.3.1 + */ + static DependencySet resolveAllDependencies(VersionResolution resolution, ArtifactRetriever retriever, List repositories, Collection roots, Scope... scopes) { + var result = new DependencySet(); + if (roots.isEmpty()) { + return result; + } + + var prefetcher = PomPrefetcher.create(resolution, retriever, repositories); + try { + var resolutions = new ArrayList>(roots.size()); + for (var root : roots) { + resolutions.add(() -> new DependencyResolver(resolution, retriever, repositories, root).getAllDependencies(prefetcher, scopes)); + } + for (var dependencies : ParallelExecution.execute(resolutions, resolution.resolutionParallelism())) { + result.addAll(dependencies); + } + } finally { + if (prefetcher != null) { + prefetcher.shutdown(); + } + } + return result; + } + private boolean matchesExclusions(Dependency context, PomDependency checked) { while (context != null) { if (context.exclusions() != null) { diff --git a/src/main/java/rife/bld/dependencies/DependencyScopes.java b/src/main/java/rife/bld/dependencies/DependencyScopes.java index d261858..7f0c8f2 100644 --- a/src/main/java/rife/bld/dependencies/DependencyScopes.java +++ b/src/main/java/rife/bld/dependencies/DependencyScopes.java @@ -6,6 +6,7 @@ package rife.bld.dependencies; import rife.ioc.HierarchicalProperties; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; @@ -147,15 +148,14 @@ public class DependencyScopes extends LinkedHashMap { private DependencySet resolveScopedDependencies(HierarchicalProperties properties, ArtifactRetriever retriever, List repositories, Scope[] resolvedScopes, Scope[] transitiveScopes, DependencySet excluded) { var resolution = new VersionResolution(properties); - var dependencies = new DependencySet(); + var roots = new ArrayList(); for (var scope : resolvedScopes) { var scoped_dependencies = get(scope); if (scoped_dependencies != null) { - for (var dependency : scoped_dependencies) { - dependencies.addAll(new DependencyResolver(resolution, retriever, repositories, dependency).getAllDependencies(transitiveScopes)); - } + roots.addAll(scoped_dependencies); } } + var dependencies = DependencyResolver.resolveAllDependencies(resolution, retriever, repositories, roots, transitiveScopes); if (excluded != null) { dependencies.removeAll(excluded); } diff --git a/src/main/java/rife/bld/dependencies/DependencySet.java b/src/main/java/rife/bld/dependencies/DependencySet.java index 813817d..a62bd10 100644 --- a/src/main/java/rife/bld/dependencies/DependencySet.java +++ b/src/main/java/rife/bld/dependencies/DependencySet.java @@ -8,9 +8,6 @@ import rife.bld.dependencies.exceptions.DependencyTransferException; import java.io.File; import java.util.*; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; import java.util.function.Supplier; /** @@ -195,39 +192,8 @@ public class DependencySet extends AbstractSet implements Set executeTransfers(List>> transfers, int transferParallelism) { var result = new ArrayList(); - - var parallelism = Math.min(transfers.size(), transferParallelism); - if (parallelism <= 1) { - for (var transfer : transfers) { - result.addAll(transfer.get()); - } - return result; - } - - var executor = Executors.newFixedThreadPool(parallelism); - try { - var futures = new ArrayList>>(); - for (var transfer : transfers) { - futures.add(executor.submit(transfer::get)); - } - for (var future : futures) { - try { - result.addAll(future.get()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Artifact transfer was interrupted", e); - } catch (ExecutionException e) { - if (e.getCause() instanceof RuntimeException runtime) { - throw runtime; - } - if (e.getCause() instanceof Error error) { - throw error; - } - throw new IllegalStateException(e.getCause()); - } - } - } finally { - executor.shutdownNow(); + for (var artifacts : ParallelExecution.execute(transfers, transferParallelism)) { + result.addAll(artifacts); } return result; } @@ -260,11 +226,7 @@ public class DependencySet extends AbstractSet implements Set repositories, Scope... scopes) { - var compile_dependencies = new DependencySet(); - for (var dependency : this) { - compile_dependencies.addAll(new DependencyResolver(resolution, retriever, repositories, dependency).getAllDependencies(scopes)); - } - return compile_dependencies.generateDependencyTree(); + return DependencyResolver.resolveAllDependencies(resolution, retriever, repositories, this, scopes).generateDependencyTree(); } /** diff --git a/src/main/java/rife/bld/dependencies/ParallelExecution.java b/src/main/java/rife/bld/dependencies/ParallelExecution.java new file mode 100644 index 0000000..86dd7ca --- /dev/null +++ b/src/main/java/rife/bld/dependencies/ParallelExecution.java @@ -0,0 +1,64 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.dependencies; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.Supplier; + +/** + * Executes tasks in parallel while preserving the order of their results + * and the sequential semantics of failures: the first task that fails in + * order will have its exception rethrown. + * + * @author Geert Bevin (gbevin[remove] at uwyn dot com) + * @since 2.3.1 + */ +final class ParallelExecution { + private ParallelExecution() { + } + + static List execute(List> tasks, int parallelism) { + var result = new ArrayList(tasks.size()); + + parallelism = Math.min(tasks.size(), parallelism); + if (parallelism <= 1) { + for (var task : tasks) { + result.add(task.get()); + } + return result; + } + + var executor = Executors.newFixedThreadPool(parallelism); + try { + var futures = new ArrayList>(tasks.size()); + for (var task : tasks) { + futures.add(executor.submit(task::get)); + } + for (var future : futures) { + try { + result.add(future.get()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Parallel execution was interrupted", e); + } catch (ExecutionException e) { + if (e.getCause() instanceof RuntimeException runtime) { + throw runtime; + } + if (e.getCause() instanceof Error error) { + throw error; + } + throw new IllegalStateException(e.getCause()); + } + } + } finally { + executor.shutdownNow(); + } + return result; + } +} diff --git a/src/main/java/rife/bld/dependencies/PomPrefetcher.java b/src/main/java/rife/bld/dependencies/PomPrefetcher.java new file mode 100644 index 0000000..fabbc45 --- /dev/null +++ b/src/main/java/rife/bld/dependencies/PomPrefetcher.java @@ -0,0 +1,75 @@ +/* + * Copyright 2001-2026 Geert Bevin (gbevin[remove] at uwyn dot com) + * Licensed under the Apache License, Version 2.0 (the "License") + */ +package rife.bld.dependencies; + +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Speculatively retrieves POMs in parallel so that they are already cached + * by the artifact retriever when the sequential dependency resolution + * processes them, without influencing the resolution semantics. + *

+ * A single instance can be shared by multiple concurrent resolutions, the + * POM of each unique dependency will only be prefetched once. + * + * @author Geert Bevin (gbevin[remove] at uwyn dot com) + * @since 2.3.1 + */ +class PomPrefetcher { + private final VersionResolution resolution_; + private final ArtifactRetriever retriever_; + private final List repositories_; + private final ExecutorService executor_; + private final Set submitted_ = ConcurrentHashMap.newKeySet(); + + /** + * Creates a prefetcher when it can be beneficial. + * + * @return the prefetcher; or {@code null} when the retriever doesn't + * cache the retrieved POMs or when the resolution parallelism disables it + * @since 2.3.1 + */ + static PomPrefetcher create(VersionResolution resolution, ArtifactRetriever retriever, List repositories) { + // prefetching only has benefits when the retrieved POMs are + // cached for the sequential resolution that follows + if (!retriever.isCaching() || resolution.resolutionParallelism() <= 1) { + return null; + } + return new PomPrefetcher(resolution, retriever, repositories); + } + + private PomPrefetcher(VersionResolution resolution, ArtifactRetriever retriever, List repositories) { + resolution_ = resolution; + retriever_ = retriever; + repositories_ = repositories; + executor_ = Executors.newFixedThreadPool(resolution.resolutionParallelism()); + } + + void prefetch(Collection candidates) { + for (var candidate : candidates) { + var dependency = resolution_.overrideDependency(candidate.convertToDependency()); + if (submitted_.add(dependency)) { + executor_.submit(() -> { + try { + new DependencyResolver(resolution_, retriever_, repositories_, dependency).getMavenPom(dependency); + } catch (Throwable e) { + // failures are ignored since they will resurface + // with the proper context when the dependency is + // resolved in order + } + }); + } + } + } + + void shutdown() { + executor_.shutdownNow(); + } +} diff --git a/src/main/java/rife/bld/dependencies/VersionResolution.java b/src/main/java/rife/bld/dependencies/VersionResolution.java index 7741c4e..8dc2de0 100644 --- a/src/main/java/rife/bld/dependencies/VersionResolution.java +++ b/src/main/java/rife/bld/dependencies/VersionResolution.java @@ -49,8 +49,18 @@ public class VersionResolution { public static final String PROPERTY_TRANSFER_PARALLELISM = "bld.transferParallelism"; private static final int DEFAULT_TRANSFER_PARALLELISM = 6; + /** + * The property key that determines how many POMs are speculatively + * retrieved in parallel during transitive dependency resolution, + * {@code 1} disables the parallel retrieval. + * @since 2.3.1 + */ + public static final String PROPERTY_RESOLUTION_PARALLELISM = "bld.resolutionParallelism"; + private static final int DEFAULT_RESOLUTION_PARALLELISM = 6; + private final Map versionOverrides_ = new HashMap<>(); private final int transferParallelism_; + private final int resolutionParallelism_; /** * Returns a dummy {@code VersionResolution} instance that doesn't override anything. @@ -73,7 +83,6 @@ public class VersionResolution { * @since 2.0 */ public VersionResolution(HierarchicalProperties properties) { - var transfer_parallelism = DEFAULT_TRANSFER_PARALLELISM; if (properties != null) { for (var name : properties.getNames()) { if (name.startsWith(PROPERTY_OVERRIDE_PREFIX)) { @@ -88,17 +97,23 @@ public class VersionResolution { } } } + } + transferParallelism_ = parseParallelism(properties, PROPERTY_TRANSFER_PARALLELISM, DEFAULT_TRANSFER_PARALLELISM); + resolutionParallelism_ = parseParallelism(properties, PROPERTY_RESOLUTION_PARALLELISM, DEFAULT_RESOLUTION_PARALLELISM); + } - var parallelism = properties.getValueString(PROPERTY_TRANSFER_PARALLELISM); + private static int parseParallelism(HierarchicalProperties properties, String property, int defaultValue) { + if (properties != null) { + var parallelism = properties.getValueString(property); if (parallelism != null && !parallelism.isBlank()) { try { - transfer_parallelism = Math.max(1, Integer.parseInt(parallelism.trim())); + return Math.max(1, Integer.parseInt(parallelism.trim())); } catch (NumberFormatException e) { - Logger.getLogger("rife.bld").warning("Unable to parse the " + PROPERTY_TRANSFER_PARALLELISM + " property as an integer: '" + parallelism + "', using " + transfer_parallelism + " instead"); + Logger.getLogger("rife.bld").warning("Unable to parse the " + property + " property as an integer: '" + parallelism + "', using " + defaultValue + " instead"); } } } - transferParallelism_ = transfer_parallelism; + return defaultValue; } /** @@ -158,4 +173,16 @@ public class VersionResolution { public int transferParallelism() { return transferParallelism_; } + + /** + * Returns the number of POMs that are speculatively retrieved in parallel + * during transitive dependency resolution, {@code 1} means the parallel + * retrieval is disabled. + * + * @return the number of parallel POM retrievals + * @since 2.3.1 + */ + public int resolutionParallelism() { + return resolutionParallelism_; + } } diff --git a/src/test/java/rife/bld/dependencies/TestDependencyResolver.java b/src/test/java/rife/bld/dependencies/TestDependencyResolver.java index f8eb44d..7939498 100644 --- a/src/test/java/rife/bld/dependencies/TestDependencyResolver.java +++ b/src/test/java/rife/bld/dependencies/TestDependencyResolver.java @@ -4,15 +4,20 @@ */ package rife.bld.dependencies; +import com.sun.net.httpserver.HttpServer; import org.junit.jupiter.api.Test; import rife.ioc.HierarchicalProperties; import rife.tools.FileUtils; import rife.tools.StringUtils; import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; import java.nio.file.Files; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; import static rife.bld.dependencies.Dependency.CLASSIFIER_JAVADOC; @@ -2341,4 +2346,98 @@ public class TestDependencyResolver { FileUtils.deleteDirectory(tmp2); } } + + @Test + void testGetAllDependenciesParallelPomPrefetching() throws Exception { + var max_concurrent_retrievals = new AtomicInteger(); + var server = createPomServer(max_concurrent_retrievals); + server.start(); + try { + var repositories = List.of(new Repository("http://localhost:" + server.getAddress().getPort() + "/")); + var root = new Dependency("com.example", "root", new VersionNumber(1, 0, 0)); + + // the caching retriever enables parallel POM prefetching + var resolver = new DependencyResolver(VersionResolution.dummy(), ArtifactRetriever.cachingInstance(), repositories, root); + assertEquals(StringUtils.convertLineSeparator(""" + com.example:root:1.0.0 + com.example:child1:1.0.0 + com.example:child2:1.0.0 + com.example:child3:1.0.0 + com.example:child4:1.0.0 + com.example:child5:1.0.0 + com.example:child6:1.0.0 + com.example:shared:1.0.0"""), StringUtils.join(resolver.getAllDependencies(compile), System.lineSeparator())); + assertTrue(max_concurrent_retrievals.get() > 1, "expected concurrent POM retrievals, max was " + max_concurrent_retrievals.get()); + } finally { + server.stop(0); + } + } + + @Test + void testGetAllDependenciesSequentialWithoutCachingRetriever() throws Exception { + var max_concurrent_retrievals = new AtomicInteger(); + var server = createPomServer(max_concurrent_retrievals); + server.start(); + try { + var repositories = List.of(new Repository("http://localhost:" + server.getAddress().getPort() + "/")); + var root = new Dependency("com.example", "root", new VersionNumber(1, 0, 0)); + + // the uncached retriever disables prefetching, retrievals stay sequential + var resolver = new DependencyResolver(VersionResolution.dummy(), ArtifactRetriever.instance(), repositories, root); + assertEquals(8, resolver.getAllDependencies(compile).size()); + assertEquals(1, max_concurrent_retrievals.get(), "expected sequential POM retrievals, max was " + max_concurrent_retrievals.get()); + } finally { + server.stop(0); + } + } + + private static HttpServer createPomServer(AtomicInteger maxConcurrentRetrievals) + throws IOException { + var graph = new HashMap>(); + graph.put("root", List.of("child1", "child2", "child3", "child4", "child5", "child6")); + graph.put("child1", List.of("shared")); + + var active_retrievals = new AtomicInteger(); + var server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + server.createContext("/", exchange -> { + var active = active_retrievals.incrementAndGet(); + maxConcurrentRetrievals.accumulateAndGet(active, Math::max); + try { + // delay the response so that parallel retrievals overlap + Thread.sleep(100); + + // serve the POM of the artifact in the request path + var segments = exchange.getRequestURI().getPath().split("/"); + var artifact = segments[segments.length - 3]; + var body = buildPom(artifact, graph.getOrDefault(artifact, List.of())).getBytes(); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + active_retrievals.decrementAndGet(); + } + }); + server.setExecutor(Executors.newCachedThreadPool()); + return server; + } + + private static String buildPom(String artifact, List children) { + var dependencies = new StringBuilder(); + for (var child : children) { + dependencies.append("com.example") + .append(child) + .append("1.0.0"); + } + return """ + + + 4.0.0 + com.example + %s + 1.0.0 + %s + """.formatted(artifact, dependencies); + } }