Skip to content

Fix #12605: DefaultProjectBuildingHelper synchronized bottleneck in parallel builds - #12630

Draft
elharo wants to merge 2 commits into
masterfrom
fix-12605-project-realm-sync
Draft

Fix #12605: DefaultProjectBuildingHelper synchronized bottleneck in parallel builds#12630
elharo wants to merge 2 commits into
masterfrom
fix-12605-project-realm-sync

Conversation

@elharo

@elharo elharo commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #12605

Problem

DefaultProjectBuildingHelper.createProjectRealm() was declared synchronized, making it a global lock during multi-module parallel builds. Every project needing extension realm setup had to acquire this lock serially.

Additionally, DefaultProjectRealmCache.put() had a check-then-act race condition: containsKey() followed by put() are not atomic, allowing two threads to both pass the check and overwrite each other's entry.

Fix

  1. Removed synchronized from createProjectRealm() — the projectRealmCache uses ConcurrentHashMap and is already thread-safe for get/createKey.
  2. Fixed put() race — replaced the non-atomic containsKey() + put() with putIfAbsent(), which atomically inserts only if absent and throws IllegalStateException if the key already exists.

Testing

Added DefaultProjectRealmCacheTest that verifies concurrent put() calls with the same key result in exactly one successful insertion.

…arallel builds

Remove the synchronized keyword from createProjectRealm() and fix
the check-then-act race in DefaultProjectRealmCache.put() by using
ConcurrentHashMap.putIfAbsent() instead of containsKey() + put().

The projectRealmCache already uses ConcurrentHashMap and is thread-safe
for get/createKey operations. The only non-thread-safe portion was the
put() method's check-then-act pattern, which is now atomic.

This allows multiple threads to set up extension realms for different
projects concurrently rather than serializing through a global lock.

Fixes gh-12605
@elharo
elharo requested a review from Copilot July 30, 2026 13:11

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR correctly identifies that DefaultProjectRealmCache.put() had a non-atomic check-then-act pattern and fixes it with putIfAbsent(). However, removing synchronized from createProjectRealm() exposes a TOCTOU race at the caller level that can crash parallel builds.

Issue: Race condition in createProjectRealm() (high severity)

The get() → create realm → put() sequence (lines 223-258) is not atomic. In a parallel multi-module build where two modules share the same extensions:

  1. Both threads call projectRealmCache.get(key) → both get null
  2. Both enter the if (record == null) block
  3. Both call classRealmManager.createProjectRealm() creating duplicate ClassRealms
  4. One thread's putIfAbsent() succeeds; the other throws IllegalStateException
  5. The ISE is not caught by the caller at DefaultProjectBuilder.java:1274 (which only catches PluginResolutionException | PluginManagerException | PluginVersionResolutionException) → build crash

The old synchronized prevented this race entirely.

Secondary issue: ClassRealm resource leak (medium severity)

The ClassRealm created by the losing thread is never registered in the cache, so DefaultProjectRealmCache.flush() never disposes it.

Suggested approaches

  • Replace the get-create-put pattern with a computeIfAbsent-style approach that atomically populates the cache
  • Or use a finer-grained lock (e.g., striped by key) to serialize same-key access while allowing different keys to proceed concurrently
  • Or change put() to return the existing record instead of throwing (matching ConcurrentHashMap.putIfAbsent semantics) and have the caller dispose the redundant realm

Test coverage

The new test validates the cache-level atomicity (good), but does not cover the higher-level race in createProjectRealm() that this PR introduces.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of gnodet

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Removes a global synchronization bottleneck during parallel builds and hardens project realm caching against concurrent duplicate insertions.

Changes:

  • Removed synchronized from DefaultProjectBuildingHelper.createProjectRealm() to avoid serializing parallel module builds.
  • Made DefaultProjectRealmCache.put() atomic by switching to putIfAbsent() and throwing on duplicates.
  • Added a concurrency-focused unit test to validate only one concurrent put() succeeds for the same key.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectBuildingHelper.java Removes synchronized to eliminate a global lock in parallel builds.
impl/maven-core/src/main/java/org/apache/maven/project/DefaultProjectRealmCache.java Fixes a check-then-act race by using putIfAbsent() and duplicate detection.
impl/maven-core/src/test/java/org/apache/maven/project/DefaultProjectRealmCacheTest.java Adds a concurrent test to assert duplicate put() behavior under contention.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


for (int i = 0; i < threadCount; i++) {
futures.add(executor.submit(() -> {
barrier.await();
Comment on lines +61 to +62

int successCount = 0;
Comment on lines +44 to +66

int threadCount = 10;
CyclicBarrier barrier = new CyclicBarrier(threadCount);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
List<Future<Boolean>> futures = new ArrayList<>();

for (int i = 0; i < threadCount; i++) {
futures.add(executor.submit(() -> {
barrier.await();
try {
cache.put(key, mock(ClassRealm.class), mock(DependencyFilter.class));
return true;
} catch (IllegalStateException e) {
return false;
}
}));
}

int successCount = 0;
for (Future<Boolean> f : futures) {
if (f.get()) {
successCount++;
}
Comment on lines 97 to +103
CacheRecord record = new CacheRecord(projectRealm, extensionArtifactFilter);

cache.put(key, record);
CacheRecord existing = cache.putIfAbsent(key, record);

if (existing != null) {
throw new IllegalStateException("Duplicate project realm for extensions " + key);
}

@gnodet gnodet left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after new commit — the TOCTOU race from our previous review remains unaddressed. The new commit (df63754) only fixes checkstyle/style issues in the test file and introduces two new issues.

1. TOCTOU race condition still present (high severity)

The critical race in createProjectRealm() is unchanged: removing synchronized without replacing it with an equivalent concurrency mechanism means IllegalStateException will be thrown under contention in parallel builds. See our previous review for the full analysis and suggested approaches.

2. Compilation error introduced (high severity)

The checkstyle commit removed the org.eclipse.aether.graph.DependencyFilter import along with an unused local variable, but DependencyFilter.class is still used inline in mock(DependencyFilter.class) at line 52. Since DependencyFilter is from org.eclipse.aether.graph (not the test's own package), this will fail to compile.

3. Typo (low severity)

The renamed loop variable futre at line 62 should be future.

This review was generated by an AI agent and may contain inaccuracies. Please verify all suggestions before applying.

Claude Code on behalf of gnodet

@elharo
elharo marked this pull request as draft July 30, 2026 14:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[maven-4.0.x] DefaultProjectBuildingHelper: synchronized bottleneck in parallel builds

3 participants