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");
     }


Reply via email to