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-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-named-locks/src/main/java/org/eclipse/aether/named/providers/FileLockNamedLockFactory.java b/maven-resolver-named-locks/src/main/java/org/eclipse/aether/named/providers/FileLockNamedLockFactory.java index fcaf4583be..04e416123d 100644 --- a/maven-resolver-named-locks/src/main/java/org/eclipse/aether/named/providers/FileLockNamedLockFactory.java +++ b/maven-resolver-named-locks/src/main/java/org/eclipse/aether/named/providers/FileLockNamedLockFactory.java @@ -29,6 +29,9 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardOpenOption; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -99,23 +102,56 @@ public class FileLockNamedLockFactory extends NamedLockFactorySupport { private static final long SLEEP_MILLIS = Long.parseLong(System.getProperty(SYSTEM_PROP_SLEEP_MILLIS, "50")); + /** + * Maximum number of idle (not currently locked) FileChannels to keep open for reuse. Keeping channels open + * avoids repeated {@code open}/{@code creat} syscalls, but each open channel consumes a file descriptor. + * On systems with low FD limits (e.g., macOS defaults to 256), large reactor builds with thousands of + * unique artifacts can exhaust the limit. This cap bounds idle channel retention; channels for actively + * held locks are never evicted. + * + * @configurationSource {@link System#getProperty(String, String)} + * @configurationType {@link java.lang.Integer} + * @configurationDefaultValue 200 + */ + public static final String SYSTEM_PROP_MAX_CACHED_CHANNELS = "aether.named.file-lock.maxCachedChannels"; + + private static final int MAX_CACHED_CHANNELS = + Integer.parseInt(System.getProperty(SYSTEM_PROP_MAX_CACHED_CHANNELS, "200")); + private final ConcurrentMap fileChannels; + /** + * LRU pool of idle (unlocked) channels available for reuse. Access-ordered: the least recently used + * entry is evicted first when the pool exceeds {@link #MAX_CACHED_CHANNELS}. Guarded by its own + * monitor; never held while performing I/O. + */ + private final LinkedHashMap idleChannels; + public FileLockNamedLockFactory() { this.fileChannels = new ConcurrentHashMap<>(); + this.idleChannels = new LinkedHashMap<>(64, 0.75f, true); // access-order } @Override protected NamedLockSupport createLock(final NamedLockKey key) { Path path = Paths.get(URI.create(key.name())); - FileChannel fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path)); - if (!fileChannel.isOpen()) { - // Channel was closed externally (I/O error, NFS hiccup, etc.). Evict the stale entry - // and open a fresh one. remove(key, fileChannel) is atomic: it only removes if the - // value is still this exact (stale) instance, avoiding races with other threads that - // may have already replaced it. - fileChannels.remove(key, fileChannel); + // Try to reclaim an idle channel first (avoids open syscall) + FileChannel fileChannel; + synchronized (idleChannels) { + fileChannel = idleChannels.remove(key); + } + if (fileChannel != null && fileChannel.isOpen()) { + fileChannels.put(key, fileChannel); + } else { fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path)); + if (!fileChannel.isOpen()) { + // Channel was closed externally (I/O error, NFS hiccup, etc.). Evict the stale entry + // and open a fresh one. remove(key, fileChannel) is atomic: it only removes if the + // value is still this exact (stale) instance, avoiding races with other threads that + // may have already replaced it. + fileChannels.remove(key, fileChannel); + fileChannel = fileChannels.computeIfAbsent(key, k -> openFileChannel(key, path)); + } } return new FileLockNamedLock(key, fileChannel, this); } @@ -157,10 +193,30 @@ private FileChannel openFileChannel(NamedLockKey key, Path path) { @Override protected void destroyLock(final NamedLock namedLock) { - // Keep the FileChannel open in the fileChannels map for reuse by future createLock() calls. - // Opening a FileChannel is a syscall (open/creat) that shows up as a hotspot when locks are - // acquired and released frequently (e.g., per-artifact resolution in primed builds). - // Channels are closed on factory shutdown via doShutdown(). + NamedLockKey key = namedLock.key(); + FileChannel channel = fileChannels.remove(key); + if (channel == null) { + return; + } + // Move the channel to the idle pool for reuse by future createLock() calls. + // Evict the least recently used idle channel if the pool is full. + FileChannel evicted = null; + synchronized (idleChannels) { + idleChannels.put(key, channel); + if (idleChannels.size() > MAX_CACHED_CHANNELS) { + Iterator> it = + idleChannels.entrySet().iterator(); + evicted = it.next().getValue(); + it.remove(); + } + } + if (evicted != null) { + try { + evicted.close(); + } catch (IOException e) { + logger.warn("Failed to close evicted file channel", e); + } + } } @Override @@ -173,5 +229,15 @@ protected void doShutdown() { } } fileChannels.clear(); + synchronized (idleChannels) { + for (FileChannel channel : idleChannels.values()) { + try { + channel.close(); + } catch (IOException e) { + logger.warn("Failed to close idle file channel", e); + } + } + idleChannels.clear(); + } } } 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..d965b54882 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,8 @@ import java.util.Collection; import java.util.HashMap; import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; import java.util.Objects; import org.eclipse.aether.artifact.Artifact; @@ -35,6 +37,14 @@ import static java.util.Objects.requireNonNull; +// Note on lookup semantics: management rules follow "nearest to root wins" precedence. +// Each instance maintains a cumulative ancestor LayeredMap — a zero-copy cons-list of +// map fragments — providing O(layers) lookups instead of O(depth) chain walks. +// The layered map is built incrementally at construction time: child.ancestors = +// new layer(parent.ancestors, parent.ownData). When the parent has no per-level data, +// the reference is shared. Since containsManagedXxx() during derive blocks duplicate +// entries for most properties, there is at most one value per key across all layers. + /** * A dependency manager support class for Maven-specific dependency graph management. * @@ -105,8 +115,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,9 +149,57 @@ 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). */ + // ── Cumulative ancestor maps ────────────────────────────────────────────── + // Zero-copy layered view of ALL management entries from root through parent. + // Each layer holds a reference to the parent layer (older data) and its own entries. + // Lookups traverse the chain from newest to oldest — first match wins (O(layers)). + // Adding a new level is O(1): just link on top. No HashMap copying. + // When the parent has no per-level data, the child shares the same reference. + // These are derived data, excluded from equals/hashCode. + + /** Union of all ancestor version entries (root through parent). */ + private final LayeredMap ancestorVersions; + + /** Union of all ancestor scope entries (root through parent). */ + private final LayeredMap ancestorScopes; + + /** Union of all ancestor optional entries (root through parent). */ + private final LayeredMap ancestorOptionals; + + /** Union of all ancestor local-path entries (root through parent). */ + private final LayeredMap ancestorLocalPaths; + + /** Union of all ancestor exclusion entries (root through parent), layered additively. */ + private final LayeredMap> ancestorExclusions; + + /** + * 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; + /** + * Multi-entry memoization cache for {@link #deriveChildManager(DependencyCollectionContext)}: + * remembers recent managed-dependency lists (by reference identity) and their results. + *

+ * In BFS dependency collection, siblings typically share the same interned managed-dependency + * list (guaranteed by DataPool's {@code internArtifactDescriptorManagedDependencies}), but + * a reactor with several distinct BOM patterns may alternate between many lists. A 16-entry + * ring buffer captures these patterns while keeping 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 static final int MEMO_CACHE_SIZE = 16; + + @SuppressWarnings("unchecked") + private transient List[] memoKeys = new List[MEMO_CACHE_SIZE]; + + private transient DependencyManager[] memoValues = new DependencyManager[MEMO_CACHE_SIZE]; + private transient int memoIndex; + /** * Creates a new dependency manager with the specified derivation and application parameters. * @@ -148,7 +210,7 @@ public abstract class AbstractDependencyManager implements DependencyManager { */ protected AbstractDependencyManager(int deriveUntil, int applyFrom, ScopeManager scopeManager) { this( - new ArrayList<>(), + null, 0, deriveUntil, applyFrom, @@ -164,7 +226,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 +236,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 +248,66 @@ 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); + // 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. + int h = parent != null ? parent.hashCode : 0; + h = 31 * h + depth; + h = 31 * h + Objects.hashCode(managedVersions); + h = 31 * h + Objects.hashCode(managedScopes); + h = 31 * h + Objects.hashCode(managedOptionals); + h = 31 * h + Objects.hashCode(managedExclusions); + this.hashCode = h; + } + + /** + * Links the parent's own per-level MMap on top of the parent's cumulative ancestor layers, + * producing the child's cumulative ancestor map. O(1) — no HashMap copying. + * When the parent has no per-level data, the parent's layered map is returned as-is. + */ + private static LayeredMap mergeAncestors(LayeredMap parentAncestors, MMap parentOwn) { + if (parentAncestors == null && parentOwn == null) { + return null; + } + if (parentOwn == null) { + return parentAncestors; // share reference — no new data at this level + } + return new LayeredMap<>(parentAncestors, parentOwn.delegate); + } + + /** + * Links the parent's own per-level exclusions on top of the parent's cumulative ancestor layers. + * O(1) — no HashMap copying. Unlike other properties, exclusions use additive semantics: + * {@link #getManagedExclusions(Key)} walks all layers to collect the union. + */ + private static LayeredMap> mergeAncestorExclusions( + LayeredMap> parentAncestors, + MMap>> parentOwn) { + if (parentAncestors == null && parentOwn == null) { + return null; + } + if (parentOwn == null) { + return parentAncestors; // share reference + } + // Unwrap Holder values into a plain map for this layer + HashMap> ownEntries = new HashMap<>(); + parentOwn.delegate.forEach((key, holder) -> ownEntries.put(key, holder.getValue())); + return new LayeredMap<>(parentAncestors, ownEntries); } protected abstract DependencyManager newInstance( @@ -198,115 +318,142 @@ protected abstract DependencyManager newInstance( MMap>> managedExclusions); private boolean containsManagedVersion(Key key, MMap managedVersions) { - for (AbstractDependencyManager ancestor : path) { - if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { - return true; - } + // 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; } + // 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); } + /** + * 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) { - for (AbstractDependencyManager ancestor : path) { - if (ancestor.managedVersions != null && ancestor.managedVersions.containsKey(key)) { - return ancestor.managedVersions.get(key); - } - } + String result = ancestorVersions != null ? ancestorVersions.get(key) : null; if (depth == 1 && managedVersions != null && managedVersions.containsKey(key)) { - return managedVersions.get(key); + result = managedVersions.get(key); } - return null; + return result; } private boolean containsManagedScope(Key key, MMap managedScopes) { - for (AbstractDependencyManager ancestor : path) { - if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { - return true; - } + if (this.managedScopes != null && this.managedScopes.containsKey(key)) { + return true; + } + if (ancestorScopes != null && ancestorScopes.containsKey(key)) { + return true; } return managedScopes != null && managedScopes.containsKey(key); } private String getManagedScope(Key key) { - for (AbstractDependencyManager ancestor : path) { - if (ancestor.managedScopes != null && ancestor.managedScopes.containsKey(key)) { - return ancestor.managedScopes.get(key); - } - } + String result = ancestorScopes != null ? ancestorScopes.get(key) : null; 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) { - if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { - return true; - } + if (this.managedOptionals != null && this.managedOptionals.containsKey(key)) { + return true; + } + if (ancestorOptionals != null && ancestorOptionals.containsKey(key)) { + return true; } return managedOptionals != null && managedOptionals.containsKey(key); } private Boolean getManagedOptional(Key key) { - for (AbstractDependencyManager ancestor : path) { - if (ancestor.managedOptionals != null && ancestor.managedOptionals.containsKey(key)) { - return ancestor.managedOptionals.get(key); - } - } + Boolean result = ancestorOptionals != null ? ancestorOptionals.get(key) : null; 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) { - if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { - return true; - } + if (this.managedLocalPaths != null && this.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. - * - * @param key the dependency key - * @return the managed local path, or null if not managed + * 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) { - for (AbstractDependencyManager ancestor : path) { - if (ancestor.managedLocalPaths != null && ancestor.managedLocalPaths.containsKey(key)) { - return ancestor.managedLocalPaths.get(key); - } - } + String result = ancestorLocalPaths != null ? ancestorLocalPaths.get(key) : null; 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. + * Returns merged exclusions from all ancestor layers plus own exclusions. * Unlike other managed properties, exclusions are accumulated additively - * from root to current level throughout the entire dependency path. + * from all levels in the dependency path — each layer is walked to collect + * the full union. * * @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) { - if (ancestor.managedExclusions != null && ancestor.managedExclusions.containsKey(key)) { - result.addAll(ancestor.managedExclusions.get(key).value); - } + // Collect exclusions from all ancestor layers (parent/older layers first) + Collection ancestorExcl = collectExclusionsFromLayers(ancestorExclusions, key); + Holder> ownExcl = managedExclusions != null ? managedExclusions.get(key) : null; + + if (ancestorExcl == null && ownExcl == null) { + return null; + } + if (ancestorExcl != null && ownExcl == null) { + return ancestorExcl; + } + if (ancestorExcl == null) { + return ownExcl.value; + } + // Both present: merge additively + ArrayList result = new ArrayList<>(ancestorExcl); + result.addAll(ownExcl.value); + return result; + } + + /** + * Walks all layers of the layered exclusions map, collecting exclusions for the given key. + * Parent (older) layers are collected first via recursion to maintain order. + * Recursion depth is bounded by the number of layers (typically 2–5), not tree depth. + */ + private static Collection collectExclusionsFromLayers( + LayeredMap> layers, Key key) { + if (layers == null) { + return null; } - if (managedExclusions != null && managedExclusions.containsKey(key)) { - result.addAll(managedExclusions.get(key).value); + // Recurse to parent first (older data) + Collection result = collectExclusionsFromLayers(layers.parent, key); + Collection layerExcl = layers.ownEntries.get(key); + if (layerExcl != null) { + if (result == null) { + result = new ArrayList<>(layerExcl); + } else { + result.addAll(layerExcl); + } } - return result.isEmpty() ? null : result; + return result; } @Override @@ -316,13 +463,23 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) return this; } + // Memoization: check if we've already derived for this managed dependencies list + // (same object reference — guaranteed by DataPool's descriptor/list interning). + // A 4-entry ring buffer captures the common BOM patterns in a large reactor. + List managedDeps = context.getManagedDependencies(); + for (int i = 0; i < MEMO_CACHE_SIZE; i++) { + if (managedDeps == memoKeys[i] && memoValues[i] != null) { + return memoValues[i]; + } + } + 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); @@ -379,12 +536,48 @@ public DependencyManager deriveChildManager(DependencyCollectionContext context) } } - 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); + // 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 . + // + // 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 + && managedScopes == null + && managedOptionals == null + && managedLocalPaths == null + && managedExclusions == null + && isApplied() + && this.managedVersions == null + && this.managedScopes == null + && this.managedOptionals == null + && this.managedLocalPaths == null + && this.managedExclusions == null) { + 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); + } + + // Cache the result in the ring buffer for future calls with the same managed deps list + memoKeys[memoIndex] = managedDeps; + memoValues[memoIndex] = result; + memoIndex = (memoIndex + 1) % MEMO_CACHE_SIZE; + return result; } @Override @@ -518,13 +711,21 @@ public boolean equals(Object obj) { } AbstractDependencyManager that = (AbstractDependencyManager) obj; + // 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 - return Objects.equals(path, that.path) - && depth == that.depth + // 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(managedExclusions, that.managedExclusions) + && Objects.equals(parent, that.parent); } @Override @@ -537,18 +738,30 @@ 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(); + int h = artifactId.hashCode(); + h = 31 * h + groupId.hashCode(); + h = 31 * h + extension.hashCode(); + h = 31 * h + classifier.hashCode(); + this.hashCode = h; } @Override @@ -559,10 +772,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 @@ -572,7 +785,7 @@ public int hashCode() { @Override public String toString() { - return String.valueOf(artifact); + return groupId + ":" + artifactId + ":" + extension + (classifier.isEmpty() ? "" : ":" + classifier); } } @@ -587,7 +800,7 @@ protected static class Holder { Holder(T value) { this.value = requireNonNull(value); - this.hashCode = Objects.hash(value); + this.hashCode = value.hashCode(); } public T getValue() { @@ -608,4 +821,43 @@ public int hashCode() { return hashCode; } } + + /** + * A zero-copy layered map built as a cons-list of map fragments. + * Each layer holds a reference to its parent (older data) and its own entries. + *

+ * For "first match wins" properties (versions, scopes, optionals, local paths), + * {@link #get(Object)} returns the first value found traversing from newest to oldest layer. + * For additive properties (exclusions), callers walk all layers via the {@link #parent} + * pointer to collect the union. + *

+ * Adding a new level is O(1) — just link on top. Lookups are O(layers) where layers is + * the number of depths that contributed management data (typically 2–5 in practice). + * + * @param key type + * @param value type + */ + static class LayeredMap { + final LayeredMap parent; + final Map ownEntries; + + LayeredMap(LayeredMap parent, Map ownEntries) { + this.parent = parent; + this.ownEntries = ownEntries; + } + + /** Lookup: newest layer first, O(layers). */ + V get(K key) { + V value = ownEntries.get(key); + if (value != null) { + return value; + } + return parent != null ? parent.get(key) : null; + } + + /** Contains check: any layer, O(layers). */ + boolean containsKey(K key) { + return ownEntries.containsKey(key) || (parent != null && parent.containsKey(key)); + } + } } 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/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); } } 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, 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 f6d341d46e..cbbcd42287 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,9 +22,11 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.eclipse.aether.ConfigurationProperties; import org.eclipse.aether.RepositoryException; @@ -406,6 +408,11 @@ private static class Path { private final Path parent; // derived private final int depth; + // Set of conflict IDs on the path from root to this node (inclusive), enabling O(1) cycle detection. + // Each node copies its parent's set and adds its own conflictId. Since dependency tree depth is + // bounded in practice (< 30), the per-node copy cost is negligible compared to the O(depth) + // parent-chain walk it replaces. + private final Set conflictIdsOnPath; // 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. @@ -422,6 +429,12 @@ private Path(State state, DependencyNode dn, String conflictId, Path parent) { this.conflictId = conflictId; this.parent = parent; this.depth = parent != null ? parent.depth + 1 : 0; + if (parent != null) { + this.conflictIdsOnPath = new HashSet<>(parent.conflictIdsOnPath); + } else { + this.conflictIdsOnPath = new HashSet<>(); + } + this.conflictIdsOnPath.add(this.conflictId); pull(0); this.state @@ -432,18 +445,12 @@ private Path(State state, DependencyNode dn, String conflictId, Path parent) { /** * Checks whether the given conflictId appears on the path from this node to the root. - * This replaces the previous approach of copying a HashSet at every child, which was the - * dominant source of memory consumption (millions of HashMap.Node objects for large graphs). + * Uses a pre-built {@link HashSet} of conflict IDs accumulated along the path from root, + * making this an O(1) operation instead of the previous O(depth) parent-chain walk that + * showed up as a JFR hotspot (3.9% CPU) in large multi-module builds. */ private boolean hasConflictIdOnPathToRoot(String targetConflictId) { - Path current = this; - while (current != null) { - if (Objects.equals(current.conflictId, targetConflictId)) { - return true; - } - current = current.parent; - } - return false; + return conflictIdsOnPath.contains(targetConflictId); } /** @@ -684,9 +691,10 @@ private void moveOutOfScope() { * Method will return really added {@link Path} instances, as this class avoids cycles. Those forming a cycle * are not recursed (not returned in list), keeping {@link Path} cycle free. *

- * 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. + * Cycle detection is performed via {@link #hasConflictIdOnPathToRoot(String)} which uses + * a pre-built {@link HashSet} of conflict IDs accumulated along the path from root, making + * each check O(1). Since dependency tree depth is bounded in practice (< 30), the per-node + * set copy cost is negligible. * This implies that this conflict resolver, by its nature "redoes" the * {@link TransformationContextKeys#CYCLIC_CONFLICT_IDS} calculated by {@link ConflictIdSorter}. */ 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..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 @@ -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; @@ -204,6 +206,107 @@ 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"); + } + + /** + * 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 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()); + 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"); + } + + /** + * 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); @@ -268,4 +371,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"); + } }