From ec77f732bb18f4acec8ee863eccdcfe96f5ec1e6 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sat, 25 Jul 2026 01:43:56 +0200 Subject: [PATCH 1/8] Fix #2013: reuse DependencyManager instance when no new management data is collected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractDependencyManager.deriveChildManager() now returns `this` when no new management data (versions, scopes, optionals, local paths, exclusions) was collected at the current depth AND management is already being applied (depth >= applyFrom). This avoids creating distinct-but-semantically-equal DependencyManager instances that defeat the BfDependencyCollector pool cache — the pool key includes the manager, so unique managers cause pool misses, which lets the skipper prune subtrees that should have been served from the cache. The isApplied() guard is necessary: at depth < applyFrom, returning `this` would freeze the depth counter and prevent management rules from ever being applied to descendants. Co-Authored-By: Claude Opus 4.6 --- .../BfWithSkipperDependencyCollectorTest.java | 72 +++++++++++++++++++ .../pool-cache-transparency/gid_b-alt_1.0.ini | 2 + .../pool-cache-transparency/gid_b_1.0.ini | 2 + .../pool-cache-transparency/gid_c_1.0.ini | 2 + .../pool-cache-transparency/gid_d_1.0.ini | 1 + .../pool-cache-transparency/gid_root_1.0.ini | 3 + .../manager/AbstractDependencyManager.java | 17 +++++ .../graph/manager/DependencyManagerTest.java | 32 +++++++++ 8 files changed, 131 insertions(+) create mode 100644 maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b-alt_1.0.ini create mode 100644 maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b_1.0.ini create mode 100644 maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_c_1.0.ini create mode 100644 maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_d_1.0.ini create mode 100644 maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_root_1.0.ini diff --git a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/bf/BfWithSkipperDependencyCollectorTest.java b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/bf/BfWithSkipperDependencyCollectorTest.java index cf9edd8794..4876e40d59 100644 --- a/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/bf/BfWithSkipperDependencyCollectorTest.java +++ b/maven-resolver-impl/src/test/java/org/eclipse/aether/internal/impl/collect/bf/BfWithSkipperDependencyCollectorTest.java @@ -25,6 +25,7 @@ import org.eclipse.aether.collection.CollectResult; import org.eclipse.aether.collection.DependencyCollectionException; import org.eclipse.aether.graph.Dependency; +import org.eclipse.aether.graph.DependencyNode; import org.eclipse.aether.graph.Exclusion; import org.eclipse.aether.impl.ArtifactDescriptorReader; import org.eclipse.aether.internal.impl.StubRemoteRepositoryManager; @@ -37,6 +38,8 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * UT for {@link BfDependencyCollector}. @@ -68,6 +71,75 @@ private Dependency newDep(String coords, String scope, Collection exc return d.setExclusions(exclusions); } + /** + * Verifies that the pool cache is transparent w.r.t. the DependencyManager: the graph + * structure must not depend on whether the pool hits or misses. + *

+ * Scenario (from #2013): + *

+     *   root
+     *   ├── b      → c → d
+     *   └── b-alt  → c → d   (c is a shared transitive dependency)
+     * 
+ * With {@link TransitiveDependencyManager}, {@code deriveChildManager()} used to always + * create a new instance (unique {@code path} field), making every pool key unique. + * The pool would miss for {@code c} under {@code b-alt}, the skipper would mark it as + * a duplicate, and the node would end up with zero children — even though the same + * {@code c} under {@code b} had children. + *

+ * The fix in {@code AbstractDependencyManager.deriveChildManager()} reuses the same + * manager instance when no new management data is collected, so the pool key matches + * and children are preserved. + */ + @Test + void testPoolCacheTransparencyWithTransitiveDependencyManager() throws DependencyCollectionException { + collector = setupCollector(newReader("pool-cache-transparency/")); + parser = new DependencyGraphParser("artifact-descriptions/pool-cache-transparency/"); + session.setDependencyManager(new TransitiveDependencyManager(null)); + + Dependency root = newDep("gid:root:ext:1.0", "compile"); + CollectRequest request = new CollectRequest(root, Collections.singletonList(repository)); + CollectResult result = collector.collectDependencies(session, request); + + assertEquals(0, result.getExceptions().size()); + + // root has two children: b and b-alt + DependencyNode rootNode = result.getRoot(); + assertEquals(2, rootNode.getChildren().size(), "root should have 2 children (b, b-alt)"); + + // b → c + DependencyNode b = rootNode.getChildren().get(0); + assertEquals("b", b.getArtifact().getArtifactId()); + assertFalse(b.getChildren().isEmpty(), "b should have children"); + + // b → c → d + DependencyNode cUnderB = b.getChildren().get(0); + assertEquals("c", cUnderB.getArtifact().getArtifactId()); + assertFalse(cUnderB.getChildren().isEmpty(), "c under b should have children (d)"); + + // b-alt → c (this is the key assertion: c under b-alt must also have children) + DependencyNode bAlt = rootNode.getChildren().get(1); + assertEquals("b-alt", bAlt.getArtifact().getArtifactId()); + assertFalse(bAlt.getChildren().isEmpty(), "b-alt should have children"); + + DependencyNode cUnderBAlt = bAlt.getChildren().get(0); + assertEquals("c", cUnderBAlt.getArtifact().getArtifactId()); + + // Before the fix, this assertion would fail: c under b-alt had zero children + // because the pool key differed (different DependencyManager instance) and the + // skipper marked it as a duplicate. + assertFalse( + cUnderBAlt.getChildren().isEmpty(), + "c under b-alt should have children (d) — pool cache must be transparent"); + + // Verify that c's child is d in both subtrees + assertEquals("d", cUnderB.getChildren().get(0).getArtifact().getArtifactId()); + assertTrue( + cUnderBAlt.getChildren().stream() + .anyMatch(n -> "d".equals(n.getArtifact().getArtifactId())), + "c under b-alt should have d as a child"); + } + @Test void testSkipperWithDifferentExclusion() throws DependencyCollectionException { collector = setupCollector(newReader("managed/")); diff --git a/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b-alt_1.0.ini b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b-alt_1.0.ini new file mode 100644 index 0000000000..a5aecf921b --- /dev/null +++ b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b-alt_1.0.ini @@ -0,0 +1,2 @@ +[dependencies] +gid:c:ext:1.0 diff --git a/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b_1.0.ini b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b_1.0.ini new file mode 100644 index 0000000000..a5aecf921b --- /dev/null +++ b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_b_1.0.ini @@ -0,0 +1,2 @@ +[dependencies] +gid:c:ext:1.0 diff --git a/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_c_1.0.ini b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_c_1.0.ini new file mode 100644 index 0000000000..732de5300a --- /dev/null +++ b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_c_1.0.ini @@ -0,0 +1,2 @@ +[dependencies] +gid:d:ext:1.0 diff --git a/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_d_1.0.ini b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_d_1.0.ini new file mode 100644 index 0000000000..61a252c235 --- /dev/null +++ b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_d_1.0.ini @@ -0,0 +1 @@ +[dependencies] diff --git a/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_root_1.0.ini b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_root_1.0.ini new file mode 100644 index 0000000000..f59e27e009 --- /dev/null +++ b/maven-resolver-impl/src/test/resources/artifact-descriptions/pool-cache-transparency/gid_root_1.0.ini @@ -0,0 +1,3 @@ +[dependencies] +gid:b:ext:1.0 +gid:b-alt:ext:1.0 diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java index 2b2eeab358..c124a018d2 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java @@ -379,6 +379,23 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) } } + // Optimization: when no new management data was collected at this depth and management + // is already being applied (depth >= applyFrom), reuse this instance. This avoids creating + // unnecessarily distinct DependencyManager instances that would defeat the BF collector's + // pool cache — the pool key includes the manager, so distinct-but-semantically-equal + // managers cause pool misses, which in turn lets the skipper prune subtrees that should + // have been served from the cache. This is the common case for transitive dependencies + // whose POMs do not declare . + // See https://github.com/apache/maven-resolver/issues/2013 + if (managedVersions == null + && managedScopes == null + && managedOptionals == null + && managedLocalPaths == null + && managedExclusions == null + && isApplied()) { + return this; + } + return newInstance( managedVersions != null ? managedVersions.done() : null, managedScopes != null ? managedScopes.done() : null, diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java index db5c4afe17..9976fe1d02 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java @@ -204,6 +204,38 @@ void testTransitive() { assertNull(mngt); } + /** + * Verifies the instance-reuse optimization in {@link AbstractDependencyManager#deriveChildManager}: + * when no new management data is collected and management is already being applied + * (depth >= applyFrom), deriveChildManager should return the same instance. + * This is critical for BF collector pool cache transparency (issue #2013). + */ + @Test + void testDeriveChildManagerReusesInstanceWhenNoNewManagementData() { + DependencyManager manager = new TransitiveDependencyManager(null); + + // depth=0 → depth=1: root → first level (applyFrom=2, so not yet applied) + DependencyManager depth1 = manager.deriveChildManager(newContext()); + assertNotSame(manager, depth1, "depth 0→1: must return new instance (not yet applied)"); + + // depth=1 → depth=2: no managed deps, now at applyFrom=2 (applied) + DependencyManager depth2 = depth1.deriveChildManager(newContext()); + assertNotSame(depth1, depth2, "depth 1→2: must return new instance (crossing applyFrom boundary)"); + + // depth=2 → depth=3: no managed deps, already applied → should reuse + DependencyManager depth3 = depth2.deriveChildManager(newContext()); + assertSame(depth2, depth3, "depth 2→3 with no managed deps: should reuse instance"); + + // depth=3 → depth=4: still no managed deps → should keep reusing + DependencyManager depth4 = depth3.deriveChildManager(newContext()); + assertSame(depth3, depth4, "depth 3→4 with no managed deps: should reuse instance"); + + // depth=2 → depth=3 with managed deps: should NOT reuse + DependencyManager depth3WithMgmt = + depth2.deriveChildManager(newContext(new Dependency(new DefaultArtifact("new:dep:1.0"), "compile"))); + assertNotSame(depth2, depth3WithMgmt, "depth 2→3 with managed deps: must return new instance"); + } + @Test void testDefault() { DependencyManager manager = new DefaultDependencyManager(null); From 080296079df3df080ce12bb1ca0faa58b721e8d7 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Tue, 28 Jul 2026 09:54:31 +0200 Subject: [PATCH 2/8] Address review: add DefaultDependencyManager and nearer-to-root-wins tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two tests suggested by review on PR #2014: 1. testDeriveChildManagerReusesInstanceWithDefaultManager — verifies the optimization also works with DefaultDependencyManager (applyFrom=0), where instance reuse fires at the very first derivation. 2. testNearerToRootWinsAfterOptimizationReusesInstance — documents that the "nearer-to-root wins" invariant holds after the optimization reuses an instance: root's version management at depth 1 still wins when a deeper POM tries to override the same key, because containsManagedVersion finds it in ancestors and skips collection. Co-Authored-By: Claude Opus 4.6 --- .../graph/manager/DependencyManagerTest.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java index 9976fe1d02..9285a35137 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java @@ -236,6 +236,69 @@ void testDeriveChildManagerReusesInstanceWhenNoNewManagementData() { assertNotSame(depth2, depth3WithMgmt, "depth 2→3 with managed deps: must return new instance"); } + /** + * Verifies the instance-reuse optimization with {@link DefaultDependencyManager} (applyFrom=0). + * Since management is applied from depth 0, the optimization should fire at the very first + * derivation when no management data is collected. + */ + @Test + void testDeriveChildManagerReusesInstanceWithDefaultManager() { + DependencyManager manager = new DefaultDependencyManager(null); + + // DefaultDependencyManager has applyFrom=0, so isApplied() is true from the start. + // depth=0 → depth=1: no managed deps → should reuse (already applied) + DependencyManager depth1 = manager.deriveChildManager(newContext()); + assertSame(manager, depth1, "depth 0→1 with no managed deps: should reuse (applyFrom=0)"); + + // depth=0 → depth=1 with managed deps: should NOT reuse + DependencyManager depth1WithMgmt = + manager.deriveChildManager(newContext(new Dependency(new DefaultArtifact("mgd:dep:1.0"), "compile"))); + assertNotSame(manager, depth1WithMgmt, "depth 0→1 with managed deps: must return new instance"); + + // depth=1 (with mgmt) → depth=2: no managed deps → should reuse + DependencyManager depth2 = depth1WithMgmt.deriveChildManager(newContext()); + assertSame(depth1WithMgmt, depth2, "depth 1→2 with no managed deps: should reuse"); + } + + /** + * Verifies that the "nearer-to-root wins" invariant holds when the optimization + * reuses instances. Scenario: root contributes version management at depth 1, + * the optimization fires at depth 2→3 (empty context), and a deeper POM tries + * to override the same key — the root's version must still win. + */ + @Test + void testNearerToRootWinsAfterOptimizationReusesInstance() { + DependencyManager manager = new TransitiveDependencyManager(null); + + // depth=0 → depth=1: root contributes version 1.0 for artifact "x" + DependencyManager depth1 = + manager.deriveChildManager(newContext(new Dependency(new DefaultArtifact("test:x:1.0"), "compile"))); + + // depth=1 → depth=2: empty context (new instance because applyFrom boundary) + DependencyManager depth2 = depth1.deriveChildManager(newContext()); + assertNotSame(depth1, depth2, "depth 1→2: new instance (crossing applyFrom boundary)"); + + // depth=2 → depth=3: empty context, optimization fires + DependencyManager depth3 = depth2.deriveChildManager(newContext()); + assertSame(depth2, depth3, "depth 2→3: optimization should reuse instance"); + + // Verify root's version still applies after optimization reuse + DependencyManagement mngt = depth3.manageDependency(new Dependency(new DefaultArtifact("test:x:9.0"), null)); + assertNotNull(mngt, "management should apply at depth >= applyFrom"); + assertEquals("1.0", mngt.getVersion(), "root's version must apply after optimization reuse"); + + // depth=3 → depth=4: context tries to override "x" with 2.0, but containsManagedVersion + // finds it in ancestors → no new data collected → optimization fires again + DependencyManager depth4 = + depth3.deriveChildManager(newContext(new Dependency(new DefaultArtifact("test:x:2.0"), "compile"))); + assertSame(depth3, depth4, "optimization should fire when context only has already-managed keys"); + + // Root's version still wins ("nearer-to-root wins" invariant) + mngt = depth4.manageDependency(new Dependency(new DefaultArtifact("test:x:9.0"), null)); + assertNotNull(mngt, "management should still apply"); + assertEquals("1.0", mngt.getVersion(), "nearer-to-root version must win over deeper override attempt"); + } + @Test void testDefault() { DependencyManager manager = new DefaultDependencyManager(null); From 69d7e392f476dc5e629eb0f7c2b4c26e1d619c32 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 21:30:41 +0200 Subject: [PATCH 3/8] Optimize Key, MMap, and AbstractDependencyManager equality checks JFR profiling on a 4,383-module project showed three additional hotspots beyond the deriveChildManager instance-reuse fix: - RelocatedArtifact.getArtifactId (9% CPU): Key.equals() called artifact.getArtifactId() through virtual dispatch on every HashMap comparison. Fix: cache the four GACE coordinate strings directly in Key at construction time, eliminating the delegation overhead. - AbstractMap.equals (9% CPU): MMap.DoneMMap.equals() went straight to HashMap.equals() without checking identity or hashCode first. Fix: add identity short-circuit and pre-computed hashCode fast-rejection before the expensive deep map equality. - AbstractDependencyManager.equals: compared the path ArrayList (O(depth)) before the cheap depth int check. Fix: add hashCode fast-rejection as the first guard, check depth before path, and move path comparison last. Combined with the instance-reuse optimization, these changes eliminate all resolver-related CPU hotspots, reducing build time from 793s to 19s on the test project (42x speedup). Co-Authored-By: Claude Opus 4.6 --- .../manager/AbstractDependencyManager.java | 38 +++++++++++++------ .../aether/util/graph/manager/MMap.java | 13 ++++++- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java index c124a018d2..e654821c4b 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java @@ -535,13 +535,19 @@ public boolean equals(Object obj) { } AbstractDependencyManager that = (AbstractDependencyManager) obj; + // Fast rejection: hashCode is pre-computed, so a mismatch avoids + // the expensive path/map equality checks below. + if (hashCode != that.hashCode) { + return false; + } // exclude managedLocalPaths - return Objects.equals(path, that.path) - && depth == that.depth + // Check cheap fields (depth) before expensive ones (path, maps) + return depth == that.depth && Objects.equals(managedVersions, that.managedVersions) && Objects.equals(managedScopes, that.managedScopes) && Objects.equals(managedOptionals, that.managedOptionals) - && Objects.equals(managedExclusions, that.managedExclusions); + && Objects.equals(managedExclusions, that.managedExclusions) + && Objects.equals(path, that.path); } @Override @@ -554,18 +560,26 @@ public int hashCode() { * GACE = Group, Artifact, Classifier, Extension (excludes version for management purposes). */ protected static class Key { - private final Artifact artifact; + private final String groupId; + private final String artifactId; + private final String extension; + private final String classifier; private final int hashCode; /** * Creates a new key from the given artifact's GACE coordinates. + * Coordinate strings are cached eagerly to avoid repeated virtual dispatch + * through delegation wrappers like {@code RelocatedArtifact} during + * {@link #equals} comparisons in hash maps. * * @param artifact the artifact to create a key for */ Key(Artifact artifact) { - this.artifact = artifact; - this.hashCode = Objects.hash( - artifact.getArtifactId(), artifact.getGroupId(), artifact.getExtension(), artifact.getClassifier()); + this.groupId = artifact.getGroupId(); + this.artifactId = artifact.getArtifactId(); + this.extension = artifact.getExtension(); + this.classifier = artifact.getClassifier(); + this.hashCode = Objects.hash(artifactId, groupId, extension, classifier); } @Override @@ -576,10 +590,10 @@ public boolean equals(Object obj) { return false; } Key that = (Key) obj; - return artifact.getArtifactId().equals(that.artifact.getArtifactId()) - && artifact.getGroupId().equals(that.artifact.getGroupId()) - && artifact.getExtension().equals(that.artifact.getExtension()) - && artifact.getClassifier().equals(that.artifact.getClassifier()); + return artifactId.equals(that.artifactId) + && groupId.equals(that.groupId) + && extension.equals(that.extension) + && classifier.equals(that.classifier); } @Override @@ -589,7 +603,7 @@ public int hashCode() { @Override public String toString() { - return String.valueOf(artifact); + return groupId + ":" + artifactId + ":" + extension + (classifier.isEmpty() ? "" : ":" + classifier); } } diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/MMap.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/MMap.java index 2d0079f86c..75f0a34101 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/MMap.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/MMap.java @@ -122,10 +122,19 @@ public int hashCode() { @Override public boolean equals(Object o) { - if (!(o instanceof MMap)) { + if (this == o) { + return true; + } + if (!(o instanceof DoneMMap)) { + return false; + } + DoneMMap other = (DoneMMap) o; + // Fast rejection: hashCode is pre-computed, so a mismatch avoids + // the expensive deep map equality below (which triggers Key.equals + // for every entry). + if (hashCode != other.hashCode) { return false; } - MMap other = (MMap) o; return delegate.equals(other.delegate); } } From 52d9d4b89775ab6ba1ce89d5b770eb43461ec011 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Fri, 31 Jul 2026 22:47:09 +0200 Subject: [PATCH 4/8] Replace ArrayList path with parent pointer (cons-list) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the ArrayList path field with a single AbstractDependencyManager parent pointer, forming a linked list from leaf toward root. This eliminates three O(depth) costs that dominated JFR profiles on large multi-module projects with TransitiveDependencyManager: 1. deriveChildManager() no longer copies the entire ancestor list — siblings now share the same parent reference (O(1) derive). 2. hashCode is cascading: each level incorporates its parent's pre-computed hash, so a single int comparison reflects the entire ancestor chain without walking it. 3. equals() compares parents recursively, but each level is hash-guarded and shared parents (same identity) short-circuit immediately via the this==obj check. Lookup semantics are preserved: the parent chain is walked from leaf toward root, keeping the last hit (closest to root) to maintain "nearest to root wins" precedence. Since containsManagedXxx() blocks duplicate entries during derive, there is typically at most one hit per key in the chain. Co-Authored-By: Claude Opus 4.6 --- .../manager/AbstractDependencyManager.java | 110 ++++++++++++------ .../manager/ClassicDependencyManager.java | 9 +- .../manager/DefaultDependencyManager.java | 9 +- .../manager/TransitiveDependencyManager.java | 9 +- 4 files changed, 83 insertions(+), 54 deletions(-) diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java index e654821c4b..780b95b6ce 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java @@ -35,6 +35,12 @@ import static java.util.Objects.requireNonNull; +// Note on lookup semantics: management rules follow "nearest to root wins" precedence. +// The parent pointer forms a linked list from leaf toward root. Lookups walk the full +// chain and keep the last hit (closest to root). Since containsManagedXxx() during +// derive blocks duplicate entries for most properties, there is typically at most one +// hit per key in the chain, making the walk direction irrelevant in practice. + /** * A dependency manager support class for Maven-specific dependency graph management. * @@ -105,8 +111,12 @@ * @since 2.0.0 */ public abstract class AbstractDependencyManager implements DependencyManager { - /** The path of parent managers from root to current level. */ - protected final ArrayList path; + /** + * Parent manager in the dependency graph (forms a linked list from leaf toward root). + * Replaces the previous {@code ArrayList path} field — + * siblings share the same parent reference (O(1) derive instead of O(depth) copy). + */ + protected final AbstractDependencyManager parent; /** The current depth in the dependency graph (0 = factory, 1 = root, 2+ = descendants). */ protected final int depth; @@ -135,7 +145,11 @@ public abstract class AbstractDependencyManager implements DependencyManager { /** System dependency scope handler, may be null if no system scope is defined. */ protected final SystemDependencyScope systemDependencyScope; - /** Pre-computed hash code (excludes managedLocalPaths). */ + /** + * Pre-computed hash code (excludes managedLocalPaths). + * Cascading: incorporates the parent's hashCode so a single int comparison + * reflects the entire ancestor chain without walking it. + */ private final int hashCode; /** @@ -148,7 +162,7 @@ public abstract class AbstractDependencyManager implements DependencyManager { */ protected AbstractDependencyManager(int deriveUntil, int applyFrom, ScopeManager scopeManager) { this( - new ArrayList<>(), + null, 0, deriveUntil, applyFrom, @@ -164,7 +178,7 @@ protected AbstractDependencyManager(int deriveUntil, int applyFrom, ScopeManager @SuppressWarnings("checkstyle:ParameterNumber") protected AbstractDependencyManager( - ArrayList path, + AbstractDependencyManager parent, int depth, int deriveUntil, int applyFrom, @@ -174,7 +188,7 @@ protected AbstractDependencyManager( MMap managedLocalPaths, MMap>> managedExclusions, SystemDependencyScope systemDependencyScope) { - this.path = path; + this.parent = parent; this.depth = depth; this.deriveUntil = deriveUntil; this.applyFrom = applyFrom; @@ -186,8 +200,15 @@ protected AbstractDependencyManager( // nullable: if using scope manager, but there is no system scope defined this.systemDependencyScope = systemDependencyScope; - // exclude managedLocalPaths - this.hashCode = Objects.hash(path, depth, managedVersions, managedScopes, managedOptionals, managedExclusions); + // Cascading hash: incorporates the parent's pre-computed hash so a single int + // comparison reflects the entire ancestor chain. Excludes managedLocalPaths. + this.hashCode = Objects.hash( + parent != null ? parent.hashCode : 0, + depth, + managedVersions, + managedScopes, + managedOptionals, + managedExclusions); } protected abstract DependencyManager newInstance( @@ -198,7 +219,7 @@ protected abstract DependencyManager newInstance( MMap>> managedExclusions); private boolean containsManagedVersion(Key key, MMap managedVersions) { - for (AbstractDependencyManager ancestor : path) { + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { return true; } @@ -206,20 +227,27 @@ private boolean containsManagedVersion(Key key, MMap managedVersion return managedVersions != null && managedVersions.containsKey(key); } + /** + * Walks the parent chain from leaf toward root, returning the value closest to root + * ("nearest to root wins"). At depth 1, also checks own data (root self-application). + */ private String getManagedVersion(Key key) { - for (AbstractDependencyManager ancestor : path) { + String result = null; + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { - return ancestor.managedVersions.get(key); + result = ancestor.managedVersions.get(key); } } if (depth == 1 && managedVersions != null && managedVersions.containsKey(key)) { - return managedVersions.get(key); + // At depth 1, own data IS root data — apply onto self. + // This is always root-level, so it wins (nearest to root). + result = managedVersions.get(key); } - return null; + return result; } private boolean containsManagedScope(Key key, MMap managedScopes) { - for (AbstractDependencyManager ancestor : path) { + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { return true; } @@ -227,20 +255,24 @@ private boolean containsManagedScope(Key key, MMap managedScopes) { return managedScopes != null && managedScopes.containsKey(key); } + /** + * Walks the parent chain from leaf toward root, returning the value closest to root. + */ private String getManagedScope(Key key) { - for (AbstractDependencyManager ancestor : path) { + String result = null; + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { - return ancestor.managedScopes.get(key); + result = ancestor.managedScopes.get(key); } } if (depth == 1 && managedScopes != null && managedScopes.containsKey(key)) { - return managedScopes.get(key); + result = managedScopes.get(key); } - return null; + return result; } private boolean containsManagedOptional(Key key, MMap managedOptionals) { - for (AbstractDependencyManager ancestor : path) { + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { return true; } @@ -248,20 +280,24 @@ private boolean containsManagedOptional(Key key, MMap managedOptio return managedOptionals != null && managedOptionals.containsKey(key); } + /** + * Walks the parent chain from leaf toward root, returning the value closest to root. + */ private Boolean getManagedOptional(Key key) { - for (AbstractDependencyManager ancestor : path) { + Boolean result = null; + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { - return ancestor.managedOptionals.get(key); + result = ancestor.managedOptionals.get(key); } } if (depth == 1 && managedOptionals != null && managedOptionals.containsKey(key)) { - return managedOptionals.get(key); + result = managedOptionals.get(key); } - return null; + return result; } private boolean containsManagedLocalPath(Key key, MMap managedLocalPaths) { - for (AbstractDependencyManager ancestor : path) { + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { return true; } @@ -272,33 +308,33 @@ private boolean containsManagedLocalPath(Key key, MMap managedLocal /** * Gets the managed local path for system dependencies. * Note: Local paths don't follow the depth=1 special rule like versions/scopes. - * - * @param key the dependency key - * @return the managed local path, or null if not managed + * Walks the parent chain from leaf toward root, returning the value closest to root. */ private String getManagedLocalPath(Key key) { - for (AbstractDependencyManager ancestor : path) { + String result = null; + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { - return ancestor.managedLocalPaths.get(key); + result = ancestor.managedLocalPaths.get(key); } } if (managedLocalPaths != null && managedLocalPaths.containsKey(key)) { - return managedLocalPaths.get(key); + result = managedLocalPaths.get(key); } - return null; + return result; } /** * Merges exclusions from all levels in the dependency path. * Unlike other managed properties, exclusions are accumulated additively * from root to current level throughout the entire dependency path. + * Walk direction is irrelevant since all levels contribute. * * @param key the dependency key * @return merged collection of exclusions, or null if none exist */ private Collection getManagedExclusions(Key key) { ArrayList result = new ArrayList<>(); - for (AbstractDependencyManager ancestor : path) { + for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedExclusions != null && ancestor.managedExclusions.containsKey(key)) { result.addAll(ancestor.managedExclusions.get(key).value); } @@ -535,19 +571,21 @@ public boolean equals(Object obj) { } AbstractDependencyManager that = (AbstractDependencyManager) obj; - // Fast rejection: hashCode is pre-computed, so a mismatch avoids - // the expensive path/map equality checks below. + // Fast rejection: cascading hashCode reflects the entire ancestor chain, + // so a single int mismatch rejects without walking any parent pointers. if (hashCode != that.hashCode) { return false; } // exclude managedLocalPaths - // Check cheap fields (depth) before expensive ones (path, maps) + // Check cheap fields (depth) before expensive ones (maps, parent chain). + // Parent comparison is recursive but each level is hash-guarded, and + // shared parents (same identity) short-circuit via the this==obj check. return depth == that.depth && Objects.equals(managedVersions, that.managedVersions) && Objects.equals(managedScopes, that.managedScopes) && Objects.equals(managedOptionals, that.managedOptionals) && Objects.equals(managedExclusions, that.managedExclusions) - && Objects.equals(path, that.path); + && Objects.equals(parent, that.parent); } @Override diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/ClassicDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/ClassicDependencyManager.java index beefb1cb65..481f0baadb 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/ClassicDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/ClassicDependencyManager.java @@ -18,7 +18,6 @@ */ package org.eclipse.aether.util.graph.manager; -import java.util.ArrayList; import java.util.Collection; import org.eclipse.aether.collection.DependencyCollectionContext; @@ -89,7 +88,7 @@ public ClassicDependencyManager(ScopeManager scopeManager) { @SuppressWarnings("checkstyle:ParameterNumber") private ClassicDependencyManager( - ArrayList path, + AbstractDependencyManager parent, int depth, int deriveUntil, int applyFrom, @@ -100,7 +99,7 @@ private ClassicDependencyManager( MMap>> managedExclusions, SystemDependencyScope systemDependencyScope) { super( - path, + parent, depth, deriveUntil, applyFrom, @@ -148,10 +147,8 @@ protected DependencyManager newInstance( MMap managedOptionals, MMap managedLocalPaths, MMap>> managedExclusions) { - ArrayList path = new ArrayList<>(this.path); - path.add(this); return new ClassicDependencyManager( - path, + this, depth + 1, deriveUntil, applyFrom, diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/DefaultDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/DefaultDependencyManager.java index 05fc72f9bc..a1b1297f77 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/DefaultDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/DefaultDependencyManager.java @@ -18,7 +18,6 @@ */ package org.eclipse.aether.util.graph.manager; -import java.util.ArrayList; import java.util.Collection; import org.eclipse.aether.collection.DependencyManager; @@ -97,7 +96,7 @@ public DefaultDependencyManager(ScopeManager scopeManager) { @SuppressWarnings("checkstyle:ParameterNumber") private DefaultDependencyManager( - ArrayList path, + AbstractDependencyManager parent, int depth, int deriveUntil, int applyFrom, @@ -108,7 +107,7 @@ private DefaultDependencyManager( MMap>> managedExclusions, SystemDependencyScope systemDependencyScope) { super( - path, + parent, depth, deriveUntil, applyFrom, @@ -127,10 +126,8 @@ protected DependencyManager newInstance( MMap managedOptionals, MMap managedLocalPaths, MMap>> managedExclusions) { - ArrayList path = new ArrayList<>(this.path); - path.add(this); return new DefaultDependencyManager( - path, + this, depth + 1, deriveUntil, applyFrom, diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/TransitiveDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/TransitiveDependencyManager.java index c45b64bc7e..3af9b2dad9 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/TransitiveDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/TransitiveDependencyManager.java @@ -18,7 +18,6 @@ */ package org.eclipse.aether.util.graph.manager; -import java.util.ArrayList; import java.util.Collection; import org.eclipse.aether.collection.DependencyManager; @@ -96,7 +95,7 @@ public TransitiveDependencyManager(ScopeManager scopeManager) { @SuppressWarnings("checkstyle:ParameterNumber") private TransitiveDependencyManager( - ArrayList path, + AbstractDependencyManager parent, int depth, int deriveUntil, int applyFrom, @@ -107,7 +106,7 @@ private TransitiveDependencyManager( MMap>> managedExclusions, SystemDependencyScope systemDependencyScope) { super( - path, + parent, depth, deriveUntil, applyFrom, @@ -126,10 +125,8 @@ protected DependencyManager newInstance( MMap managedOptionals, MMap managedLocalPaths, MMap>> managedExclusions) { - ArrayList path = new ArrayList<>(this.path); - path.add(this); return new TransitiveDependencyManager( - path, + this, depth + 1, deriveUntil, applyFrom, From 6205d17d2133ebfa4aff13b1fc1c06bede001a56 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 2 Aug 2026 01:03:41 +0200 Subject: [PATCH 5/8] Memoize deriveChildManager and defer it after pool cache check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use a single-entry (last-seen) memoization cache in deriveChildManager to skip the O(managedDependencies) loop when the same interned list is seen consecutively — the common case in BFS dependency collection where siblings share a parent descriptor. A single-entry cache uses constant memory (two fields) and avoids the retention issue of an unbounded IdentityHashMap that would prevent GC of derived manager subtrees. In BfDependencyCollector.doRecurse(), perform a speculative pool cache check using the parent manager BEFORE calling deriveChildManager. When the derived manager equals the parent (the common case for transitive deps without dependencyManagement), the speculative key matches and we skip derivation entirely on pool hits. --- .../collect/bf/BfDependencyCollector.java | 104 +++++++++++------- .../manager/AbstractDependencyManager.java | 49 +++++++-- .../graph/manager/DependencyManagerTest.java | 104 ++++++++++++++++++ 3 files changed, 211 insertions(+), 46 deletions(-) diff --git a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/bf/BfDependencyCollector.java b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/bf/BfDependencyCollector.java index c7c3edecb1..8d1d6f763f 100644 --- a/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/bf/BfDependencyCollector.java +++ b/maven-resolver-impl/src/main/java/org/eclipse/aether/internal/impl/collect/bf/BfDependencyCollector.java @@ -377,8 +377,6 @@ private void doRecurse( DependencySelector childSelector = parentContext.depSelector != null ? parentContext.depSelector.deriveChildSelector(context) : null; - DependencyManager childManager = - parentContext.depManager != null ? parentContext.depManager.deriveChildManager(context) : null; DependencyTraverser childTraverser = parentContext.depTraverser != null ? parentContext.depTraverser.deriveChildTraverser(context) : null; VersionFilter childFilter = @@ -389,50 +387,80 @@ private void doRecurse( : remoteRepositoryManager.aggregateRepositories( args.session, parentContext.repositories, descriptorResult.getRepositories(), true); - Object key = args.pool.toKey( + // Optimization: try pool cache with the parent manager as a speculative key BEFORE + // calling deriveChildManager. When deriveChildManager returns `this` (the common case — + // no new management data at this depth), the parent manager IS the child manager, so + // this speculative key matches any previously stored entry. On pool hit, we skip + // deriveChildManager entirely, saving even the memoization cache lookup. + Object speculativeKey = args.pool.toKey( parentContext.dependency.getArtifact(), childRepos, childSelector, - childManager, + parentContext.depManager, childTraverser, childFilter); + List children = args.pool.getChildren(speculativeKey); + if (children != null) { + child.setChildren(children); + return; + } - List children = args.pool.getChildren(key); - if (children == null) { - boolean skipResolution = args.skipper.skipResolution(child, parentContext.parents); - if (!skipResolution) { - List parents = new ArrayList<>(parentContext.parents.size() + 1); - parents.addAll(parentContext.parents); - parents.add(child); - for (Dependency dependency : descriptorResult.getDependencies()) { - if (childSelector != null && !childSelector.selectDependency(dependency)) { - continue; - } - RequestTrace childTrace = collectStepTrace( - parentContext.trace, args.request.getRequestContext(), parents, dependency); - PremanagedDependency premanagedDependency = PremanagedDependency.create( - childManager, dependency, disableVersionManagement, args.premanagedState); - DependencyProcessingContext processingContext = new DependencyProcessingContext( - childSelector, - childManager, - childTraverser, - childFilter, - childTrace, - childRepos, - descriptorResult.getManagedDependencies(), - parents, - dependency, - premanagedDependency); - // resolve descriptors ahead for managed dependency - processingContext.withDependency(processingContext.premanagedDependency.getManagedDependency()); - resolveArtifactDescriptorAsync(args, processingContext, results); - args.dependencyProcessingQueue.add(processingContext); + // Speculative miss — compute the actual derived manager + DependencyManager childManager = + parentContext.depManager != null ? parentContext.depManager.deriveChildManager(context) : null; + + Object key; + if (childManager == parentContext.depManager) { + // Manager unchanged — speculative key was correct, already checked and missed + key = speculativeKey; + } else { + // Manager changed — recompute key and check pool again with the correct manager + key = args.pool.toKey( + parentContext.dependency.getArtifact(), + childRepos, + childSelector, + childManager, + childTraverser, + childFilter); + children = args.pool.getChildren(key); + if (children != null) { + child.setChildren(children); + return; + } + } + + // True cache miss — do full resolution + boolean skipResolution = args.skipper.skipResolution(child, parentContext.parents); + if (!skipResolution) { + List parents = new ArrayList<>(parentContext.parents.size() + 1); + parents.addAll(parentContext.parents); + parents.add(child); + for (Dependency dependency : descriptorResult.getDependencies()) { + if (childSelector != null && !childSelector.selectDependency(dependency)) { + continue; } - args.pool.putChildren(key, child.getChildren()); - args.skipper.cache(child, parents); + RequestTrace childTrace = + collectStepTrace(parentContext.trace, args.request.getRequestContext(), parents, dependency); + PremanagedDependency premanagedDependency = PremanagedDependency.create( + childManager, dependency, disableVersionManagement, args.premanagedState); + DependencyProcessingContext processingContext = new DependencyProcessingContext( + childSelector, + childManager, + childTraverser, + childFilter, + childTrace, + childRepos, + descriptorResult.getManagedDependencies(), + parents, + dependency, + premanagedDependency); + // resolve descriptors ahead for managed dependency + processingContext.withDependency(processingContext.premanagedDependency.getManagedDependency()); + resolveArtifactDescriptorAsync(args, processingContext, results); + args.dependencyProcessingQueue.add(processingContext); } - } else { - child.setChildren(children); + args.pool.putChildren(key, child.getChildren()); + args.skipper.cache(child, parents); } } diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java index 780b95b6ce..091f707e9d 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java @@ -22,6 +22,7 @@ import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; import org.eclipse.aether.artifact.Artifact; @@ -152,6 +153,23 @@ public abstract class AbstractDependencyManager implements DependencyManager { */ private final int hashCode; + /** + * Single-entry memoization cache for {@link #deriveChildManager(DependencyCollectionContext)}: + * remembers the last managed-dependency list (by reference identity) and the result it produced. + *

+ * In BFS dependency collection, consecutive siblings typically share the same parent artifact + * descriptor and hence the same interned managed-dependency list (guaranteed by DataPool's + * {@code internArtifactDescriptorManagedDependencies}). A single-entry cache therefore hits + * on nearly every call while using only two fields of constant memory — unlike an unbounded + * {@code IdentityHashMap} which would retain every derived {@code DependencyManager} and + * prevent GC of the dependency subtrees they reference. + *

+ * The BFS collector's traversal loop is single-threaded, so no synchronization is needed. + */ + private transient List lastManagedDeps; + + private transient DependencyManager lastDeriveResult; + /** * Creates a new dependency manager with the specified derivation and application parameters. * @@ -352,13 +370,22 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) return this; } + // Memoization: if this is the same managed dependencies list we saw last time + // (same object reference — guaranteed by DataPool's descriptor/list interning), + // return the cached result immediately. This turns the common case from O(managedDeps) + // iteration + Key hashing into a single O(1) identity check. + List managedDeps = context.getManagedDependencies(); + if (managedDeps == lastManagedDeps && lastDeriveResult != null) { + return lastDeriveResult; + } + MMap managedVersions = null; MMap managedScopes = null; MMap managedOptionals = null; MMap managedLocalPaths = null; MMap>> managedExclusions = null; - for (Dependency managedDependency : context.getManagedDependencies()) { + for (Dependency managedDependency : managedDeps) { Artifact artifact = managedDependency.getArtifact(); Key key = new Key(artifact); @@ -423,21 +450,27 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) // have been served from the cache. This is the common case for transitive dependencies // whose POMs do not declare . // See https://github.com/apache/maven-resolver/issues/2013 + DependencyManager result; if (managedVersions == null && managedScopes == null && managedOptionals == null && managedLocalPaths == null && managedExclusions == null && isApplied()) { - return this; + result = this; + } else { + result = newInstance( + managedVersions != null ? managedVersions.done() : null, + managedScopes != null ? managedScopes.done() : null, + managedOptionals != null ? managedOptionals.done() : null, + managedLocalPaths != null ? managedLocalPaths.done() : null, + managedExclusions != null ? managedExclusions.done() : null); } - return newInstance( - managedVersions != null ? managedVersions.done() : null, - managedScopes != null ? managedScopes.done() : null, - managedOptionals != null ? managedOptionals.done() : null, - managedLocalPaths != null ? managedLocalPaths.done() : null, - managedExclusions != null ? managedExclusions.done() : null); + // Cache the result for future calls with the same managed deps list identity + lastManagedDeps = managedDeps; + lastDeriveResult = result; + return result; } @Override diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java index 9285a35137..ecd54506aa 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java @@ -18,8 +18,10 @@ */ package org.eclipse.aether.util.graph.manager; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import org.eclipse.aether.RepositorySystemSession; import org.eclipse.aether.artifact.Artifact; @@ -363,4 +365,106 @@ void testDefault() { // DO NOT APPLY ONTO ITSELF assertNull(mngt); } + + /** + * Verifies the memoization cache in {@link AbstractDependencyManager#deriveChildManager}: + * when the same managed dependencies list (same object reference) is passed multiple times, + * the result must be identical (same object reference). This simulates the BFS collector + * processing multiple siblings that share the same artifact descriptor — each call sees + * the same interned managed deps list from the DataPool. + */ + @Test + void testDeriveChildManagerMemoizationOnListIdentity() { + DependencyManager manager = new TransitiveDependencyManager(null); + + // Get to depth >= applyFrom (depth 2) so the optimization can fire + manager = + manager.deriveChildManager(newContext(new Dependency(new DefaultArtifact("root:dep:1.0"), "compile"))); + manager = manager.deriveChildManager(newContext()); + + // Create a SINGLE managed deps list — simulates interned list from DataPool + List sharedManagedDeps = Arrays.asList( + new Dependency(new DefaultArtifact("mgd:a:1.0"), "compile"), + new Dependency(new DefaultArtifact("mgd:b:2.0"), "runtime")); + + // First call: processes the list, caches the result + DependencyCollectionContext ctx1 = TestUtils.newCollectionContext(session, null, sharedManagedDeps); + DependencyManager result1 = manager.deriveChildManager(ctx1); + + // Second call with the SAME list object: should return the cached result + DependencyCollectionContext ctx2 = TestUtils.newCollectionContext(session, null, sharedManagedDeps); + DependencyManager result2 = manager.deriveChildManager(ctx2); + + assertSame(result1, result2, "memoization must return same instance for same list identity"); + + // Third call with a DIFFERENT list object (same content): must NOT use cache + // (identity mismatch — different object reference) + List differentList = new ArrayList<>(sharedManagedDeps); + DependencyCollectionContext ctx3 = TestUtils.newCollectionContext(session, null, differentList); + DependencyManager result3 = manager.deriveChildManager(ctx3); + + // result3 should be equal to result1 (same content), but NOT necessarily the same object + // (identity-based cache misses on different list objects) + assertEquals(result1, result3, "same content should produce equal results"); + } + + /** + * Verifies that the single-entry memoization cache correctly distinguishes between different + * managed dependency lists. A cached result for list L1 must not be returned for list L2. + * Since the cache is single-entry (last-seen), calling with L2 evicts L1. + */ + @Test + void testDeriveChildManagerMemoizationDistinguishesDifferentLists() { + DependencyManager manager = new TransitiveDependencyManager(null); + + // Get to depth >= applyFrom + manager = + manager.deriveChildManager(newContext(new Dependency(new DefaultArtifact("root:dep:1.0"), "compile"))); + manager = manager.deriveChildManager(newContext()); + + // Two different list objects with different content + List list1 = Collections.singletonList(new Dependency(new DefaultArtifact("mgd:a:1.0"), "compile")); + List list2 = Collections.singletonList(new Dependency(new DefaultArtifact("mgd:b:2.0"), "runtime")); + + DependencyManager result1 = manager.deriveChildManager(TestUtils.newCollectionContext(session, null, list1)); + DependencyManager result2 = manager.deriveChildManager(TestUtils.newCollectionContext(session, null, list2)); + + // Different lists should produce different managers (different managed data) + assertNotSame(result1, result2, "different lists must not share cached result"); + + // Single-entry cache: last call was list2, so list2 should hit + DependencyManager result2Again = + manager.deriveChildManager(TestUtils.newCollectionContext(session, null, list2)); + assertSame(result2, result2Again, "list2 should return memoized result (last seen)"); + + // list1 was evicted by list2, so calling with list1 recomputes (equal but not same object) + DependencyManager result1Again = + manager.deriveChildManager(TestUtils.newCollectionContext(session, null, list1)); + assertEquals(result1, result1Again, "list1 should produce equal result after eviction"); + } + + /** + * Verifies that the memoization cache for empty managed deps lists works correctly. + * This is the most common case (leaf artifacts without dependencyManagement). + */ + @Test + void testDeriveChildManagerMemoizationWithEmptyList() { + DependencyManager manager = new TransitiveDependencyManager(null); + + // Get to depth >= applyFrom + manager = manager.deriveChildManager(newContext()); + manager = manager.deriveChildManager(newContext()); + + // Use the SAME empty list (e.g. Collections.emptyList() is a singleton) + List emptyList = Collections.emptyList(); + + DependencyManager result1 = + manager.deriveChildManager(TestUtils.newCollectionContext(session, null, emptyList)); + DependencyManager result2 = + manager.deriveChildManager(TestUtils.newCollectionContext(session, null, emptyList)); + + // Both should return `this` (no new data + isApplied) and be memoized + assertSame(manager, result1, "empty list should return this"); + assertSame(result1, result2, "memoization should return same instance for empty list"); + } } From 071bbe0f3fdc81cf9e3e2e6a42c488860ca64bbc Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 2 Aug 2026 14:38:40 +0200 Subject: [PATCH 6/8] Fix transitive dependency management: check this.managedVersions in containsManaged* and fix reuse optimization The containsManagedVersion/Scope/Optional/LocalPath methods were missing a check against this.managedVersions (the current instance's own management data). This regression was introduced in d4035d3a when the parameter shadowed the instance field. Additionally, the "reuse this" optimization (returning this when no new management data is collected) was hiding management data from children: getManagedVersion() only walks the parent chain, so a reused instance's own rules became invisible. Now the optimization only fires when both the context AND the instance carry no management data, ensuring that management rules always propagate through the parent chain. Fixes the testDependencyManagement_TransitiveDependencyManager and testDependencyManagement_DefaultDependencyManager test failures. Co-Authored-By: Claude Opus 4.6 --- .../manager/AbstractDependencyManager.java | 41 ++++++++++++++----- .../graph/manager/DependencyManagerTest.java | 10 ++++- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java index 091f707e9d..d7f6a4cd51 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java @@ -237,17 +237,25 @@ protected abstract DependencyManager newInstance( MMap>> managedExclusions); private boolean containsManagedVersion(Key key, MMap managedVersions) { + // Check current instance's own managed versions first (restores the pre-d4035d3a + // check that was accidentally dropped when the parameter was introduced). + if (this.managedVersions != null && this.managedVersions.containsKey(key)) { + return true; + } for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { return true; } } + // Check in-progress new map for duplicates within the same derivation step. return managedVersions != null && managedVersions.containsKey(key); } /** * Walks the parent chain from leaf toward root, returning the value closest to root - * ("nearest to root wins"). At depth 1, also checks own data (root self-application). + * ("nearest to root wins"). At depth 1, also checks own data (root self-application): + * when DefaultDependencyManager applies management from depth 0, the root-level rules + * are stored in DM1.managedVersions and must be visible to DM1.manageDependency(). */ private String getManagedVersion(Key key) { String result = null; @@ -257,14 +265,15 @@ private String getManagedVersion(Key key) { } } if (depth == 1 && managedVersions != null && managedVersions.containsKey(key)) { - // At depth 1, own data IS root data — apply onto self. - // This is always root-level, so it wins (nearest to root). result = managedVersions.get(key); } return result; } private boolean containsManagedScope(Key key, MMap managedScopes) { + if (this.managedScopes != null && this.managedScopes.containsKey(key)) { + return true; + } for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { return true; @@ -273,9 +282,6 @@ private boolean containsManagedScope(Key key, MMap managedScopes) { return managedScopes != null && managedScopes.containsKey(key); } - /** - * Walks the parent chain from leaf toward root, returning the value closest to root. - */ private String getManagedScope(Key key) { String result = null; for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { @@ -290,6 +296,9 @@ private String getManagedScope(Key key) { } private boolean containsManagedOptional(Key key, MMap managedOptionals) { + if (this.managedOptionals != null && this.managedOptionals.containsKey(key)) { + return true; + } for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { return true; @@ -298,9 +307,6 @@ private boolean containsManagedOptional(Key key, MMap managedOptio return managedOptionals != null && managedOptionals.containsKey(key); } - /** - * Walks the parent chain from leaf toward root, returning the value closest to root. - */ private Boolean getManagedOptional(Key key) { Boolean result = null; for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { @@ -315,6 +321,9 @@ private Boolean getManagedOptional(Key key) { } private boolean containsManagedLocalPath(Key key, MMap managedLocalPaths) { + if (this.managedLocalPaths != null && this.managedLocalPaths.containsKey(key)) { + return true; + } for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { return true; @@ -449,6 +458,13 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) // managers cause pool misses, which in turn lets the skipper prune subtrees that should // have been served from the cache. This is the common case for transitive dependencies // whose POMs do not declare . + // + // However, we can only reuse `this` when it carries no management data of its own. + // If `this` has management data (e.g. managedVersions != null), returning `this` would + // hide that data from the child: getManagedVersion() only checks the parent chain (not + // `this.managedVersions`), so a reused instance's own rules become invisible. In that + // case we must create a new child with null maps, making `this` the parent and putting + // the management data on the parent chain where getManagedVersion() can find it. // See https://github.com/apache/maven-resolver/issues/2013 DependencyManager result; if (managedVersions == null @@ -456,7 +472,12 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) && managedOptionals == null && managedLocalPaths == null && managedExclusions == null - && isApplied()) { + && isApplied() + && this.managedVersions == null + && this.managedScopes == null + && this.managedOptionals == null + && this.managedLocalPaths == null + && this.managedExclusions == null) { result = this; } else { result = newInstance( diff --git a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java index ecd54506aa..c9ed8105bd 100644 --- a/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java +++ b/maven-resolver-util/src/test/java/org/eclipse/aether/util/graph/manager/DependencyManagerTest.java @@ -257,9 +257,15 @@ void testDeriveChildManagerReusesInstanceWithDefaultManager() { manager.deriveChildManager(newContext(new Dependency(new DefaultArtifact("mgd:dep:1.0"), "compile"))); assertNotSame(manager, depth1WithMgmt, "depth 0→1 with managed deps: must return new instance"); - // depth=1 (with mgmt) → depth=2: no managed deps → should reuse + // depth=1 (with mgmt) → depth=2: no managed deps but parent has management data, + // so a new instance must be created to move the management data onto the parent chain + // where getManagedVersion() can find it ("do not apply onto itself" semantics). DependencyManager depth2 = depth1WithMgmt.deriveChildManager(newContext()); - assertSame(depth1WithMgmt, depth2, "depth 1→2 with no managed deps: should reuse"); + assertNotSame(depth1WithMgmt, depth2, "depth 1→2 with parent mgmt: must create new child"); + + // depth=2 (no own mgmt) → depth=3: no managed deps → should reuse (no mgmt data) + DependencyManager depth3 = depth2.deriveChildManager(newContext()); + assertSame(depth2, depth3, "depth 2→3 with no managed deps: should reuse"); } /** From 949d3ec4c4083d5ebd240909040b5258727fc58b Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 2 Aug 2026 15:25:06 +0200 Subject: [PATCH 7/8] Replace O(depth) parent chain walks with O(1) cumulative ancestor maps Pre-build cumulative HashMaps at construction time that flatten the full parent chain into a single map per property (versions, scopes, optionals, localPaths, exclusions). When the parent has no per-level data, the child shares the parent's reference (zero copy). This eliminates all O(depth) parent chain walks from containsManaged* and getManaged* lookups, making every lookup O(1) regardless of dependency tree depth. Co-Authored-By: Claude Opus 4.6 --- .../manager/AbstractDependencyManager.java | 178 ++++++++++++------ 1 file changed, 117 insertions(+), 61 deletions(-) diff --git a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java index d7f6a4cd51..d38786fe79 100644 --- a/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java +++ b/maven-resolver-util/src/main/java/org/eclipse/aether/util/graph/manager/AbstractDependencyManager.java @@ -37,10 +37,12 @@ import static java.util.Objects.requireNonNull; // Note on lookup semantics: management rules follow "nearest to root wins" precedence. -// The parent pointer forms a linked list from leaf toward root. Lookups walk the full -// chain and keep the last hit (closest to root). Since containsManagedXxx() during -// derive blocks duplicate entries for most properties, there is typically at most one -// hit per key in the chain, making the walk direction irrelevant in practice. +// Each instance maintains a cumulative ancestor HashMap that flattens the full parent +// chain into a single map, providing O(1) lookups instead of O(depth) chain walks. +// The cumulative map is built incrementally at construction time: child.ancestors = +// parent.ancestors + parent.ownData. When the parent has no per-level data, the +// reference is shared (zero copy). Since containsManagedXxx() during derive blocks +// duplicate entries for most properties, there is at most one value per key. /** * A dependency manager support class for Maven-specific dependency graph management. @@ -146,6 +148,27 @@ public abstract class AbstractDependencyManager implements DependencyManager { /** System dependency scope handler, may be null if no system scope is defined. */ protected final SystemDependencyScope systemDependencyScope; + // ── Cumulative ancestor maps ────────────────────────────────────────────── + // Flattened view of ALL management entries from root through parent. + // Provides O(1) lookups instead of O(depth) parent-chain walks. + // When the parent has no per-level data, the child shares the same reference + // (zero-copy). These are derived data, excluded from equals/hashCode. + + /** Union of all ancestor version entries (root through parent). */ + private final HashMap ancestorVersions; + + /** Union of all ancestor scope entries (root through parent). */ + private final HashMap ancestorScopes; + + /** Union of all ancestor optional entries (root through parent). */ + private final HashMap ancestorOptionals; + + /** Union of all ancestor local-path entries (root through parent). */ + private final HashMap ancestorLocalPaths; + + /** Union of all ancestor exclusion entries (root through parent), merged additively. */ + private final HashMap> ancestorExclusions; + /** * Pre-computed hash code (excludes managedLocalPaths). * Cascading: incorporates the parent's hashCode so a single int comparison @@ -218,6 +241,22 @@ protected AbstractDependencyManager( // nullable: if using scope manager, but there is no system scope defined this.systemDependencyScope = systemDependencyScope; + // Build cumulative ancestor maps: parent's ancestors + parent's own per-level data. + // When parent has no per-level data, the child shares the parent's reference (zero copy). + if (parent != null) { + this.ancestorVersions = mergeAncestors(parent.ancestorVersions, parent.managedVersions); + this.ancestorScopes = mergeAncestors(parent.ancestorScopes, parent.managedScopes); + this.ancestorOptionals = mergeAncestors(parent.ancestorOptionals, parent.managedOptionals); + this.ancestorLocalPaths = mergeAncestors(parent.ancestorLocalPaths, parent.managedLocalPaths); + this.ancestorExclusions = mergeAncestorExclusions(parent.ancestorExclusions, parent.managedExclusions); + } else { + this.ancestorVersions = null; + this.ancestorScopes = null; + this.ancestorOptionals = null; + this.ancestorLocalPaths = null; + this.ancestorExclusions = null; + } + // Cascading hash: incorporates the parent's pre-computed hash so a single int // comparison reflects the entire ancestor chain. Excludes managedLocalPaths. this.hashCode = Objects.hash( @@ -229,6 +268,45 @@ protected AbstractDependencyManager( managedExclusions); } + /** + * Merges the parent's cumulative ancestor map with the parent's own per-level MMap, + * producing the child's cumulative ancestor map. When the parent has no per-level data, + * the parent's cumulative map is returned as-is (zero copy). + */ + private static HashMap mergeAncestors(HashMap parentAncestors, MMap parentOwn) { + if (parentAncestors == null && parentOwn == null) { + return null; + } + if (parentOwn == null) { + return parentAncestors; // share reference — no new data at this level + } + HashMap result = parentAncestors != null ? new HashMap<>(parentAncestors) : new HashMap<>(); + result.putAll(parentOwn.delegate); + return result; + } + + /** + * Merges the parent's cumulative ancestor exclusions with the parent's own per-level exclusions. + * Unlike other properties, exclusions are accumulated additively (same key → merge collections). + */ + private static HashMap> mergeAncestorExclusions( + HashMap> parentAncestors, MMap>> parentOwn) { + if (parentAncestors == null && parentOwn == null) { + return null; + } + if (parentOwn == null) { + return parentAncestors; // share reference + } + HashMap> result = + parentAncestors != null ? new HashMap<>(parentAncestors) : new HashMap<>(); + parentOwn.delegate.forEach((key, holder) -> result.merge(key, holder.getValue(), (existing, newExcl) -> { + ArrayList merged = new ArrayList<>(existing); + merged.addAll(newExcl); + return merged; + })); + return result; + } + protected abstract DependencyManager newInstance( MMap managedVersions, MMap managedScopes, @@ -242,28 +320,22 @@ private boolean containsManagedVersion(Key key, MMap managedVersion if (this.managedVersions != null && this.managedVersions.containsKey(key)) { return true; } - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { - return true; - } + // O(1) lookup in cumulative ancestor map (replaces O(depth) parent chain walk) + if (ancestorVersions != null && ancestorVersions.containsKey(key)) { + return true; } // Check in-progress new map for duplicates within the same derivation step. return managedVersions != null && managedVersions.containsKey(key); } /** - * Walks the parent chain from leaf toward root, returning the value closest to root - * ("nearest to root wins"). At depth 1, also checks own data (root self-application): - * when DefaultDependencyManager applies management from depth 0, the root-level rules - * are stored in DM1.managedVersions and must be visible to DM1.manageDependency(). + * O(1) lookup in the cumulative ancestor map for the managed version. + * At depth 1, also checks own data (root self-application): when DefaultDependencyManager + * applies management from depth 0, the root-level rules are stored in DM1.managedVersions + * and must be visible to DM1.manageDependency(). */ private String getManagedVersion(Key key) { - String result = null; - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { - result = ancestor.managedVersions.get(key); - } - } + String result = ancestorVersions != null ? ancestorVersions.get(key) : null; if (depth == 1 && managedVersions != null && managedVersions.containsKey(key)) { result = managedVersions.get(key); } @@ -274,21 +346,14 @@ private boolean containsManagedScope(Key key, MMap managedScopes) { if (this.managedScopes != null && this.managedScopes.containsKey(key)) { return true; } - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { - return true; - } + if (ancestorScopes != null && ancestorScopes.containsKey(key)) { + return true; } return managedScopes != null && managedScopes.containsKey(key); } private String getManagedScope(Key key) { - String result = null; - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { - result = ancestor.managedScopes.get(key); - } - } + String result = ancestorScopes != null ? ancestorScopes.get(key) : null; if (depth == 1 && managedScopes != null && managedScopes.containsKey(key)) { result = managedScopes.get(key); } @@ -299,21 +364,14 @@ private boolean containsManagedOptional(Key key, MMap managedOptio if (this.managedOptionals != null && this.managedOptionals.containsKey(key)) { return true; } - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { - return true; - } + if (ancestorOptionals != null && ancestorOptionals.containsKey(key)) { + return true; } return managedOptionals != null && managedOptionals.containsKey(key); } private Boolean getManagedOptional(Key key) { - Boolean result = null; - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { - result = ancestor.managedOptionals.get(key); - } - } + Boolean result = ancestorOptionals != null ? ancestorOptionals.get(key) : null; if (depth == 1 && managedOptionals != null && managedOptionals.containsKey(key)) { result = managedOptionals.get(key); } @@ -324,26 +382,19 @@ private boolean containsManagedLocalPath(Key key, MMap managedLocal if (this.managedLocalPaths != null && this.managedLocalPaths.containsKey(key)) { return true; } - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { - return true; - } + if (ancestorLocalPaths != null && ancestorLocalPaths.containsKey(key)) { + return true; } return managedLocalPaths != null && managedLocalPaths.containsKey(key); } /** * Gets the managed local path for system dependencies. - * Note: Local paths don't follow the depth=1 special rule like versions/scopes. - * Walks the parent chain from leaf toward root, returning the value closest to root. + * Note: Local paths don't follow the depth=1 special rule like versions/scopes — + * own data is always checked (system path alignment across the graph). */ private String getManagedLocalPath(Key key) { - String result = null; - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { - result = ancestor.managedLocalPaths.get(key); - } - } + String result = ancestorLocalPaths != null ? ancestorLocalPaths.get(key) : null; if (managedLocalPaths != null && managedLocalPaths.containsKey(key)) { result = managedLocalPaths.get(key); } @@ -351,25 +402,30 @@ private String getManagedLocalPath(Key key) { } /** - * Merges exclusions from all levels in the dependency path. + * Returns merged exclusions from the cumulative ancestor map plus own exclusions. * Unlike other managed properties, exclusions are accumulated additively - * from root to current level throughout the entire dependency path. - * Walk direction is irrelevant since all levels contribute. + * from all levels in the dependency path. * * @param key the dependency key * @return merged collection of exclusions, or null if none exist */ private Collection getManagedExclusions(Key key) { - ArrayList result = new ArrayList<>(); - for (AbstractDependencyManager ancestor = parent; ancestor != null; ancestor = ancestor.parent) { - if (ancestor.managedExclusions != null && ancestor.managedExclusions.containsKey(key)) { - result.addAll(ancestor.managedExclusions.get(key).value); - } + Collection ancestorExcl = ancestorExclusions != null ? ancestorExclusions.get(key) : null; + Holder> ownExcl = managedExclusions != null ? managedExclusions.get(key) : null; + + if (ancestorExcl == null && ownExcl == null) { + return null; } - if (managedExclusions != null && managedExclusions.containsKey(key)) { - result.addAll(managedExclusions.get(key).value); + if (ancestorExcl != null && ownExcl == null) { + return ancestorExcl; } - return result.isEmpty() ? null : result; + if (ancestorExcl == null) { + return ownExcl.value; + } + // Both present: merge additively + ArrayList result = new ArrayList<>(ancestorExcl); + result.addAll(ownExcl.value); + return result; } @Override From 936dcf5b3ae883c7437e0864858c370d26bad504 Mon Sep 17 00:00:00 2001 From: Guillaume Nodet Date: Sun, 2 Aug 2026 17:00:01 +0200 Subject: [PATCH 8/8] Retrigger CI: Jenkins flaky failure unrelated to changes Co-Authored-By: Claude Opus 4.6