You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maven.apache.org by gn...@apache.org on 2026/10/22 11:46:35 UTC
(maven) branch master updated: Serialize the mojo executions of a project in the concurrent builder (#13591)
This is an automated email from the ASF dual-hosted git repository.
gnodet pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/maven.git
The following commit(s) were added to refs/heads/master by this push:
new 0336b8c610 Serialize the mojo executions of a project in the concurrent builder (#13591)
0336b8c610 is described below
commit 0336b8c610a3af0293f04bd865bb4a67780f3eb4
Author: Arturo Bernal <ab...@apache.org>
AuthorDate: Thu Oct 22 13:46:29 2026 +0200
Serialize the mojo executions of a project in the concurrent builder (#13591)
With `-b concurrent` and two or more threads, `MojoExecutor#ensureDependenciesAreResolved`
replaces resolved artifacts and artifact filter on the shared MavenProject before each mojo runs,
including mojos with no dependency requirement (which write a null filter). The concurrent executor
had no project lock, so mojos of the same project could race on that shared state.
The concurrent MojoExecutor now takes a per-project lock around dependency preparation and mojo
execution for legacy (v4 API) mojos. Maven 4 mojos using @Resolution get their dependencies
injected directly and skip both ensureDependenciesAreResolved and the lock, preserving full
intra-project concurrency for v4 plugins. Executions of different projects are never serialized.
Fixes #13590
---
.../maven/lifecycle/internal/MojoExecutor.java | 64 ++++---
.../internal/concurrent/MojoExecutor.java | 65 ++++++-
.../internal/concurrent/MojoExecutorTest.java | 190 +++++++++++++++++++++
3 files changed, 295 insertions(+), 24 deletions(-)
diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java
index bc7e55c1bb..21866d29d2 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/MojoExecutor.java
@@ -78,9 +78,11 @@ public class MojoExecutor {
private static final SessionData.Key<ProjectIndex> PROJECT_INDEX = SessionData.key(ProjectIndex.class);
@SuppressWarnings({"unchecked", "rawtypes"})
- private static final SessionData.Key<Map<MavenProject, OwnerReentrantLock>> PROJECT_LOCKS =
+ protected static final SessionData.Key<Map<MavenProject, OwnerReentrantLock>> PROJECT_LOCKS =
(SessionData.Key) SessionData.key(Map.class, ProjectLock.class);
+ protected static final NoExceptionCloseable NO_LOCK = new NoLock();
+
private final BuildPluginManager pluginManager;
private final MavenPluginManager mavenPluginManager;
private final LifecycleDependencyResolver lifeCycleDependencyResolver;
@@ -92,7 +94,7 @@ public class MojoExecutor {
private final MessageBuilderFactory messageBuilderFactory;
- private final Map<Thread, MojoDescriptor> mojos = new ConcurrentHashMap<>();
+ protected final Map<Thread, MojoDescriptor> mojos = new ConcurrentHashMap<>();
@Inject
public MojoExecutor(
@@ -226,18 +228,18 @@ public void close() {}
* by concurrently running mojo executions. To prevent race conditions, an aggregating execution will block
* all other executions until finished.
* We also lock on a given project to forbid a forked lifecycle to be executed concurrently with the project.
- * TODO: ideally, the builder should take care of the ordering in a smarter way
- * TODO: and concurrency issues fixed with MNG-7157
*/
protected class ProjectLock implements NoExceptionCloseable {
final Lock acquiredAggregatorLock;
final OwnerReentrantLock acquiredProjectLock;
ProjectLock(MavenSession session, MojoDescriptor mojoDescriptor) {
- mojos.put(Thread.currentThread(), mojoDescriptor);
boolean aggregator = mojoDescriptor.isAggregator();
acquiredAggregatorLock = aggregator ? aggregatorLock.writeLock() : aggregatorLock.readLock();
- acquiredProjectLock = getProjectLock(session);
+ SessionData data = session.getSession().getData();
+ Map<MavenProject, OwnerReentrantLock> locks = data.computeIfAbsent(PROJECT_LOCKS, ConcurrentHashMap::new);
+ MavenProject currentProject = session.getCurrentProject();
+ acquiredProjectLock = locks.computeIfAbsent(currentProject, p -> new OwnerReentrantLock());
if (!acquiredAggregatorLock.tryLock()) {
Thread owner = aggregatorLock.getOwner();
MojoDescriptor ownerMojo = owner != null ? mojos.get(owner) : null;
@@ -254,8 +256,8 @@ protected class ProjectLock implements NoExceptionCloseable {
MojoDescriptor ownerMojo = owner != null ? mojos.get(owner) : null;
String str = ownerMojo != null ? " The " + ownerMojo.getId() : "A";
String msg = str + " mojo is already being executed "
- + "on the project " + session.getCurrentProject().getGroupId()
- + ":" + session.getCurrentProject().getArtifactId() + ". "
+ + "on the project " + currentProject.getGroupId()
+ + ":" + currentProject.getArtifactId() + ". "
+ "This mojo execution will be blocked "
+ "until the mojo is done.";
warn(msg);
@@ -268,31 +270,28 @@ public void close() {
// release the lock in the reverse order of the acquisition
acquiredProjectLock.unlock();
acquiredAggregatorLock.unlock();
- mojos.remove(Thread.currentThread());
- }
-
- private OwnerReentrantLock getProjectLock(MavenSession session) {
- SessionData data = session.getSession().getData();
- Map<MavenProject, OwnerReentrantLock> locks = data.computeIfAbsent(PROJECT_LOCKS, ConcurrentHashMap::new);
- return locks.computeIfAbsent(session.getCurrentProject(), p -> new OwnerReentrantLock());
}
}
- static class OwnerReentrantLock extends ReentrantLock {
+ protected static class OwnerReentrantLock extends ReentrantLock {
+ public OwnerReentrantLock() {}
+
@Override
public Thread getOwner() {
return super.getOwner();
}
}
- static class OwnerReentrantReadWriteLock extends ReentrantReadWriteLock {
+ protected static class OwnerReentrantReadWriteLock extends ReentrantReadWriteLock {
+ public OwnerReentrantReadWriteLock() {}
+
@Override
public Thread getOwner() {
return super.getOwner();
}
}
- private static void warn(String msg) {
+ protected static void warn(String msg) {
for (String s : MultilineMessageHelper.format(msg)) {
LOGGER.warn(s);
}
@@ -305,7 +304,9 @@ private void doExecute(MavenSession session, MojoExecution mojoExecution, Depend
List<MavenProject> forkedProjects = executeForkedExecutions(mojoExecution, session);
try (NoExceptionCloseable lock = getProjectLock(session, mojoDescriptor)) {
- ensureDependenciesAreResolved(mojoDescriptor, session, dependencyContext);
+ if (!mojoDescriptor.isV4Api()) {
+ ensureDependenciesAreResolved(mojoDescriptor, session, dependencyContext);
+ }
doExecute2(session, mojoExecution);
} finally {
@@ -320,14 +321,27 @@ protected interface NoExceptionCloseable extends AutoCloseable {
void close();
}
- protected NoExceptionCloseable getProjectLock(MavenSession session, MojoDescriptor mojoDescriptor) {
+ public NoExceptionCloseable getProjectLock(MavenSession session, MojoDescriptor mojoDescriptor) {
if (useProjectLock(session)) {
- return new ProjectLock(session, mojoDescriptor);
+ Thread currentThread = Thread.currentThread();
+ mojos.put(currentThread, mojoDescriptor);
+ NoExceptionCloseable lock = createProjectLock(session, mojoDescriptor);
+ return () -> {
+ try {
+ lock.close();
+ } finally {
+ mojos.remove(currentThread);
+ }
+ };
} else {
- return new NoLock();
+ return NO_LOCK;
}
}
+ protected NoExceptionCloseable createProjectLock(MavenSession session, MojoDescriptor mojoDescriptor) {
+ return new ProjectLock(session, mojoDescriptor);
+ }
+
protected boolean useProjectLock(MavenSession session) {
return session.getRequest().getDegreeOfConcurrency() > 1;
}
@@ -398,6 +412,12 @@ public void ensureDependenciesAreResolved(
}
}
+ /**
+ * Returns the filter for the scopes a mojo declared it needs, or {@code null} if it declared none. A {@code null}
+ * filter is itself a meaningful value that {@link #ensureDependenciesAreResolved} writes to the shared
+ * {@link MavenProject}: it clears whatever filter an earlier mojo may have left there, so that a mojo with no
+ * declared scope sees no project dependencies, not another mojo's leftover ones (MNG-3297).
+ */
private ArtifactFilter getArtifactFilter(MojoDescriptor mojoDescriptor) {
String scopeToResolve = mojoDescriptor.getDependencyResolutionRequired();
String scopeToCollect = mojoDescriptor.getDependencyCollectionRequired();
diff --git a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java
index 273035b82c..ef74f8844b 100644
--- a/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java
+++ b/impl/maven-core/src/main/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutor.java
@@ -23,6 +23,10 @@
import javax.inject.Provider;
import javax.inject.Singleton;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.apache.maven.api.SessionData;
import org.apache.maven.api.services.MessageBuilderFactory;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.lifecycle.internal.ExecutionEventCatapult;
@@ -30,11 +34,17 @@
import org.apache.maven.plugin.BuildPluginManager;
import org.apache.maven.plugin.MavenPluginManager;
import org.apache.maven.plugin.MojosExecutionStrategy;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
@Named("concurrent")
@Singleton
public class MojoExecutor extends org.apache.maven.lifecycle.internal.MojoExecutor {
+ private static final Logger LOGGER = LoggerFactory.getLogger(MojoExecutor.class);
+
@Inject
public MojoExecutor(
BuildPluginManager pluginManager,
@@ -52,8 +62,59 @@ public MojoExecutor(
messageBuilderFactory);
}
+ /**
+ * The concurrent builder runs independent steps of the same project in parallel, for instance
+ * {@code process-resources} next to {@code compile}. Before a mojo runs,
+ * {@link #ensureDependenciesAreResolved} replaces the resolved artifacts and the artifact filter
+ * on the shared {@link MavenProject}, and the mojo reads them back while it executes.
+ *
+ * <p>This is done for every legacy (non-v4) mojo, including the ones that need no dependency:
+ * a {@code null} filter clears whatever an earlier mojo left, so that a mojo with no requirement
+ * sees no project dependencies rather than another mojo's leftover ones (MNG-3297). The executions
+ * of a single project are therefore serialized for legacy mojos.
+ *
+ * <p>Maven 4 mojos using {@code @Resolution} get their dependencies injected directly by
+ * {@code DefaultMavenPluginManager} and never read from {@link MavenProject}, so they skip both
+ * {@code ensureDependenciesAreResolved} and this lock. Executions on different projects are
+ * never serialized.
+ */
@Override
- protected boolean useProjectLock(MavenSession session) {
- return false;
+ protected NoExceptionCloseable createProjectLock(MavenSession session, MojoDescriptor mojoDescriptor) {
+ if (mojoDescriptor.isV4Api()) {
+ return NO_LOCK;
+ }
+ return new ConcurrentProjectLock(session, mojoDescriptor);
+ }
+
+ protected class ConcurrentProjectLock implements NoExceptionCloseable {
+
+ final OwnerReentrantLock lock;
+
+ ConcurrentProjectLock(MavenSession session, MojoDescriptor mojoDescriptor) {
+ SessionData data = session.getSession().getData();
+ Map<MavenProject, OwnerReentrantLock> locks = data.computeIfAbsent(PROJECT_LOCKS, ConcurrentHashMap::new);
+ MavenProject currentProject = session.getCurrentProject();
+ lock = locks.computeIfAbsent(currentProject, p -> new OwnerReentrantLock());
+ if (!lock.tryLock()) {
+ Thread owner = lock.getOwner();
+ MojoDescriptor ownerMojo = owner != null ? mojos.get(owner) : null;
+ if (LOGGER.isDebugEnabled()) {
+ LOGGER.debug(
+ "{} legacy (Maven 3) mojo is preventing full intra-project parallelism in the "
+ + "concurrent builder on the project {}:{}. This mojo execution will be blocked "
+ + "until the mojo is done. Consider migrating plugins to the Maven 4 API to "
+ + "benefit from full intra-project concurrency.",
+ ownerMojo != null ? "The " + ownerMojo.getId() : "A",
+ currentProject.getGroupId(),
+ currentProject.getArtifactId());
+ }
+ lock.lock();
+ }
+ }
+
+ @Override
+ public void close() {
+ lock.unlock();
+ }
}
}
diff --git a/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutorTest.java b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutorTest.java
new file mode 100644
index 0000000000..eaa7d46f3f
--- /dev/null
+++ b/impl/maven-core/src/test/java/org/apache/maven/lifecycle/internal/concurrent/MojoExecutorTest.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.lifecycle.internal.concurrent;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Supplier;
+
+import org.apache.maven.api.Session;
+import org.apache.maven.api.SessionData;
+import org.apache.maven.execution.MavenExecutionRequest;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.project.MavenProject;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * The executions of a project must not overlap, as each of them replaces the artifacts of the shared project (#13590).
+ */
+class MojoExecutorTest {
+
+ private final MojoExecutor executor = new MojoExecutor(null, null, null, null, null, null);
+
+ private final Map<Object, Object> data = new ConcurrentHashMap<>();
+
+ @SuppressWarnings("unchecked")
+ private MavenSession session(MavenProject project) {
+ SessionData sessionData = mock(SessionData.class);
+ when(sessionData.computeIfAbsent(any(), any())).thenAnswer(invocation -> {
+ Supplier<Object> supplier = invocation.getArgument(1);
+ return data.computeIfAbsent(invocation.getArgument(0), k -> supplier.get());
+ });
+ Session apiSession = mock(Session.class);
+ when(apiSession.getData()).thenReturn(sessionData);
+ MavenExecutionRequest request = mock(MavenExecutionRequest.class);
+ when(request.getDegreeOfConcurrency()).thenReturn(2);
+ MavenSession session = mock(MavenSession.class);
+ when(session.getSession()).thenReturn(apiSession);
+ when(session.getRequest()).thenReturn(request);
+ when(session.getCurrentProject()).thenReturn(project);
+ return session;
+ }
+
+ private static MavenProject project(String artifactId) {
+ org.apache.maven.model.Model model = new org.apache.maven.model.Model();
+ model.setGroupId("g");
+ model.setArtifactId(artifactId);
+ model.setVersion("1");
+ return new MavenProject(model);
+ }
+
+ private static MojoDescriptor mojoWithoutDependencies() {
+ MojoDescriptor mojo = new MojoDescriptor();
+ mojo.setPluginDescriptor(new PluginDescriptor());
+ return mojo;
+ }
+
+ private static MojoDescriptor v4Mojo() {
+ return new MojoDescriptor(
+ null,
+ org.apache.maven.api.plugin.descriptor.MojoDescriptor.newBuilder()
+ .goal("test")
+ .dependencyResolution("compile")
+ .build());
+ }
+
+ @Test
+ void testExecutionsOfTheSameProjectDoNotOverlap() throws Exception {
+ MavenSession session = session(project("a"));
+ ExecutorService threads = Executors.newFixedThreadPool(2);
+ try {
+ CountDownLatch holding = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Future<?> first = threads.submit(() -> {
+ try (AutoCloseable lock = executor.getProjectLock(session, mojoWithoutDependencies())) {
+ holding.countDown();
+ release.await();
+ }
+ return null;
+ });
+ assertTrue(holding.await(5, TimeUnit.SECONDS));
+
+ Future<?> second = threads.submit(() -> {
+ try (AutoCloseable lock = executor.getProjectLock(session, mojoWithoutDependencies())) {
+ return null;
+ }
+ });
+ assertThrows(TimeoutException.class, () -> second.get(300, TimeUnit.MILLISECONDS));
+
+ release.countDown();
+ first.get(5, TimeUnit.SECONDS);
+ second.get(5, TimeUnit.SECONDS);
+ } finally {
+ threads.shutdownNow();
+ }
+ }
+
+ @Test
+ void testV4MojosOfTheSameProjectRunInParallel() throws Exception {
+ MavenSession session = session(project("a"));
+ ExecutorService threads = Executors.newFixedThreadPool(2);
+ try {
+ CountDownLatch holding = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Future<?> first = threads.submit(() -> {
+ try (AutoCloseable lock = executor.getProjectLock(session, v4Mojo())) {
+ holding.countDown();
+ release.await();
+ }
+ return null;
+ });
+ assertTrue(holding.await(5, TimeUnit.SECONDS));
+
+ Future<Boolean> second = threads.submit(() -> {
+ try (AutoCloseable lock = executor.getProjectLock(session, v4Mojo())) {
+ return true;
+ }
+ });
+ assertTrue(second.get(5, TimeUnit.SECONDS));
+ assertFalse(first.isDone());
+
+ release.countDown();
+ first.get(5, TimeUnit.SECONDS);
+ } finally {
+ threads.shutdownNow();
+ }
+ }
+
+ @Test
+ void testExecutionsOfDifferentProjectsRunInParallel() throws Exception {
+ MavenSession sessionA = session(project("a"));
+ MavenSession sessionB = session(project("b"));
+ ExecutorService threads = Executors.newFixedThreadPool(2);
+ try {
+ CountDownLatch holding = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ Future<?> first = threads.submit(() -> {
+ try (AutoCloseable lock = executor.getProjectLock(sessionA, mojoWithoutDependencies())) {
+ holding.countDown();
+ release.await();
+ }
+ return null;
+ });
+ assertTrue(holding.await(5, TimeUnit.SECONDS));
+
+ Future<Boolean> second = threads.submit(() -> {
+ try (AutoCloseable lock = executor.getProjectLock(sessionB, mojoWithoutDependencies())) {
+ return true;
+ }
+ });
+ assertTrue(second.get(5, TimeUnit.SECONDS));
+ assertFalse(first.isDone());
+
+ release.countDown();
+ first.get(5, TimeUnit.SECONDS);
+ } finally {
+ threads.shutdownNow();
+ }
+ }
+}