Skip to content

BfDependencyCollector pool cache hit/miss changes graph structure (cache-transparency violation) #2013

Description

@gnodet

Summary

The BfDependencyCollector.doRecurse() method has a cache-transparency issue: whether a dependency node retains its children depends on whether the pool cache hits or misses — a performance optimization is changing the semantic result.

Root cause

doRecurse() uses two independent caching/dedup layers with different keys:

  1. Pool cache — keyed on (artifact, repos, selector, manager, traverser, filter), i.e. it includes the DependencyManager (compared via equals).
  2. GACE skipper — keyed on artifact identity only (G:A:C:E, no version, no manager).

The interaction at BfDependencyCollector.java lines ~400-436:

List<DependencyNode> children = pool.getChildren(key); // key includes DependencyManager
if (children == null) {        // POOL MISS → skipper is consulted
    if (!skipper.skipResolution(child, parents)) {
        // resolve children, cache in pool
    }
    // else: node gets ZERO children (skipper marked it as duplicate)
} else {                       // POOL HIT → skipper is NEVER consulted
    child.setChildren(children);
}

When the pool hits, the skipper is bypassed and the node gets cached children. When the pool misses, the skipper IS consulted and may mark the node as skippedAsDuplicate, giving it zero children. The ConflictResolver (in default Verbosity.NONE) then prunes childless conflict-loser nodes entirely.

This means the graph structure depends on whether the pool cache hits or misses — which is a function of DependencyManager.equals(), not of the dependency being resolved.

How this manifests

Discovered via apache/maven-shade-plugin#819. Consider a project where:

  • Direct dependency a depends on b (no classifier) and b:alt (classifier "alt")
  • Both b and b:alt share a transitive dependency c

Under Maven 3.10 (ClassicDependencyManager): at depth ≥ 2, deriveChildManager() returns the same instance → pool key for c under b equals pool key for c under b:altpool hit → skipper is never consulted → c appears under both variants.

Under Maven 4 (TransitiveDependencyManager): deriveChildManager() always creates a new instance with a unique path field (added in #1539) → pool key differs → pool miss → skipper marks c under b:alt as skippedAsDuplicatec gets zero children → ConflictResolver prunes it → c disappears from b:alt's subtree.

The same Resolver 2.0.20 produces different graphs under Maven 3.10 vs Maven 4, purely due to the DependencyManager choice.

Reproducer

Standalone reproducer (no plugin build needed): https://github.com/aschemaven/mshade-819-reproducer

./run.sh <maven-home>

Results:

Maven Resolver c under b c under b:alt
3.9.16 1.x ✅ present ✅ present
3.10.0-rc-1 2.0.20 ✅ present ✅ present
4.0.0-rc-5 2.0.13 ✅ present ❌ pruned
4.0.0-rc-5 + -Daether.conflictResolver.verbose=true 2.0.13 ✅ present ✅ present

Suggested fix

Make the pool cache transparent — either:

  1. Always consult the skipper regardless of pool hit/miss, but let the pool provide cached children if the skipper says "don't skip".
  2. On pool miss + skipper skip, still try to provide cached children from the winning node's pool entry (since the GACE keys match), so the node retains its children even when the pool key differs.

Either approach would make the graph structure independent of the DependencyManager.equals() behavior.

Workaround

The maven-shade-plugin has a workaround in PR #820: collecting with ConflictResolver.CONFIG_PROP_VERBOSE = STANDARD so pruned nodes are retained as markers. This is a valid fix on the plugin side, but other plugins that walk the dependency graph may be silently affected by the same issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions