Skip to content

Plugin config directory probing hashes other modules' target/ output as inputs, permanently invalidating the cache #525

Description

@olamy

Summary

When the input scanner probes plugin configuration for paths, a <directory> (or any path-valued tag) that points at another module's directory is walked recursively, and that module's target/ is not excluded. The other module's build output — jar, target/classes/*.class, maven-archiver/pom.properties, maven-status/** — therefore becomes part of the consuming module's checksum.

Build output is not stable across builds (jars carry timestamps unless project.build.outputTimestamp is set, and maven-status files change whenever the module is actually compiled rather than restored). The result is a module that can never get a cache hit, with no warning — it just looks like the remote cache is broken.

ExclusionResolver.addDefaultExcludes() builds the target / target/classes / target/test-classes exclusions from the current project only:

Path buildDirectoryPath = absoluteNormalizedPath(build.getDirectory());
Path outputDirectoryPath = absoluteNormalizedPath(build.getOutputDirectory());
Path testOutputDirectoryPath = absoluteNormalizedPath(build.getTestOutputDirectory());

They are absolute paths, so they only ever match the project being hashed. Any other reactor module reached through plugin-config probing has its build output treated as source input.

Reproducer

Note: create it outside java.io.tmpdirgetPathOrNull() silently skips anything under the temp dir, so a repro in /tmp will not trigger the scan.

mkdir -p ~/mbc-repro/{.mvn,mod-a/src/main/java,mod-b/src/main/java} && cd ~/mbc-repro

cat > .mvn/extensions.xml <<'EOF'
<extensions>
  <extension>
    <groupId>org.apache.maven.extensions</groupId>
    <artifactId>maven-build-cache-extension</artifactId>
    <version>1.3.0</version>
  </extension>
</extensions>
EOF

cat > .mvn/maven-build-cache-config.xml <<'EOF'
<cache xmlns="https://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0">
  <configuration>
    <enabled>true</enabled>
    <hashAlgorithm>XX</hashAlgorithm>
  </configuration>
  <input>
    <global>
      <glob>{*.java,*.xml,*.properties}</glob>
    </global>
  </input>
</cache>
EOF

cat > pom.xml <<'EOF'
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>org.example</groupId>
  <artifactId>parent</artifactId>
  <version>1.0-SNAPSHOT</version>
  <packaging>pom</packaging>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <modules>
    <module>mod-a</module>
    <module>mod-b</module>
  </modules>
</project>
EOF

cat > mod-a/pom.xml <<'EOF'
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.0-SNAPSHOT</version>
  </parent>
  <artifactId>mod-a</artifactId>
</project>
EOF
echo 'public class A {}' > mod-a/src/main/java/A.java
echo 'shared note'      > mod-a/notes.txt

# mod-b has NO dependency on mod-a. It only wants one file out of mod-a's directory.
cat > mod-b/pom.xml <<'EOF'
<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>org.example</groupId>
    <artifactId>parent</artifactId>
    <version>1.0-SNAPSHOT</version>
  </parent>
  <artifactId>mod-b</artifactId>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <executions>
          <execution>
            <id>copy-shared-note</id>
            <phase>prepare-package</phase>
            <goals><goal>copy-resources</goal></goals>
            <configuration>
              <outputDirectory>${project.build.directory}</outputDirectory>
              <resources>
                <resource>
                  <directory>${maven.multiModuleProjectDirectory}/mod-a</directory>
                  <includes><include>notes.txt</include></includes>
                </resource>
              </resources>
            </configuration>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>
</project>
EOF
echo 'public class B {}' > mod-b/src/main/java/B.java

mvn -B -ntp -Dmaven.build.cache.location=$PWD/cache clean install   # warm
mvn -B -ntp -Dmaven.build.cache.location=$PWD/cache clean install   # mod-b hits

# a CI agent that has to rebuild mod-a. No source change anywhere.
rm -rf cache/v1.2/org.example/mod-a
mvn -B -ntp -Dmaven.build.cache.location=$PWD/cache clean install

Actual

The last build misses on mod-b, although nothing in mod-b — or in any source file — changed:

[INFO] Local build was not found by checksum 65e6bd150bbbd545 for org.example:mod-b

mod-b's buildinfo.xml shows why:

file  2eaf0644a54b6fa5  ../mod-a/target/classes/A.class
file  d4bdf912ad00f859  ../mod-a/target/maven-archiver/pom.properties
file  b12c0741ec50f3fc  ../mod-a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
file  847b95fc10a3e8b6  ../mod-a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
file  c918f0a5600bfbf0  ../mod-a/target/mod-a-1.0-SNAPSHOT.jar
file  ...               ../mod-a/notes.txt, ../mod-a/pom.xml, ../mod-a/src/main/java/A.java
file  ...               src/main/java/B.java

Editing anything in mod-a also invalidates mod-b the same way, even though mod-b does not depend on mod-a.

The input set additionally differs depending on whether mod-a was restored (only the jar is present in target/) or rebuilt (whole target/ tree present), so the two paths produce different checksums for the same sources.

Expected

Another module's build output directory should never be a checksum input. Either exclude every reactor project's build.directory when walking, or apply the owning project's default exclusions when the walked path belongs to a different MavenProject in the session — MultiModuleSupport already has the project list.

Secondary: the configured <global><glob> is bypassed for plugin-config scans

notes.txt above is collected even though the configured glob is {*.java,*.xml,*.properties}, because DefaultPluginScanConfig — used whenever a plugin has no explicit <dirScan> — hardcodes *:

// DefaultPluginScanConfig
public ScanConfigProperties getTagScanProperties(String tagName) {
    return new ScanConfigProperties(true, "*");
}

whereas PluginScanConfigImpl.defaultScanConfig() returns null, which MavenProjectInput then resolves to the project glob:

final String glob = defaultIfEmpty(propertyConfig.getGlob(), projectGlob);

So adding an otherwise-empty <dirScan/> for a plugin silently narrows the scanned file set from * to the project glob. That asymmetry looks unintentional.

Real-world impact

Found while investigating why Eclipse Jetty's CI never got a single cache hit. Jetty's parent pom has an inherited maven-resources-plugin execution pointing at ${maven.multiModuleProjectDirectory}/build, which contains a helper module. Every one of the ~400 reactor modules therefore hashed build/build-resources/target/build-resources-<version>.jar, which is not reproducible — so the entire reactor invalidated itself on every build, on every agent, forever. Diagnosing it took diffing buildinfo.xml between two runs, because nothing in the log hints that an input lives under another module's target/.

A warning when a collected input falls under any reactor project's build directory would make this class of problem self-diagnosing.

Environment

  • maven-build-cache-extension 1.3.0
  • Maven 3.9.16
  • Java 25.0.3 (Temurin); also observed on 17 and 21

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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