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();
+        }
+    }
+}