This is an automated email from the ASF dual-hosted git repository.
cstamas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven-resolver.git
The following commit(s) were added to refs/heads/master by this push:
new d331083b8 Reduce PathConflictResolver memory and auto-select resolver
(#1938)
d331083b8 is described below
commit d331083b8c1fdfd3f37fc44d8e9152fb5b5f960f
Author: Guillaume Nodet <[email protected]>
AuthorDate: Mon Jun 29 21:45:03 2026 +0200
Reduce PathConflictResolver memory and auto-select resolver (#1938)
## Summary
Improvements to `PathConflictResolver` to prevent OOM and StackOverflow on
large dependency graphs, plus allocation and collection-sizing optimizations:
### Memory & safety improvements
1. **Auto-selection heuristic** (new default `"auto"` mode): walks the
dependency tree counting total nodes (including diamond-expanded duplicates)
with early-exit optimization. Falls back to `ClassicConflictResolver` when the
estimated Path tree memory exceeds 25% of available heap. This correctly
handles the pathological case where diamond dependencies cause the Path tree to
be 100-150× larger than the unique node count.
2. **Lazy children lists**: `Path.children` starts as `null` (leaf nodes
never allocate a list). Initialized in `addChildren()` with exact capacity.
Saves ~40 bytes per leaf node (typically 60-70% of all nodes).
3. **Out-of-scope flag**: replaces removal from `LinkedHashSet` partitions
with a boolean `outOfScope` flag. Partitions use `ArrayList` instead of
`LinkedHashSet`, avoiding ~48 bytes/entry of HashMap.Node overhead.
4. **Iterative `gatherCRNodes` and `moveOutOfScope`**: both replaced
recursive DFS with explicit `ArrayList` stacks to avoid `StackOverflowError` on
deep dependency chains.
5. **Partition compaction**: after filtering active paths for each conflict
group, the partition entry is replaced with the filtered list, releasing
references to out-of-scope paths and their detached subtrees for GC during
resolution instead of retaining them until the end.
6. **Children nulling on out-of-scope**: `moveOutOfScope()` nulls children
references on marked nodes, accelerating GC of detached subtrees.
### Allocation & performance optimizations
7. **Allocation-free artifact comparisons**: replaced
`ArtifactIdUtils.toId()` / `toVersionlessId()` + `String.equals()` with
`equalsId()` / `equalsVersionlessId()` in `push()` and
`isDirectDependencyOnPathToRoot()`.
8. **Eliminate string concatenation in `relatedSiblingsCount()`**: compare
`groupId`/`artifactId` fields directly.
9. **Pre-sized `activePaths` / `items` lists**: initialized with
`allPaths.size()` capacity.
10. **Right-sized `partitions` / `resolvedIds` HashMaps**: initialized with
exact capacity from `sortedConflictIds.size()`.
11. **Pooled `ScopeContext`**: single mutable instance on `State`, reset
and reused.
12. **Optimized `isDirectDependencyOnPathToRoot()`**: walks directly to the
depth-1 ancestor.
13. **Removed dead recursive `push()` code**: `push()` is always called
with `levels=0`.
14. **Inline stats tracking**: `conflictItemCount` tracked during the main
loop.
## Benchmark results
Tested with a real-world 814-module project with heavy inter-module
dependencies:
| Configuration | Heap | Result |
|---|---|---|
| **Baseline** (master, `path` default) | 512m | **OOM** (125s of GC
thrashing) |
| **Baseline** (master, `path` default) | 1g | **OOM** (126s) |
| **Baseline** (master, `path` default) | 2g | **OOM** (267s) |
| **Baseline** (master, `path` default) | 4g | **OOM** (424s) |
| **Patched** (`path` forced) | 4g | ✅ OK (214s) |
| **Patched** (`classic` forced) | 384m | ✅ OK (183s) |
| **Patched** (`classic` forced) | 512m | ✅ OK (153-163s, 3 runs) |
| **Patched** (`auto` default) | 384m | ✅ OK (222s) |
| **Patched** (`auto` default) | 512m | ✅ OK (165-210s, 3 runs) |
**Key findings:**
- The baseline `PathConflictResolver` **cannot resolve this project at any
heap size** (OOM even at 4GB)
- The root cause: diamond dependencies cause the Path tree to explode. A
single module with 7,522 unique nodes produces **1,190,821 tree nodes** (158×
expansion factor)
- The old heuristic used `conflictIds.size()` (unique nodes) — estimated
~1MB when the actual Path tree was ~227MB
- The new heuristic walks the tree counting actual nodes with early-exit,
correctly falling back to `ClassicConflictResolver` for large graphs
- With auto-selection, the patched resolver **succeeds at 384MB heap**
where the baseline fails at 4GB
## Test plan
- [x] All 443 tests pass (438 original + 5 new auto-selection tests)
- [x] New tests cover: auto mode, explicit path/classic config dispatch,
unknown config rejection, default config behavior
- [x] Benchmarked with 814-module real-world reproducer — confirms OOM fix
and correct auto-selection
---
.../util/graph/transformer/ConflictResolver.java | 71 ++++++-
.../graph/transformer/PathConflictResolver.java | 230 +++++++++++++--------
.../graph/transformer/ConflictResolverTest.java | 139 +++++++++++++
3 files changed, 351 insertions(+), 89 deletions(-)
diff --git
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
index 0b7df0856..fc8bf87ab 100644
---
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
+++
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/ConflictResolver.java
@@ -18,6 +18,7 @@
*/
package org.eclipse.aether.util.graph.transformer;
+import java.util.ArrayDeque;
import java.util.Arrays;
import java.util.Collection;
@@ -129,7 +130,11 @@ public class ConflictResolver implements
DependencyGraphTransformer {
public static final String CONFIG_PROP_VERBOSE =
ConfigurationProperties.PREFIX_AETHER + "conflictResolver.verbose";
/**
- * The name of the conflict resolver implementation to use: "path"
(default) or "classic" (same as Maven 3).
+ * The name of the conflict resolver implementation to use: "auto"
(default), "path", or "classic" (same as Maven 3).
+ * <p>
+ * When set to "auto", the resolver estimates whether the Path tree would
fit in available heap memory.
+ * If it would consume more than 25% of available heap, the classic
(in-place) resolver is used instead
+ * to avoid OutOfMemoryErrors on very large dependency graphs.
*
* @since 2.0.11
* @configurationSource {@link
RepositorySystemSession#getConfigProperties()}
@@ -141,8 +146,9 @@ public class ConflictResolver implements
DependencyGraphTransformer {
public static final String CLASSIC_CONFLICT_RESOLVER = "classic";
public static final String PATH_CONFLICT_RESOLVER = "path";
+ public static final String AUTO_CONFLICT_RESOLVER = "auto";
- public static final String DEFAULT_CONFLICT_RESOLVER_IMPL =
PATH_CONFLICT_RESOLVER;
+ public static final String DEFAULT_CONFLICT_RESOLVER_IMPL =
AUTO_CONFLICT_RESOLVER;
/**
* The enum representing verbosity levels of conflict resolver.
@@ -258,22 +264,79 @@ public class ConflictResolver implements
DependencyGraphTransformer {
}
@Override
+ @SuppressWarnings("unchecked")
public DependencyNode transformGraph(DependencyNode node,
DependencyGraphTransformationContext context)
throws RepositoryException {
String cf = ConfigUtils.getString(
context.getSession(), DEFAULT_CONFLICT_RESOLVER_IMPL,
CONFIG_PROP_CONFLICT_RESOLVER_IMPL);
ConflictResolver delegate;
- if (PATH_CONFLICT_RESOLVER.equals(cf)) {
+ if (AUTO_CONFLICT_RESOLVER.equals(cf)) {
+ delegate = selectConflictResolver(node, context);
+ } else if (PATH_CONFLICT_RESOLVER.equals(cf)) {
delegate = new PathConflictResolver(versionSelector,
scopeSelector, optionalitySelector, scopeDeriver);
} else if (CLASSIC_CONFLICT_RESOLVER.equals(cf)) {
delegate = new ClassicConflictResolver(versionSelector,
scopeSelector, optionalitySelector, scopeDeriver);
} else {
throw new IllegalArgumentException("Unknown conflict resolver: " +
cf + "; known are "
- + Arrays.asList(PATH_CONFLICT_RESOLVER,
CLASSIC_CONFLICT_RESOLVER));
+ + Arrays.asList(AUTO_CONFLICT_RESOLVER,
PATH_CONFLICT_RESOLVER, CLASSIC_CONFLICT_RESOLVER));
}
return delegate.transformGraph(node, context);
}
+ /**
+ * Selects the most appropriate conflict resolver based on graph size and
available memory.
+ * <p>
+ * PathConflictResolver builds a parallel tree of Path objects that costs
~200 bytes per node.
+ * For very large dependency graphs (millions of nodes), this can exhaust
the heap.
+ * In such cases, ClassicConflictResolver is used instead — it works
in-place with no parallel
+ * structure, trading O(N²) worst-case time for O(1) extra space.
+ */
+ private ConflictResolver selectConflictResolver(DependencyNode node,
DependencyGraphTransformationContext context)
+ throws RepositoryException {
+ // Ensure conflict IDs are computed — both implementations need this
anyway
+ if (context.get(TransformationContextKeys.SORTED_CONFLICT_IDS) ==
null) {
+ new ConflictIdSorter().transformGraph(node, context);
+ }
+
+ Runtime rt = Runtime.getRuntime();
+ long available = rt.maxMemory() - (rt.totalMemory() - rt.freeMemory());
+
+ // Estimate the maximum number of Path tree nodes that would fit in
25% of available heap.
+ // Each Path object costs ~200 bytes (object header + fields +
children list entry).
+ int maxPathNodes = (int) Math.min(available / (4L * 200),
Integer.MAX_VALUE);
+
+ // Walk the dependency tree to count total nodes (including
diamond-expanded duplicates).
+ // The Path tree mirrors this structure, so the count directly
reflects Path tree size.
+ // Use early-exit: stop counting once we exceed the threshold — no
need to measure the
+ // full tree if we already know it's too large.
+ if (treeExceedsThreshold(node, maxPathNodes)) {
+ return new ClassicConflictResolver(versionSelector, scopeSelector,
optionalitySelector, scopeDeriver);
+ } else {
+ return new PathConflictResolver(versionSelector, scopeSelector,
optionalitySelector, scopeDeriver);
+ }
+ }
+
+ /**
+ * Checks whether the total number of nodes in the dependency tree
(including diamond-expanded
+ * duplicates) exceeds the given threshold. Uses an iterative walk with
early exit to avoid
+ * measuring the full tree when it's clearly too large.
+ */
+ private boolean treeExceedsThreshold(DependencyNode root, int threshold) {
+ int count = 0;
+ ArrayDeque<DependencyNode> stack = new ArrayDeque<>();
+ stack.push(root);
+ while (!stack.isEmpty()) {
+ DependencyNode n = stack.pop();
+ if (++count > threshold) {
+ return true;
+ }
+ for (DependencyNode child : n.getChildren()) {
+ stack.push(child);
+ }
+ }
+ return false;
+ }
+
/**
* A context used to hold information that is relevant for deriving the
scope of a child dependency.
*
diff --git
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java
index 20b1b6f9a..f6d341d46 100644
---
a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java
+++
b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/transformer/PathConflictResolver.java
@@ -22,12 +22,9 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
-import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
import org.eclipse.aether.ConfigurationProperties;
import org.eclipse.aether.RepositoryException;
@@ -170,28 +167,38 @@ public final class PathConflictResolver extends
ConflictResolver {
scopeDeriver.getInstance(node, context),
optionalitySelector.getInstance(node, context),
conflictIds,
+ sortedConflictIds.size(),
node);
// loop over topographically sorted conflictIds
+ int conflictItemCount = 0;
for (String conflictId : sortedConflictIds) {
- // paths in given conflict group to consider
- Set<Path> paths = state.partitions.get(conflictId);
- if (paths.isEmpty()) {
+ // paths in given conflict group to consider; filter out those
moved out of scope
+ List<Path> allPaths = state.partitions.get(conflictId);
+ List<Path> activePaths = new ArrayList<>(allPaths.size());
+ List<ConflictItem> items = new ArrayList<>(allPaths.size());
+ for (Path p : allPaths) {
+ if (!p.outOfScope) {
+ activePaths.add(p);
+ items.add(new ConflictItem(p));
+ }
+ }
+ // Replace partition entry with filtered list to release
references to out-of-scope
+ // paths (and their detached subtrees), allowing GC during
resolution
+ state.partitions.put(conflictId, activePaths);
+ if (activePaths.isEmpty()) {
// this means that whole group "fall out of scope" (are all on
loser branches); skip
continue;
}
+ conflictItemCount += activePaths.size();
// create conflict context for given conflictId
- ConflictContext ctx = new ConflictContext(
- node,
- state.conflictIds,
-
paths.stream().map(ConflictItem::new).collect(Collectors.toList()),
- conflictId);
+ ConflictContext ctx = new ConflictContext(node, state.conflictIds,
items, conflictId);
// select winner (is done by VersionSelector)
state.versionSelector.selectVersion(ctx);
if (ctx.winner == null) {
- throw new RepositoryException("conflict resolver did not
select winner among " + ctx.items);
+ throw new RepositoryException("conflict resolver did not
select winner among " + items);
}
// select scope (no side effect between this and above operations)
state.scopeSelector.selectScope(ctx);
@@ -208,8 +215,8 @@ public final class PathConflictResolver extends
ConflictResolver {
}
state.resolvedIds.put(conflictId, winnerPath);
- // loop over considered paths and apply selection results; note:
node may remove itself from iterated set
- for (Path path : new ArrayList<>(paths)) {
+ // loop over considered paths and apply selection results
+ for (Path path : activePaths) {
// apply selected properties scope/optional to winner (winner
carries version; others are losers)
if (path == winnerPath) {
path.scope = ctx.scope;
@@ -217,7 +224,11 @@ public final class PathConflictResolver extends
ConflictResolver {
}
// reset children as inheritance may be affected by this node
scope/optionality change
- path.children.forEach(c -> c.pull(0));
+ if (path.children != null) {
+ for (Path c : path.children) {
+ c.pull(0);
+ }
+ }
// derive with new values from this to children only; observe
winner flag
path.derive(1, path == winnerPath);
// push this node full level changes to DN graph
@@ -228,9 +239,7 @@ public final class PathConflictResolver extends
ConflictResolver {
if (stats != null) {
long time2 = System.nanoTime();
stats.put("ConflictResolver.totalTime", time2 - time1);
- stats.put(
- "ConflictResolver.conflictItemCount",
-
state.partitions.values().stream().map(Set::size).reduce(0, Integer::sum));
+ stats.put("ConflictResolver.conflictItemCount", conflictItemCount);
}
return node;
@@ -279,9 +288,10 @@ public final class PathConflictResolver extends
ConflictResolver {
/**
* A mapping from conflictId to paths represented as {@link Path}s
that exist for each conflictId. In other
* words all paths to each {@link DependencyNode} that are member of
same conflictId group.
- * Uses {@link LinkedHashSet} for O(1) removal in {@link
Path#moveOutOfScope()} while preserving insertion order.
+ * Uses {@link ArrayList} per partition; out-of-scope paths are marked
via {@link Path#outOfScope} flag
+ * and filtered at query time, avoiding the per-entry overhead of
LinkedHashSet/HashMap.Node.
*/
- private final Map<String, Set<Path>> partitions;
+ private final Map<String, List<Path>> partitions;
/**
* A mapping from conflictIds to winner {@link Path}, hence {@link
DependencyNode} for given conflictId.
@@ -293,6 +303,12 @@ public final class PathConflictResolver extends
ConflictResolver {
*/
private final Path root;
+ /**
+ * Pooled {@link ScopeContext} instance reused across derive() calls
to avoid allocating a new
+ * object per node. Reset via {@link ScopeContext#reset(String,
String)} before each use.
+ */
+ private final ScopeContext scopeContext;
+
@SuppressWarnings("checkstyle:ParameterNumber")
private State(
ConflictResolver.Verbosity verbosity,
@@ -302,6 +318,7 @@ public final class PathConflictResolver extends
ConflictResolver {
ConflictResolver.ScopeDeriver scopeDeriver,
ConflictResolver.OptionalitySelector optionalitySelector,
Map<DependencyNode, String> conflictIds,
+ int conflictIdCount,
DependencyNode node)
throws RepositoryException {
this.verbosity = verbosity;
@@ -311,8 +328,10 @@ public final class PathConflictResolver extends
ConflictResolver {
this.scopeDeriver = scopeDeriver;
this.optionalitySelector = optionalitySelector;
this.conflictIds = conflictIds;
- this.partitions = new HashMap<>();
- this.resolvedIds = new HashMap<>();
+ // Right-size maps: conflictIdCount gives exact number of
partitions and resolved entries
+ this.partitions = new HashMap<>(conflictIdCount * 4 / 3 + 1);
+ this.resolvedIds = new HashMap<>(conflictIdCount * 4 / 3 + 1);
+ this.scopeContext = new ScopeContext(null, null);
this.root = build(node);
}
@@ -329,15 +348,23 @@ public final class PathConflictResolver extends
ConflictResolver {
}
/**
- * Recursively builds {@link Path} graph by observing each node
associated {@link DependencyNode}.
+ * Iteratively builds {@link Path} graph by observing each node
associated {@link DependencyNode}.
+ * Uses an explicit stack instead of recursion to avoid {@link
StackOverflowError} on very deep
+ * dependency graphs (reported in large multi-module projects with 13+
levels of recursion).
*/
- private void gatherCRNodes(Path node) throws RepositoryException {
- List<DependencyNode> children = node.dn.getChildren();
- if (!children.isEmpty()) {
- // add children; we will get back those really added (not
causing cycles)
- List<Path> added = node.addChildren(children);
- for (Path child : added) {
- gatherCRNodes(child);
+ private void gatherCRNodes(Path root) throws RepositoryException {
+ ArrayList<Path> stack = new ArrayList<>();
+ stack.add(root);
+ while (!stack.isEmpty()) {
+ Path node = stack.remove(stack.size() - 1);
+ List<DependencyNode> children = node.dn.getChildren();
+ if (!children.isEmpty()) {
+ // add children; we will get back those really added (not
causing cycles)
+ List<Path> added = node.addChildren(children);
+ // push in reverse order so first child is processed first
(DFS order)
+ for (int i = added.size() - 1; i >= 0; i--) {
+ stack.add(added.get(i));
+ }
}
}
}
@@ -379,10 +406,15 @@ public final class PathConflictResolver extends
ConflictResolver {
private final Path parent;
// derived
private final int depth;
- private final List<Path> children;
+ // Lazy: null for leaf nodes (never populated by addChildren),
right-sized for non-leaves.
+ // This avoids allocating an ArrayList + backing array for every leaf
node in the tree
+ // (typically 60-70% of all nodes), saving ~40 bytes per leaf.
+ private List<Path> children;
// mutated
private String scope;
private boolean optional;
+ // Flag used instead of removing from partition sets; avoids
LinkedHashSet overhead (~48 bytes/entry)
+ private boolean outOfScope;
private Path(State state, DependencyNode dn, String conflictId, Path
parent) {
this.state = state;
@@ -390,12 +422,11 @@ public final class PathConflictResolver extends
ConflictResolver {
this.conflictId = conflictId;
this.parent = parent;
this.depth = parent != null ? parent.depth + 1 : 0;
- this.children = new ArrayList<>();
pull(0);
this.state
.partitions
- .computeIfAbsent(this.conflictId, k -> new
LinkedHashSet<>())
+ .computeIfAbsent(this.conflictId, k -> new ArrayList<>())
.add(this);
}
@@ -429,7 +460,7 @@ public final class PathConflictResolver extends
ConflictResolver {
this.optional = false;
}
int newLevels = levels - 1;
- if (newLevels >= 0) {
+ if (newLevels >= 0 && this.children != null) {
for (Path child : this.children) {
child.pull(newLevels);
}
@@ -444,9 +475,9 @@ public final class PathConflictResolver extends
ConflictResolver {
if (!winner) {
if (this.parent != null) {
if ((dn.getManagedBits() & DependencyNode.MANAGED_SCOPE)
== 0) {
- ScopeContext context = new
ScopeContext(this.parent.scope, this.scope);
- state.scopeDeriver.deriveScope(context);
- this.scope = context.derivedScope;
+ state.scopeContext.reset(this.parent.scope,
this.scope);
+ state.scopeDeriver.deriveScope(state.scopeContext);
+ this.scope = state.scopeContext.derivedScope;
}
if ((dn.getManagedBits() &
DependencyNode.MANAGED_OPTIONAL) == 0) {
if (!this.optional && this.parent.optional) {
@@ -459,8 +490,8 @@ public final class PathConflictResolver extends
ConflictResolver {
}
}
int newLevels = levels - 1;
- if (newLevels >= 0) {
- for (Path child : children) {
+ if (newLevels >= 0 && this.children != null) {
+ for (Path child : this.children) {
child.derive(newLevels, false);
}
}
@@ -503,29 +534,32 @@ public final class PathConflictResolver extends
ConflictResolver {
switch (state.verbosity) {
case NONE:
// remove loser dn
- this.parent.children.remove(this);
+ if (this.parent.children != null) {
+ this.parent.children.remove(this);
+ }
this.parent.dn.setChildren(new
ArrayList<>(this.parent.dn.getChildren()));
this.parent.dn.getChildren().remove(this.dn);
- this.children.clear();
+ this.children = null;
break;
case STANDARD:
- String artifactId =
ArtifactIdUtils.toId(this.dn.getArtifact());
- String winnerArtifactId =
ArtifactIdUtils.toId(winner.dn.getArtifact());
// is redundant if:
// - is not same as winner, and has related
siblings (version range)
// - same instance of DN is direct dependency on
path leading here
- boolean isRedundant = (!Objects.equals(artifactId,
winnerArtifactId)
- &&
relatedSiblingsCount(this.dn.getArtifact(), this.parent) > 1);
+ boolean isRedundant =
+
(!ArtifactIdUtils.equalsId(this.dn.getArtifact(), winner.dn.getArtifact())
+ &&
relatedSiblingsCount(this.dn.getArtifact(), this.parent) > 1);
if (!this.state.showCyclesInStandardVerbosity) {
isRedundant = isRedundant
||
this.parent.isDirectDependencyOnPathToRoot(this.dn.getArtifact());
}
if (isRedundant) {
// is redundant dn; remove dn
- this.parent.children.remove(this);
+ if (this.parent.children != null) {
+ this.parent.children.remove(this);
+ }
this.parent.dn.setChildren(new
ArrayList<>(this.parent.dn.getChildren()));
this.parent.dn.getChildren().remove(this.dn);
- this.children.clear();
+ this.children = null;
} else {
// copy loser dn; without children
DependencyNode dnCopy = new
DefaultDependencyNode(this.dn);
@@ -538,7 +572,7 @@ public final class PathConflictResolver extends
ConflictResolver {
}
this.dn = dnCopy;
- this.children.clear();
+ this.children = null;
markLoser = true;
}
break;
@@ -573,18 +607,15 @@ public final class PathConflictResolver extends
ConflictResolver {
}
}
- int newLevels = levels - 1;
- if (newLevels >= 0 && !this.children.isEmpty()) {
- // child may remove itself from iterated list
- for (Path child : new ArrayList<>(children)) {
- child.push(newLevels);
- }
- }
+ // Note: push() is always called with levels=0, so newLevels would
be -1
+ // and the recursive block would never execute. The recursive
structure is
+ // intentionally not present; all push() calls happen from the
main loop.
}
/**
- * Returns {@code true} if given artifactId is direct dependency on
the path leading from this toward root.
- * For some reason "classic" conflict resolver removes these.
+ * Returns {@code true} if given artifact is a direct dependency on
the path leading from this toward root.
+ * A "direct dependency" is one at depth 1 (immediate child of root).
Rather than recursing through every
+ * ancestor, this walks directly to the depth-1 node and performs one
allocation-free comparison.
* <p>
* Note: this check and use of this method is ONLY present to make
this conflict resolver produce SAME output
* as {@link ClassicConflictResolver} does, but IMHO this rule here is
very arbitrary, moreover, in "standard"
@@ -593,15 +624,14 @@ public final class PathConflictResolver extends
ConflictResolver {
* @see #CONFIG_PROP_SHOW_CYCLES_IN_STANDARD_VERBOSITY
*/
private boolean isDirectDependencyOnPathToRoot(Artifact artifact) {
- if (this.depth == 1
- && ArtifactIdUtils.toVersionlessId(this.dn.getArtifact())
-
.equals(ArtifactIdUtils.toVersionlessId(artifact))) {
- return true;
- } else if (this.parent != null) {
- return parent.isDirectDependencyOnPathToRoot(artifact);
- } else {
- return false;
+ // Walk up to depth-1 ancestor (direct dependency of root) instead
of recursing every level
+ Path current = this;
+ while (current != null && current.depth > 1) {
+ current = current.parent;
}
+ return current != null
+ && current.depth == 1
+ &&
ArtifactIdUtils.equalsVersionlessId(current.dn.getArtifact(), artifact);
}
/**
@@ -610,43 +640,62 @@ public final class PathConflictResolver extends
ConflictResolver {
* verbosity mode we remove "redundant" nodes (of a range) leaving
only "winner equal" loser, that have same GACEV as winner.
*/
private int relatedSiblingsCount(Artifact artifact, Path parent) {
- String ga = artifact.getGroupId() + ":" + artifact.getArtifactId();
- return Math.toIntExact(parent.children.stream()
- .map(n -> n.dn.getArtifact())
- .filter(a -> ga.equals(a.getGroupId() + ":" +
a.getArtifactId()))
- .count());
+ if (parent.children == null) {
+ return 0;
+ }
+ String groupId = artifact.getGroupId();
+ String artifactId = artifact.getArtifactId();
+ int count = 0;
+ for (Path n : parent.children) {
+ Artifact a = n.dn.getArtifact();
+ if (Objects.equals(groupId, a.getGroupId()) &&
Objects.equals(artifactId, a.getArtifactId())) {
+ count++;
+ }
+ }
+ return count;
}
/**
- * Removes this and all child {@link Path} nodes from winner selection
scope; essentially marks whole subtree
+ * Marks this and all child {@link Path} nodes as out of scope;
essentially marks whole subtree
* from "this and below" as loser, to not be considered in subsequent
winner selections.
- * Uses {@link LinkedHashSet} for O(1) removal instead of the previous
O(N) ArrayList.remove.
+ * Uses a boolean flag instead of removing from partition collections,
avoiding the per-entry
+ * overhead of LinkedHashSet (~48 bytes/entry). Out-of-scope paths are
filtered at query time.
+ * <p>
+ * Uses an explicit stack instead of recursion to avoid {@link
StackOverflowError} on deep
+ * dependency graphs, consistent with the iterative approach in
+ * {@link State#gatherCRNodes(Path)}. Also nulls children references
on out-of-scope nodes
+ * to allow GC of detached subtrees during resolution.
*/
private void moveOutOfScope() {
- this.state.partitions.get(this.conflictId).remove(this);
- for (Path child : this.children) {
- child.moveOutOfScope();
+ ArrayList<Path> stack = new ArrayList<>();
+ stack.add(this);
+ while (!stack.isEmpty()) {
+ Path node = stack.remove(stack.size() - 1);
+ node.outOfScope = true;
+ if (node.children != null) {
+ stack.addAll(node.children);
+ node.children = null;
+ }
}
}
/**
* Adds node children: this method should be "batch" used, as all
(potential) children should be added at once.
* Method will return really added {@link Path} instances, as this
class avoids cycles. Those forming a cycle
- * are added to {@link Path} structure but are not recursed (not
returned in list), keeping {@link Path} cycle
- * free. Cycle detection is performed by walking up the parent chain
via
+ * are not recursed (not returned in list), keeping {@link Path} cycle
free.
+ * <p>
+ * Cycle detection is performed by walking up the parent chain via
* {@link #hasConflictIdOnPathToRoot(String)} instead of maintaining a
per-node set, trading O(depth) per
* check for dramatically reduced memory allocation on large
dependency graphs.
* This implies that this conflict resolver, by its nature "redoes" the
* {@link TransformationContextKeys#CYCLIC_CONFLICT_IDS} calculated by
{@link ConflictIdSorter}.
*/
private List<Path> addChildren(List<DependencyNode> children) throws
RepositoryException {
+ // Right-size the children list to avoid ArrayList default
capacity waste
+ this.children = new ArrayList<>(children.size());
ArrayList<Path> added = new ArrayList<>(children.size());
for (DependencyNode child : children) {
String childConflictId = this.state.conflictIds.get(child);
- // Detect cycles by walking up the parent chain instead of
copying a HashSet per child.
- // This trades O(depth) per check for eliminating the dominant
memory consumer:
- // previously, each Path copied its parent's entire
conflict-ID set, creating millions
- // of HashMap.Node objects for large graphs.
boolean cycle = hasConflictIdOnPathToRoot(childConflictId);
Path c = new Path(this.state, child, childConflictId, this);
this.children.add(c);
@@ -663,8 +712,10 @@ public final class PathConflictResolver extends
ConflictResolver {
*/
private void dump(String padding) {
System.out.println(padding + this.dn + ": " + this.scope + "/" +
this.optional);
- for (Path child : children) {
- child.dump(padding + " ");
+ if (this.children != null) {
+ for (Path child : this.children) {
+ child.dump(padding + " ");
+ }
}
}
@@ -685,8 +736,8 @@ public final class PathConflictResolver extends
ConflictResolver {
* change without notice and only exists to enable unit
testing
*/
private static final class ScopeContext extends
ConflictResolver.ScopeContext {
- private final String parentScope;
- private final String childScope;
+ private String parentScope;
+ private String childScope;
private String derivedScope;
/**
@@ -703,6 +754,15 @@ public final class PathConflictResolver extends
ConflictResolver {
this.childScope = (childScope != null) ? childScope : "";
}
+ /**
+ * Resets this context for reuse, avoiding allocation of a new
instance per derive() call.
+ */
+ private void reset(String parentScope, String childScope) {
+ this.parentScope = (parentScope != null) ? parentScope : "";
+ this.derivedScope = (childScope != null) ? childScope : "";
+ this.childScope = (childScope != null) ? childScope : "";
+ }
+
/**
* Gets the scope of the parent dependency. This is usually the scope
that was derived by earlier invocations of
* the scope deriver.
diff --git
a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
index c8bb21954..06a5b7ba6 100644
---
a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
+++
b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/transformer/ConflictResolverTest.java
@@ -720,6 +720,145 @@ public final class ConflictResolverTest extends
AbstractConflictResolverTest {
assertEquals(1, occurrence.get()); // a must be present only once in
tree
}
+ // ---- Auto-selection / delegating ConflictResolver tests ----
+
+ /**
+ * Verifies that the delegating {@link ConflictResolver} with {@code
"auto"} mode (default)
+ * correctly resolves conflicts. The auto-selection heuristic chooses
between PathConflictResolver
+ * and ClassicConflictResolver based on available memory; this test
confirms the delegation
+ * produces the same correct result regardless of which implementation is
selected.
+ */
+ @org.junit.jupiter.api.Test
+ void autoSelectionResolvesConflictsCorrectly() throws RepositoryException {
+ // explicit "auto" config
+
session.setConfigProperty(ConflictResolver.CONFIG_PROP_CONFLICT_RESOLVER_IMPL,
"auto");
+ ConflictResolver delegating = new ConflictResolver(
+ new NearestVersionSelector(),
+ new JavaScopeSelector(),
+ new SimpleOptionalitySelector(),
+ new JavaScopeDeriver());
+
+ // Foo -> Bar -> Baz 2.0
+ // |---> Baz 1.0
+ DependencyNode fooNode = makeDependencyNode("some-group", "foo",
"1.0");
+ DependencyNode barNode = makeDependencyNode("some-group", "bar",
"1.0");
+ DependencyNode baz1Node = makeDependencyNode("some-group", "baz",
"1.0");
+ DependencyNode baz2Node = makeDependencyNode("some-group", "baz",
"2.0");
+ fooNode.setChildren(mutableList(barNode, baz1Node));
+ barNode.setChildren(mutableList(baz2Node));
+
+ DependencyNode transformedNode = transform(delegating, fooNode);
+
+ assertSame(fooNode, transformedNode);
+ assertEquals(2, fooNode.getChildren().size());
+ assertSame(barNode, fooNode.getChildren().get(0));
+ assertTrue(barNode.getChildren().isEmpty());
+ assertSame(baz1Node, fooNode.getChildren().get(1));
+ }
+
+ /**
+ * Verifies that the delegating {@link ConflictResolver} with explicit
{@code "path"} config
+ * correctly selects PathConflictResolver.
+ */
+ @org.junit.jupiter.api.Test
+ void explicitPathConfigResolvesConflicts() throws RepositoryException {
+
session.setConfigProperty(ConflictResolver.CONFIG_PROP_CONFLICT_RESOLVER_IMPL,
"path");
+ ConflictResolver delegating = new ConflictResolver(
+ new NearestVersionSelector(),
+ new JavaScopeSelector(),
+ new SimpleOptionalitySelector(),
+ new JavaScopeDeriver());
+
+ DependencyNode fooNode = makeDependencyNode("some-group", "foo",
"1.0");
+ DependencyNode barNode = makeDependencyNode("some-group", "bar",
"1.0");
+ DependencyNode baz1Node = makeDependencyNode("some-group", "baz",
"1.0");
+ DependencyNode baz2Node = makeDependencyNode("some-group", "baz",
"2.0");
+ fooNode.setChildren(mutableList(barNode, baz1Node));
+ barNode.setChildren(mutableList(baz2Node));
+
+ DependencyNode transformedNode = transform(delegating, fooNode);
+
+ assertSame(fooNode, transformedNode);
+ assertEquals(2, fooNode.getChildren().size());
+ assertTrue(barNode.getChildren().isEmpty());
+ assertSame(baz1Node, fooNode.getChildren().get(1));
+ }
+
+ /**
+ * Verifies that the delegating {@link ConflictResolver} with explicit
{@code "classic"} config
+ * correctly selects ClassicConflictResolver.
+ */
+ @org.junit.jupiter.api.Test
+ void explicitClassicConfigResolvesConflicts() throws RepositoryException {
+
session.setConfigProperty(ConflictResolver.CONFIG_PROP_CONFLICT_RESOLVER_IMPL,
"classic");
+ ConflictResolver delegating = new ConflictResolver(
+ new NearestVersionSelector(),
+ new JavaScopeSelector(),
+ new SimpleOptionalitySelector(),
+ new JavaScopeDeriver());
+
+ DependencyNode fooNode = makeDependencyNode("some-group", "foo",
"1.0");
+ DependencyNode barNode = makeDependencyNode("some-group", "bar",
"1.0");
+ DependencyNode baz1Node = makeDependencyNode("some-group", "baz",
"1.0");
+ DependencyNode baz2Node = makeDependencyNode("some-group", "baz",
"2.0");
+ fooNode.setChildren(mutableList(barNode, baz1Node));
+ barNode.setChildren(mutableList(baz2Node));
+
+ DependencyNode transformedNode = transform(delegating, fooNode);
+
+ assertSame(fooNode, transformedNode);
+ assertEquals(2, fooNode.getChildren().size());
+ assertTrue(barNode.getChildren().isEmpty());
+ assertSame(baz1Node, fooNode.getChildren().get(1));
+ }
+
+ /**
+ * Verifies that the delegating {@link ConflictResolver} rejects an
unknown config value.
+ */
+ @org.junit.jupiter.api.Test
+ void unknownConfigThrows() {
+
session.setConfigProperty(ConflictResolver.CONFIG_PROP_CONFLICT_RESOLVER_IMPL,
"bogus");
+ ConflictResolver delegating = new ConflictResolver(
+ new NearestVersionSelector(),
+ new JavaScopeSelector(),
+ new SimpleOptionalitySelector(),
+ new JavaScopeDeriver());
+
+ DependencyNode fooNode = makeDependencyNode("some-group", "foo",
"1.0");
+ DependencyNode barNode = makeDependencyNode("some-group", "bar",
"1.0");
+ fooNode.setChildren(mutableList(barNode));
+
+ assertThrows(IllegalArgumentException.class, () ->
transform(delegating, fooNode));
+ }
+
+ /**
+ * Verifies that the default config (no explicit setting) works —
exercises the auto-selection
+ * code path with default config properties.
+ */
+ @org.junit.jupiter.api.Test
+ void defaultConfigUsesAutoSelection() throws RepositoryException {
+ // No explicit config property set — should use AUTO_CONFLICT_RESOLVER
default
+ ConflictResolver delegating = new ConflictResolver(
+ new NearestVersionSelector(),
+ new JavaScopeSelector(),
+ new SimpleOptionalitySelector(),
+ new JavaScopeDeriver());
+
+ DependencyNode fooNode = makeDependencyNode("some-group", "foo",
"1.0");
+ DependencyNode barNode = makeDependencyNode("some-group", "bar",
"1.0");
+ DependencyNode baz1Node = makeDependencyNode("some-group", "baz",
"1.0");
+ DependencyNode baz2Node = makeDependencyNode("some-group", "baz",
"2.0");
+ fooNode.setChildren(mutableList(barNode, baz1Node));
+ barNode.setChildren(mutableList(baz2Node));
+
+ DependencyNode transformedNode = transform(delegating, fooNode);
+
+ assertSame(fooNode, transformedNode);
+ assertEquals(2, fooNode.getChildren().size());
+ assertTrue(barNode.getChildren().isEmpty());
+ assertSame(baz1Node, fooNode.getChildren().get(1));
+ }
+
private static DependencyNode makeDependencyNode(String groupId, String
artifactId, String version) {
return makeDependencyNode(groupId, artifactId, version, "compile");
}