Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions src/main/java/org/apache/maven/plugins/deploy/DeployMojo.java
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,9 @@ private boolean allProjectsMarked() {
* Returns the list of reactor projects that have a deploy execution, cached on first call.
* The list is invariant during a build and is stored in the first reactor project's plugin
* context to avoid recomputing it on every module invocation (O(N) total instead of O(N²)).
* {@link Map#computeIfAbsent} is used instead of a manual get/null-check/put sequence so that,
* even when several reactor modules race to initialize the cache concurrently, the reactor scan
* still runs exactly once.
*/
@SuppressWarnings("unchecked")
private List<Project> getProjectsWithDeployExecution() {
Expand All @@ -213,12 +216,9 @@ private List<Project> getProjectsWithDeployExecution() {
return List.of();
}
Map<String, Object> ctx = session.getPluginContext(allProjects.get(0));
List<Project> cached = (List<Project>) ctx.get(PROJECTS_WITH_DEPLOY_KEY);
if (cached == null) {
cached = allProjects.stream().filter(this::hasDeployExecution).collect(Collectors.toList());
ctx.put(PROJECTS_WITH_DEPLOY_KEY, cached);
}
return cached;
return (List<Project>) ctx.computeIfAbsent(
PROJECTS_WITH_DEPLOY_KEY,
key -> allProjects.stream().filter(this::hasDeployExecution).collect(Collectors.toList()));
}

private boolean hasDeployExecution(Project p) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* 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.plugins.deploy;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
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.atomic.AtomicInteger;

import org.apache.maven.api.MojoExecution;
import org.apache.maven.api.Plugin;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
import org.apache.maven.api.model.Build;
import org.apache.maven.api.model.PluginExecution;
import org.junit.jupiter.api.RepeatedTest;

import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

/**
* Deterministic barrier/counting harness proving that concurrent first access to
* {@link DeployMojo#getProjectsWithDeployExecution()} performs exactly one full reactor scan,
* matching the O(N)-total complexity the cache promises. The reactor-wide plugin context is backed
* by a {@link java.util.concurrent.ConcurrentHashMap}, as returned by the real Maven 4 core
* ({@code MavenSession#getPluginContext}) for this plugin's {@code maven-api-core} boundary.
*
* <p>The first reactor project's build lookup is deliberately slowed down to widen the race window:
* every racing thread reaches the cache-miss check while the winning thread is still scanning, so
* an unsynchronized get/null-check/scan/put sequence would perform one full scan per racing thread.
*/
class DeployMojoConcurrentInitializationTest {

private static final int THREAD_COUNT = 16;
private static final int PROJECT_COUNT = 8;
private static final long FIRST_PROJECT_SCAN_DELAY_MILLIS = 200L;

@RepeatedTest(5)
void concurrentFirstAccessPerformsExactlyOneFullReactorScan() throws Exception {
AtomicInteger firstProjectScans = new AtomicInteger();
List<Project> projects = buildReactorProjects(firstProjectScans);

Session session = mock(Session.class);
when(session.getProjects()).thenReturn(projects);
Map<String, Object> pluginContext = new ConcurrentHashMap<>();
when(session.getPluginContext(projects.get(0))).thenReturn(pluginContext);

DeployMojo mojo = new DeployMojo();
setVariableValueToObject(mojo, "session", session);
setVariableValueToObject(mojo, "mojoExecution", mockMojoExecution());

Method method = DeployMojo.class.getDeclaredMethod("getProjectsWithDeployExecution");
method.setAccessible(true);

List<List<Project>> results = callConcurrently(method, mojo);

for (List<Project> result : results) {
assertSame(results.get(0), result, "every concurrent caller must observe the identical cached list");
}
assertEquals(1, firstProjectScans.get(), "concurrent first access must perform exactly one full reactor scan");
}

private static List<List<Project>> callConcurrently(Method method, DeployMojo mojo) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(THREAD_COUNT);
CountDownLatch ready = new CountDownLatch(THREAD_COUNT);
CountDownLatch start = new CountDownLatch(1);
List<Future<List<Project>>> futures = new ArrayList<>();
try {
for (int i = 0; i < THREAD_COUNT; i++) {
futures.add(pool.submit(() -> {
ready.countDown();
start.await();
return invoke(method, mojo);
}));
}
ready.await();
start.countDown();

List<List<Project>> results = new ArrayList<>();
for (Future<List<Project>> future : futures) {
results.add(future.get(10, TimeUnit.SECONDS));
}
return results;
} finally {
pool.shutdownNow();
}
}

@SuppressWarnings("unchecked")
private static List<Project> invoke(Method method, DeployMojo mojo) throws Exception {
try {
return (List<Project>) method.invoke(mojo);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}

private static List<Project> buildReactorProjects(AtomicInteger firstProjectScans) {
List<Project> projects = new ArrayList<>();
Build build = buildWithDeployExecution();
for (int i = 0; i < PROJECT_COUNT; i++) {
Project project = mock(Project.class);
if (i == 0) {
when(project.getBuild()).thenAnswer(invocation -> {
firstProjectScans.incrementAndGet();
Thread.sleep(FIRST_PROJECT_SCAN_DELAY_MILLIS);
return build;
});
} else {
when(project.getBuild()).thenReturn(build);
}
projects.add(project);
}
return projects;
}

private static Build buildWithDeployExecution() {
PluginExecution execution = PluginExecution.newBuilder()
.goals(List.of("deploy"))
.phase("deploy")
.build();
org.apache.maven.api.model.Plugin plugin = org.apache.maven.api.model.Plugin.newBuilder()
.groupId("org.apache.maven.test")
.artifactId("probe-plugin")
.executions(List.of(execution))
.build();
return Build.newBuilder().plugins(List.of(plugin)).build();
}

private static MojoExecution mockMojoExecution() {
MojoExecution execution = mock(MojoExecution.class);
Plugin pluginApi = mock(Plugin.class);
org.apache.maven.api.model.Plugin model = org.apache.maven.api.model.Plugin.newBuilder()
.groupId("org.apache.maven.test")
.artifactId("probe-plugin")
.build();
when(execution.getPlugin()).thenReturn(pluginApi);
when(pluginApi.getModel()).thenReturn(model);
return execution;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/*
* 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.plugins.deploy;

import java.lang.reflect.Method;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;

import org.apache.maven.api.MojoExecution;
import org.apache.maven.api.Plugin;
import org.apache.maven.api.Project;
import org.apache.maven.api.Session;
import org.apache.maven.api.model.Build;
import org.apache.maven.api.model.PluginExecution;
import org.junit.jupiter.api.Test;

import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
* Characterization tests for {@link DeployMojo#getProjectsWithDeployExecution()}: the sequential,
* single-threaded contract that the reactor-wide cache of deploy-execution projects must preserve
* (empty reactor, filtering, ordering, plugin-context ownership/keying, and reuse across calls).
*/
class DeployMojoDeployExecutionCacheTest {

private static final String PLUGIN_KEY = "org.apache.maven.test:probe-plugin";

@Test
void emptyReactorReturnsEmptyListWithoutTouchingPluginContext() throws Exception {
Session session = mock(Session.class);
when(session.getProjects()).thenReturn(List.of());
DeployMojo mojo = newMojo(session, mockMojoExecution());

List<Project> result = invokeGetProjectsWithDeployExecution(mojo);

assertEquals(List.of(), result);
verify(session, never()).getPluginContext(any());
}

@Test
void filtersProjectsWithDeployExecutionPreservingReactorOrder() throws Exception {
Project withExec1 = projectWithDeployExecution(true);
Project withoutExec = projectWithDeployExecution(false);
Project withExec2 = projectWithDeployExecution(true);
List<Project> allProjects = List.of(withExec1, withoutExec, withExec2);

Session session = mock(Session.class);
when(session.getProjects()).thenReturn(allProjects);
when(session.getPluginContext(withExec1)).thenReturn(new ConcurrentHashMap<>());
DeployMojo mojo = newMojo(session, mockMojoExecution());

List<Project> result = invokeGetProjectsWithDeployExecution(mojo);

assertEquals(List.of(withExec1, withExec2), result);
}

@Test
void cachesResultInFirstProjectPluginContextAcrossRepeatedCalls() throws Exception {
Project first = projectWithDeployExecution(true);
Project second = projectWithDeployExecution(true);
List<Project> allProjects = List.of(first, second);

Session session = mock(Session.class);
when(session.getProjects()).thenReturn(allProjects);
when(session.getPluginContext(first)).thenReturn(new ConcurrentHashMap<>());
DeployMojo mojo = newMojo(session, mockMojoExecution());

List<Project> firstCall = invokeGetProjectsWithDeployExecution(mojo);
List<Project> secondCall = invokeGetProjectsWithDeployExecution(mojo);

assertSame(firstCall, secondCall, "second call must reuse the cached list instance");
verify(first, times(1)).getBuild();
verify(second, times(1)).getBuild();
verify(session, never()).getPluginContext(second);
}

private static DeployMojo newMojo(Session session, MojoExecution mojoExecution) throws Exception {
DeployMojo mojo = new DeployMojo();
setVariableValueToObject(mojo, "session", session);
setVariableValueToObject(mojo, "mojoExecution", mojoExecution);
return mojo;
}

@SuppressWarnings("unchecked")
private static List<Project> invokeGetProjectsWithDeployExecution(DeployMojo mojo) throws Exception {
Method method = DeployMojo.class.getDeclaredMethod("getProjectsWithDeployExecution");
method.setAccessible(true);
return (List<Project>) method.invoke(mojo);
}

private static Project projectWithDeployExecution(boolean hasExecution) {
Project project = mock(Project.class);
Build build =
hasExecution ? buildWithDeployExecution() : Build.newBuilder().build();
when(project.getBuild()).thenReturn(build);
return project;
}

private static Build buildWithDeployExecution() {
PluginExecution execution = PluginExecution.newBuilder()
.goals(List.of("deploy"))
.phase("deploy")
.build();
org.apache.maven.api.model.Plugin plugin = org.apache.maven.api.model.Plugin.newBuilder()
.groupId("org.apache.maven.test")
.artifactId("probe-plugin")
.executions(List.of(execution))
.build();
return Build.newBuilder().plugins(List.of(plugin)).build();
}

private static MojoExecution mockMojoExecution() {
MojoExecution execution = mock(MojoExecution.class);
Plugin pluginApi = mock(Plugin.class);
org.apache.maven.api.model.Plugin model = org.apache.maven.api.model.Plugin.newBuilder()
.groupId("org.apache.maven.test")
.artifactId("probe-plugin")
.build();
when(execution.getPlugin()).thenReturn(pluginApi);
when(pluginApi.getModel()).thenReturn(model);
assertEquals(PLUGIN_KEY, model.getKey());
return execution;
}
}