From 892492a5707f2386eb4df7b493ab4fe2f09cebad Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 09:15:56 +0200 Subject: [PATCH 001/120] Add support for executing a single task --- ...urrentHierarchicalTestExecutorService.java | 84 +++++++++++++++++++ ...tHierarchicalTestExecutorServiceTests.java | 83 ++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java create mode 100644 platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java new file mode 100644 index 000000000000..549053625165 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -0,0 +1,84 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apiguardian.api.API; +import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.util.ClassLoaderUtils; + +/** + * @since 6.1 + */ +@API(status = EXPERIMENTAL, since = "6.1") +public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + + private final ExecutorService executorService; + + public ConcurrentHierarchicalTestExecutorService() { + this(ClassLoaderUtils.getDefaultClassLoader()); + } + + ConcurrentHierarchicalTestExecutorService(ClassLoader classLoader) { + executorService = Executors.newCachedThreadPool(new CustomThreadFactory(classLoader)); + } + + @Override + public Future<@Nullable Void> submit(TestTask testTask) { + return toNullable(CompletableFuture.runAsync(testTask::execute, executorService)); + } + + @Override + public void invokeAll(List testTasks) { + testTasks.forEach(TestTask::execute); + throw new UnsupportedOperationException("Not supported yet."); + } + + @Override + public void close() { + executorService.shutdown(); + } + + @SuppressWarnings("NullAway") + private static Future<@Nullable Void> toNullable(Future voidCompletableFuture) { + return voidCompletableFuture; + } + + static class CustomThreadFactory implements ThreadFactory { + + private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); + + private final AtomicInteger threadNumber = new AtomicInteger(1); + private final int poolNumber; + private final ClassLoader classLoader; + + public CustomThreadFactory(ClassLoader classLoader) { + this.classLoader = classLoader; + this.poolNumber = POOL_NUMBER.getAndIncrement(); + } + + @Override + public Thread newThread(Runnable r) { + var thread = new Thread(r, "junit-%d-worker-%d".formatted(poolNumber, threadNumber.getAndIncrement())); + thread.setContextClassLoader(classLoader); + return thread; + } + } +} diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java new file mode 100644 index 000000000000..e20963bf0e45 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.net.URL; +import java.net.URLClassLoader; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; +import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; + +/** + * @since 6.1 + */ +class ConcurrentHierarchicalTestExecutorServiceTests { + + @AutoClose + @Nullable + ConcurrentHierarchicalTestExecutorService service; + + @ParameterizedTest + @EnumSource(ExecutionMode.class) + void executesSingleTask(ExecutionMode executionMode) throws Exception { + + var task = new TestTaskStub(executionMode); + + var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); + try (customClassLoader) { + service = new ConcurrentHierarchicalTestExecutorService(customClassLoader); + service.submit(task).get(); + } + + assertThat(task.executionThread).isNotNull().isNotSameAs(Thread.currentThread()); + assertThat(task.executionThread.getName()).matches("junit-\\d+-worker-1"); + assertThat(task.executionThread.getContextClassLoader()).isSameAs(customClassLoader); + } + + @NullMarked + private static class TestTaskStub implements TestTask { + + private final ExecutionMode executionMode; + private final ResourceLock resourceLock; + private @Nullable Thread executionThread; + + TestTaskStub(ExecutionMode executionMode) { + this(executionMode, NopLock.INSTANCE); + } + + TestTaskStub(ExecutionMode executionMode, ResourceLock resourceLock) { + this.executionMode = executionMode; + this.resourceLock = resourceLock; + } + + @Override + public ExecutionMode getExecutionMode() { + return executionMode; + } + + @Override + public ResourceLock getResourceLock() { + return resourceLock; + } + + @Override + public void execute() { + executionThread = Thread.currentThread(); + } + } +} From 5c9f0f63a97cd66f45fd4dfb42256a18fa207f2f Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 09:32:53 +0200 Subject: [PATCH 002/120] Verify that `invokeAll()` is only called internally --- ...urrentHierarchicalTestExecutorService.java | 24 ++++++++++++++++--- ...tHierarchicalTestExecutorServiceTests.java | 18 ++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 549053625165..3fd0c5fd2e25 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -23,6 +23,7 @@ import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; import org.junit.platform.commons.util.ClassLoaderUtils; +import org.junit.platform.commons.util.Preconditions; /** * @since 6.1 @@ -47,6 +48,8 @@ public ConcurrentHierarchicalTestExecutorService() { @Override public void invokeAll(List testTasks) { + Preconditions.condition(CustomThread.getExecutor() == this, + "invokeAll() must not be called from a thread that is not part of this executor"); testTasks.forEach(TestTask::execute); throw new UnsupportedOperationException("Not supported yet."); } @@ -61,7 +64,7 @@ public void close() { return voidCompletableFuture; } - static class CustomThreadFactory implements ThreadFactory { + private class CustomThreadFactory implements ThreadFactory { private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); @@ -69,16 +72,31 @@ static class CustomThreadFactory implements ThreadFactory { private final int poolNumber; private final ClassLoader classLoader; - public CustomThreadFactory(ClassLoader classLoader) { + CustomThreadFactory(ClassLoader classLoader) { this.classLoader = classLoader; this.poolNumber = POOL_NUMBER.getAndIncrement(); } @Override public Thread newThread(Runnable r) { - var thread = new Thread(r, "junit-%d-worker-%d".formatted(poolNumber, threadNumber.getAndIncrement())); + var thread = new CustomThread(r, + "junit-%d-worker-%d".formatted(poolNumber, threadNumber.getAndIncrement())); thread.setContextClassLoader(classLoader); return thread; } } + + private class CustomThread extends Thread { + CustomThread(Runnable task, String name) { + super(task, name); + } + + static @Nullable ConcurrentHierarchicalTestExecutorService getExecutor() { + return Thread.currentThread() instanceof CustomThread c ? c.executor() : null; + } + + private ConcurrentHierarchicalTestExecutorService executor() { + return ConcurrentHierarchicalTestExecutorService.this; + } + } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index e20963bf0e45..e88069a19bde 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -11,13 +11,17 @@ package org.junit.platform.engine.support.hierarchical; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; +import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import java.net.URL; import java.net.URLClassLoader; +import java.util.List; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; @@ -49,6 +53,16 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { assertThat(task.executionThread.getContextClassLoader()).isSameAs(customClassLoader); } + @Test + @SuppressWarnings("NullAway") + void invokeAllMustBeExecutedFromWithinThreadPool() { + var tasks = List.of(new TestTaskStub(CONCURRENT)); + service = new ConcurrentHierarchicalTestExecutorService(); + + assertPreconditionViolationFor(() -> service.invokeAll(tasks)) // + .withMessage("invokeAll() must not be called from a thread that is not part of this executor"); + } + @NullMarked private static class TestTaskStub implements TestTask { @@ -79,5 +93,9 @@ public ResourceLock getResourceLock() { public void execute() { executionThread = Thread.currentThread(); } + + public @Nullable Thread executionThread() { + return executionThread; + } } } From a71f2320a27b91b05c25d65bc63b4d432cc3260b Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 10:01:35 +0200 Subject: [PATCH 003/120] Add support for executing children concurrently --- ...urrentHierarchicalTestExecutorService.java | 14 ++-- ...tHierarchicalTestExecutorServiceTests.java | 75 ++++++++++++++++--- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 3fd0c5fd2e25..f110ce328527 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -43,15 +43,19 @@ public ConcurrentHierarchicalTestExecutorService() { @Override public Future<@Nullable Void> submit(TestTask testTask) { - return toNullable(CompletableFuture.runAsync(testTask::execute, executorService)); + return submitInternal(testTask); } @Override public void invokeAll(List testTasks) { Preconditions.condition(CustomThread.getExecutor() == this, "invokeAll() must not be called from a thread that is not part of this executor"); - testTasks.forEach(TestTask::execute); - throw new UnsupportedOperationException("Not supported yet."); + var futures = testTasks.stream().map(this::submitInternal).toArray(CompletableFuture[]::new); + CompletableFuture.allOf(futures).join(); + } + + private CompletableFuture<@Nullable Void> submitInternal(TestTask testTask) { + return toNullable(CompletableFuture.runAsync(testTask::execute, executorService)); } @Override @@ -60,8 +64,8 @@ public void close() { } @SuppressWarnings("NullAway") - private static Future<@Nullable Void> toNullable(Future voidCompletableFuture) { - return voidCompletableFuture; + private static CompletableFuture<@Nullable Void> toNullable(CompletableFuture future) { + return future; } private class CustomThreadFactory implements ThreadFactory { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index e88069a19bde..8ca49205433d 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -12,18 +12,24 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import java.net.URL; import java.net.URLClassLoader; import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.platform.commons.util.Preconditions; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; @@ -40,7 +46,7 @@ class ConcurrentHierarchicalTestExecutorServiceTests { @EnumSource(ExecutionMode.class) void executesSingleTask(ExecutionMode executionMode) throws Exception { - var task = new TestTaskStub(executionMode); + TestTaskStub<@Nullable Object> task = TestTaskStub.withoutResult(executionMode); var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); try (customClassLoader) { @@ -48,34 +54,60 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { service.submit(task).get(); } - assertThat(task.executionThread).isNotNull().isNotSameAs(Thread.currentThread()); - assertThat(task.executionThread.getName()).matches("junit-\\d+-worker-1"); - assertThat(task.executionThread.getContextClassLoader()).isSameAs(customClassLoader); + assertThat(task.executionThread()).isNotNull().isNotSameAs(Thread.currentThread()); + assertThat(task.executionThread().getName()).matches("junit-\\d+-worker-1"); + assertThat(task.executionThread().getContextClassLoader()).isSameAs(customClassLoader); } @Test @SuppressWarnings("NullAway") void invokeAllMustBeExecutedFromWithinThreadPool() { - var tasks = List.of(new TestTaskStub(CONCURRENT)); + var tasks = List.of(TestTaskStub.withoutResult(CONCURRENT)); service = new ConcurrentHierarchicalTestExecutorService(); assertPreconditionViolationFor(() -> service.invokeAll(tasks)) // .withMessage("invokeAll() must not be called from a thread that is not part of this executor"); } + @Test + @SuppressWarnings("NullAway") + void executesTwoChildrenConcurrently() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(); + + var latch = new CountDownLatch(2); + Behavior behavior = () -> { + latch.countDown(); + return latch.await(100, TimeUnit.MILLISECONDS); + }; + + var children = List.of(new TestTaskStub<>(CONCURRENT, behavior), new TestTaskStub<>(CONCURRENT, behavior)); + var root = new TestTaskStub<@Nullable Void>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); + + service.submit(root).get(); + + assertThat(children).extracting(TestTaskStub::result).containsOnly(true); + } + @NullMarked - private static class TestTaskStub implements TestTask { + private static final class TestTaskStub implements TestTask { private final ExecutionMode executionMode; + private final Behavior behavior; private final ResourceLock resourceLock; private @Nullable Thread executionThread; + private final CompletableFuture<@Nullable T> result = new CompletableFuture<>(); - TestTaskStub(ExecutionMode executionMode) { - this(executionMode, NopLock.INSTANCE); + static TestTaskStub<@Nullable T> withoutResult(ExecutionMode executionMode) { + return new TestTaskStub<@Nullable T>(executionMode, () -> null); } - TestTaskStub(ExecutionMode executionMode, ResourceLock resourceLock) { + TestTaskStub(ExecutionMode executionMode, Behavior behavior) { + this(executionMode, behavior, NopLock.INSTANCE); + } + + TestTaskStub(ExecutionMode executionMode, Behavior behavior, ResourceLock resourceLock) { this.executionMode = executionMode; + this.behavior = behavior; this.resourceLock = resourceLock; } @@ -92,10 +124,35 @@ public ResourceLock getResourceLock() { @Override public void execute() { executionThread = Thread.currentThread(); + try { + result.complete(behavior.execute()); + } + catch (Throwable t) { + result.completeExceptionally(t); + throw throwAsUncheckedException(t); + } } public @Nullable Thread executionThread() { return executionThread; } + + public T result() { + Preconditions.condition(result.isDone(), "task was not executed"); + return result.getNow(null); + } + } + + @FunctionalInterface + interface Behavior { + + static Behavior<@Nullable Void> ofVoid(Executable executable) { + return () -> { + executable.execute(); + return null; + }; + } + + T execute() throws Throwable; } } From 1c703f04cddae41134ec2db670b61db5c25c4cad Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 10:17:37 +0200 Subject: [PATCH 004/120] Add support for executing children in same thread --- ...urrentHierarchicalTestExecutorService.java | 30 +++++++++++++++++-- ...tHierarchicalTestExecutorServiceTests.java | 15 ++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index f110ce328527..48c345d66d65 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -10,7 +10,11 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.concurrent.CompletableFuture.completedFuture; +import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; +import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -48,10 +52,32 @@ public ConcurrentHierarchicalTestExecutorService() { @Override public void invokeAll(List testTasks) { + Preconditions.condition(CustomThread.getExecutor() == this, "invokeAll() must not be called from a thread that is not part of this executor"); - var futures = testTasks.stream().map(this::submitInternal).toArray(CompletableFuture[]::new); - CompletableFuture.allOf(futures).join(); + + var childrenByExecutionMode = testTasks.stream().collect(groupingBy(TestTask::getExecutionMode)); + var concurrentChildren = forkConcurrentChildren(childrenByExecutionMode.get(CONCURRENT)); + executeSameThreadChildren(childrenByExecutionMode.get(SAME_THREAD)); + concurrentChildren.join(); + } + + private CompletableFuture forkConcurrentChildren(@Nullable List children) { + if (children == null) { + return completedFuture(null); + } + CompletableFuture[] futures = children.stream() // + .map(this::submitInternal) // + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + } + + private void executeSameThreadChildren(@Nullable List children) { + if (children != null) { + for (var testTask : children) { + testTask.execute(); + } + } } private CompletableFuture<@Nullable Void> submitInternal(TestTask testTask) { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 8ca49205433d..9894ae7b7182 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -14,6 +14,7 @@ import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; +import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; import java.net.URL; import java.net.URLClassLoader; @@ -88,6 +89,20 @@ void executesTwoChildrenConcurrently() throws Exception { assertThat(children).extracting(TestTaskStub::result).containsOnly(true); } + @Test + @SuppressWarnings("NullAway") + void executesTwoChildrenInSameThread() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(); + + var children = List.of(TestTaskStub.withoutResult(SAME_THREAD), TestTaskStub.withoutResult(SAME_THREAD)); + var root = new TestTaskStub<@Nullable Void>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); + + service.submit(root).get(); + + assertThat(root.executionThread()).isNotNull(); + assertThat(children).extracting(TestTaskStub::executionThread).containsOnly(root.executionThread()); + } + @NullMarked private static final class TestTaskStub implements TestTask { From c0a9d4284d5232cb6e165b7ca3b7990482619a45 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 10:25:23 +0200 Subject: [PATCH 005/120] Always execute single child in same thread as its parent --- ...urrentHierarchicalTestExecutorService.java | 17 +++++++++++++++-- ...tHierarchicalTestExecutorServiceTests.java | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 48c345d66d65..c01bba35c843 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -56,6 +56,15 @@ public void invokeAll(List testTasks) { Preconditions.condition(CustomThread.getExecutor() == this, "invokeAll() must not be called from a thread that is not part of this executor"); + if (testTasks.isEmpty()) { + return; + } + + if (testTasks.size() == 1) { + executeTask(testTasks.get(0)); + return; + } + var childrenByExecutionMode = testTasks.stream().collect(groupingBy(TestTask::getExecutionMode)); var concurrentChildren = forkConcurrentChildren(childrenByExecutionMode.get(CONCURRENT)); executeSameThreadChildren(childrenByExecutionMode.get(SAME_THREAD)); @@ -75,13 +84,17 @@ private CompletableFuture forkConcurrentChildren(@Nullable List children) { if (children != null) { for (var testTask : children) { - testTask.execute(); + executeTask(testTask); } } } private CompletableFuture<@Nullable Void> submitInternal(TestTask testTask) { - return toNullable(CompletableFuture.runAsync(testTask::execute, executorService)); + return toNullable(CompletableFuture.runAsync(() -> executeTask(testTask), executorService)); + } + + private static void executeTask(TestTask testTask) { + testTask.execute(); } @Override diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 9894ae7b7182..7d3b55a0eacc 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -70,6 +70,23 @@ void invokeAllMustBeExecutedFromWithinThreadPool() { .withMessage("invokeAll() must not be called from a thread that is not part of this executor"); } + @ParameterizedTest + @EnumSource(ExecutionMode.class) + @SuppressWarnings("NullAway") + void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode childExecutionMode) + throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(); + + var child = TestTaskStub.withoutResult(childExecutionMode); + var root = new TestTaskStub<@Nullable Void>(CONCURRENT, + Behavior.ofVoid(() -> service.invokeAll(List.of(child)))); + + service.submit(root).get(); + + assertThat(root.executionThread()).isNotNull(); + assertThat(child.executionThread()).isSameAs(root.executionThread()); + } + @Test @SuppressWarnings("NullAway") void executesTwoChildrenConcurrently() throws Exception { @@ -138,6 +155,8 @@ public ResourceLock getResourceLock() { @Override public void execute() { + Preconditions.condition(!result.isDone(), "task was already executed"); + executionThread = Thread.currentThread(); try { result.complete(behavior.execute()); From df84bf65e7afe32cbed3c9ca59bbfd99452fdbd9 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 10:28:01 +0200 Subject: [PATCH 006/120] Polishing --- ...urrentHierarchicalTestExecutorService.java | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index c01bba35c843..92c936d79ed1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -66,26 +66,34 @@ public void invokeAll(List testTasks) { } var childrenByExecutionMode = testTasks.stream().collect(groupingBy(TestTask::getExecutionMode)); - var concurrentChildren = forkConcurrentChildren(childrenByExecutionMode.get(CONCURRENT)); - executeSameThreadChildren(childrenByExecutionMode.get(SAME_THREAD)); + var concurrentChildren = forkAll(childrenByExecutionMode.get(CONCURRENT)); + executeAll(childrenByExecutionMode.get(SAME_THREAD)); concurrentChildren.join(); } - private CompletableFuture forkConcurrentChildren(@Nullable List children) { + private CompletableFuture forkAll(@Nullable List children) { if (children == null) { return completedFuture(null); } + if (children.size() == 1) { + return submitInternal(children.get(0)); + } CompletableFuture[] futures = children.stream() // .map(this::submitInternal) // .toArray(CompletableFuture[]::new); return CompletableFuture.allOf(futures); } - private void executeSameThreadChildren(@Nullable List children) { - if (children != null) { - for (var testTask : children) { - executeTask(testTask); - } + private void executeAll(@Nullable List children) { + if (children == null) { + return; + } + if (children.size() == 1) { + executeTask(children.get(0)); + return; + } + for (var testTask : children) { + executeTask(testTask); } } From bd426ec5e0a863f0cce92b428cc6e45618a5337b Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 10:53:44 +0200 Subject: [PATCH 007/120] Use fixed thread pool --- .../ConcurrentHierarchicalTestExecutorService.java | 8 ++++---- ...urrentHierarchicalTestExecutorServiceTests.java | 14 +++++++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 92c936d79ed1..abfceafc1db5 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -37,12 +37,12 @@ public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTe private final ExecutorService executorService; - public ConcurrentHierarchicalTestExecutorService() { - this(ClassLoaderUtils.getDefaultClassLoader()); + public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { + this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } - ConcurrentHierarchicalTestExecutorService(ClassLoader classLoader) { - executorService = Executors.newCachedThreadPool(new CustomThreadFactory(classLoader)); + ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { + executorService = Executors.newFixedThreadPool(configuration.getParallelism(), new CustomThreadFactory(classLoader)); } @Override diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 7d3b55a0eacc..01f7bf69aa77 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -51,7 +51,7 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); try (customClassLoader) { - service = new ConcurrentHierarchicalTestExecutorService(customClassLoader); + service = new ConcurrentHierarchicalTestExecutorService(configuration(1), customClassLoader); service.submit(task).get(); } @@ -64,7 +64,7 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { @SuppressWarnings("NullAway") void invokeAllMustBeExecutedFromWithinThreadPool() { var tasks = List.of(TestTaskStub.withoutResult(CONCURRENT)); - service = new ConcurrentHierarchicalTestExecutorService(); + service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); assertPreconditionViolationFor(() -> service.invokeAll(tasks)) // .withMessage("invokeAll() must not be called from a thread that is not part of this executor"); @@ -75,7 +75,7 @@ void invokeAllMustBeExecutedFromWithinThreadPool() { @SuppressWarnings("NullAway") void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode childExecutionMode) throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(); + service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); var child = TestTaskStub.withoutResult(childExecutionMode); var root = new TestTaskStub<@Nullable Void>(CONCURRENT, @@ -90,7 +90,7 @@ void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode c @Test @SuppressWarnings("NullAway") void executesTwoChildrenConcurrently() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(); + service = new ConcurrentHierarchicalTestExecutorService(configuration(3)); var latch = new CountDownLatch(2); Behavior behavior = () -> { @@ -109,7 +109,7 @@ void executesTwoChildrenConcurrently() throws Exception { @Test @SuppressWarnings("NullAway") void executesTwoChildrenInSameThread() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(); + service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); var children = List.of(TestTaskStub.withoutResult(SAME_THREAD), TestTaskStub.withoutResult(SAME_THREAD)); var root = new TestTaskStub<@Nullable Void>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); @@ -120,6 +120,10 @@ void executesTwoChildrenInSameThread() throws Exception { assertThat(children).extracting(TestTaskStub::executionThread).containsOnly(root.executionThread()); } + private static ParallelExecutionConfiguration configuration(int parallelism) { + return new DefaultParallelExecutionConfiguration(parallelism, parallelism, parallelism, parallelism, 0, __ -> true); + } + @NullMarked private static final class TestTaskStub implements TestTask { From 0947a898836e422791d5edffd84d763f8ea5f4e6 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 11:59:00 +0200 Subject: [PATCH 008/120] Implement basic work stealing --- ...urrentHierarchicalTestExecutorService.java | 145 ++++++++++++++---- ...tHierarchicalTestExecutorServiceTests.java | 5 +- 2 files changed, 120 insertions(+), 30 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index abfceafc1db5..e98f193cd709 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -10,18 +10,21 @@ package org.junit.platform.engine.support.hierarchical; -import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.apiguardian.api.API; @@ -35,6 +38,7 @@ @API(status = EXPERIMENTAL, since = "6.1") public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + private final WorkQueue workQueue = new WorkQueue(); private final ExecutorService executorService; public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { @@ -42,18 +46,22 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration } ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { - executorService = Executors.newFixedThreadPool(configuration.getParallelism(), new CustomThreadFactory(classLoader)); + executorService = Executors.newFixedThreadPool(configuration.getParallelism(), + new CustomThreadFactory(classLoader)); + for (var i = 0; i < configuration.getParallelism(); i++) { + startWorker(); + } } @Override public Future<@Nullable Void> submit(TestTask testTask) { - return submitInternal(testTask); + return enqueue(testTask).completion.thenApply(__ -> null); } @Override public void invokeAll(List testTasks) { - Preconditions.condition(CustomThread.getExecutor() == this, + Preconditions.condition(WorkerThread.getExecutor() == this, "invokeAll() must not be called from a thread that is not part of this executor"); if (testTasks.isEmpty()) { @@ -66,22 +74,76 @@ public void invokeAll(List testTasks) { } var childrenByExecutionMode = testTasks.stream().collect(groupingBy(TestTask::getExecutionMode)); - var concurrentChildren = forkAll(childrenByExecutionMode.get(CONCURRENT)); + var queueEntries = forkAll(childrenByExecutionMode.get(CONCURRENT)); executeAll(childrenByExecutionMode.get(SAME_THREAD)); - concurrentChildren.join(); + var concurrentlyExecutedChildren = stealWork(queueEntries); + if (!concurrentlyExecutedChildren.isEmpty()) { + // TODO give up worker lease + toCompletableFuture(concurrentlyExecutedChildren).join(); + } + } + + private WorkQueue.Entry enqueue(TestTask testTask) { + // TODO check if worker needs to be started + startWorker(); + return workQueue.add(testTask); + } + + private void startWorker() { + executorService.execute(() -> { + while (!executorService.isShutdown()) { + try { + // TODO get worker lease + var entry = workQueue.poll(30, TimeUnit.SECONDS); + if (entry == null) { + // TODO give up worker lease + // nothing to do -> exiting + return; + } + entry.execute(); + } + catch (InterruptedException ignore) { + // ignore spurious interrupts + } + } + }); + } + + private static CompletableFuture toCompletableFuture(List> futures) { + if (futures.size() == 1) { + return futures.get(0); + } + return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)); + } + + private List> stealWork(List queueEntries) { + if (queueEntries.isEmpty()) { + return List.of(); + } + List> futures = new ArrayList<>(queueEntries.size()); + var iterator = queueEntries.listIterator(queueEntries.size()); + for (var entry = iterator.previous(); iterator.hasPrevious(); entry = iterator.previous()) { + var claimed = workQueue.remove(entry); + if (claimed) { + entry.execute(); + } + else { + futures.add(entry.completion); + } + } + return futures; } - private CompletableFuture forkAll(@Nullable List children) { + private List forkAll(@Nullable List children) { if (children == null) { - return completedFuture(null); + return List.of(); } if (children.size() == 1) { - return submitInternal(children.get(0)); + return List.of(enqueue(children.get(0))); } - CompletableFuture[] futures = children.stream() // - .map(this::submitInternal) // - .toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures); + return children.stream() // + .map(ConcurrentHierarchicalTestExecutorService.this::enqueue) // + .toList(); } private void executeAll(@Nullable List children) { @@ -97,10 +159,6 @@ private void executeAll(@Nullable List children) { } } - private CompletableFuture<@Nullable Void> submitInternal(TestTask testTask) { - return toNullable(CompletableFuture.runAsync(() -> executeTask(testTask), executorService)); - } - private static void executeTask(TestTask testTask) { testTask.execute(); } @@ -110,11 +168,6 @@ public void close() { executorService.shutdown(); } - @SuppressWarnings("NullAway") - private static CompletableFuture<@Nullable Void> toNullable(CompletableFuture future) { - return future; - } - private class CustomThreadFactory implements ThreadFactory { private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); @@ -129,25 +182,61 @@ private class CustomThreadFactory implements ThreadFactory { } @Override - public Thread newThread(Runnable r) { - var thread = new CustomThread(r, + public Thread newThread(Runnable runnable) { + var thread = new WorkerThread(runnable, "junit-%d-worker-%d".formatted(poolNumber, threadNumber.getAndIncrement())); thread.setContextClassLoader(classLoader); return thread; } } - private class CustomThread extends Thread { - CustomThread(Runnable task, String name) { - super(task, name); + private class WorkerThread extends Thread { + + WorkerThread(Runnable runnable, String name) { + super(runnable, name); } static @Nullable ConcurrentHierarchicalTestExecutorService getExecutor() { - return Thread.currentThread() instanceof CustomThread c ? c.executor() : null; + return Thread.currentThread() instanceof WorkerThread c ? c.executor() : null; } private ConcurrentHierarchicalTestExecutorService executor() { return ConcurrentHierarchicalTestExecutorService.this; } + + } + + private static class WorkQueue { + + private final BlockingQueue queue = new ArrayBlockingQueue<>(1024); + + Entry add(TestTask task) { + var entry = new Entry(task, new CompletableFuture<>()); + queue.add(entry); + return entry; + } + + @Nullable + Entry poll(long timeout, TimeUnit unit) throws InterruptedException { + return queue.poll(timeout, unit); + } + + boolean remove(Entry entry) { + return queue.remove(entry); + } + + private record Entry(TestTask task, CompletableFuture completion) { + void execute() { + try { + executeTask(task); + completion.complete(null); + } + catch (Throwable t) { + completion.completeExceptionally(t); + } + } + } + } + } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 01f7bf69aa77..f4c20af01888 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -90,7 +90,7 @@ void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode c @Test @SuppressWarnings("NullAway") void executesTwoChildrenConcurrently() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(3)); + service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); var latch = new CountDownLatch(2); Behavior behavior = () -> { @@ -121,7 +121,8 @@ void executesTwoChildrenInSameThread() throws Exception { } private static ParallelExecutionConfiguration configuration(int parallelism) { - return new DefaultParallelExecutionConfiguration(parallelism, parallelism, parallelism, parallelism, 0, __ -> true); + return new DefaultParallelExecutionConfiguration(parallelism, parallelism, parallelism, parallelism, 0, + __ -> true); } @NullMarked From 58ac3d1750fae96d8492205b1cb0ca461c14ea7e Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 12:37:12 +0200 Subject: [PATCH 009/120] Polishing --- ...urrentHierarchicalTestExecutorService.java | 60 ++++++++++--------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index e98f193cd709..86d26899297e 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -10,6 +10,7 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; @@ -17,14 +18,13 @@ import java.util.ArrayList; import java.util.List; -import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; import org.apiguardian.api.API; @@ -39,23 +39,21 @@ public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTestExecutorService { private final WorkQueue workQueue = new WorkQueue(); - private final ExecutorService executorService; + private final ExecutorService threadPool; public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { - executorService = Executors.newFixedThreadPool(configuration.getParallelism(), - new CustomThreadFactory(classLoader)); - for (var i = 0; i < configuration.getParallelism(); i++) { - startWorker(); - } + ThreadFactory threadFactory = new CustomThreadFactory(classLoader); + threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), + configuration.getKeepAliveSeconds(), SECONDS, new LinkedBlockingQueue<>(), threadFactory); } @Override public Future<@Nullable Void> submit(TestTask testTask) { - return enqueue(testTask).completion.thenApply(__ -> null); + return enqueue(testTask).future(); } @Override @@ -80,6 +78,7 @@ public void invokeAll(List testTasks) { if (!concurrentlyExecutedChildren.isEmpty()) { // TODO give up worker lease toCompletableFuture(concurrentlyExecutedChildren).join(); + // TODO get worker lease } } @@ -90,15 +89,15 @@ private WorkQueue.Entry enqueue(TestTask testTask) { } private void startWorker() { - executorService.execute(() -> { - while (!executorService.isShutdown()) { + threadPool.execute(() -> { + while (!threadPool.isShutdown()) { try { // TODO get worker lease - var entry = workQueue.poll(30, TimeUnit.SECONDS); + var entry = workQueue.poll(); if (entry == null) { // TODO give up worker lease - // nothing to do -> exiting - return; + // nothing to do -> done + break; } entry.execute(); } @@ -128,7 +127,7 @@ private List> stealWork(List queueEntries) entry.execute(); } else { - futures.add(entry.completion); + futures.add(entry.future); } } return futures; @@ -159,13 +158,9 @@ private void executeAll(@Nullable List children) { } } - private static void executeTask(TestTask testTask) { - testTask.execute(); - } - @Override public void close() { - executorService.shutdown(); + threadPool.shutdownNow(); } private class CustomThreadFactory implements ThreadFactory { @@ -208,35 +203,44 @@ private ConcurrentHierarchicalTestExecutorService executor() { private static class WorkQueue { - private final BlockingQueue queue = new ArrayBlockingQueue<>(1024); + private final BlockingQueue queue = new LinkedBlockingQueue<>(); Entry add(TestTask task) { var entry = new Entry(task, new CompletableFuture<>()); - queue.add(entry); + var added = queue.add(entry); + if (!added) { + throw new IllegalStateException("Could not add entry to the queue for task: " + task); + } return entry; } @Nullable - Entry poll(long timeout, TimeUnit unit) throws InterruptedException { - return queue.poll(timeout, unit); + Entry poll() throws InterruptedException { + return queue.poll(1, SECONDS); } boolean remove(Entry entry) { return queue.remove(entry); } - private record Entry(TestTask task, CompletableFuture completion) { + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future) { void execute() { try { executeTask(task); - completion.complete(null); } catch (Throwable t) { - completion.completeExceptionally(t); + future.completeExceptionally(t); + } + finally { + future.complete(null); } } } } + private static void executeTask(TestTask testTask) { + testTask.execute(); + } + } From a11039c235192f6b15c9a418bc15219cf860d97f Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 12:37:31 +0200 Subject: [PATCH 010/120] Configure timeout for all tests --- .../ConcurrentHierarchicalTestExecutorServiceTests.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index f4c20af01888..0003d9eaf566 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -27,6 +27,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; @@ -37,6 +38,7 @@ /** * @since 6.1 */ +@Timeout(5) class ConcurrentHierarchicalTestExecutorServiceTests { @AutoClose From 58198aefa4a53af9d119928bdceadd53c9bbd959 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 12:54:20 +0200 Subject: [PATCH 011/120] Introduce `ResourceLock.tryAcquire` --- .../support/hierarchical/CompositeLock.java | 20 +++++++++ .../engine/support/hierarchical/NopLock.java | 5 +++ .../support/hierarchical/ResourceLock.java | 12 +++++ .../support/hierarchical/SingleLock.java | 5 +++ .../hierarchical/CompositeLockTests.java | 44 +++++++++++++++++-- .../support/hierarchical/SingleLockTests.java | 14 ++++++ 6 files changed, 96 insertions(+), 4 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java index 7ac65a73e6a1..3fdca310b5eb 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/CompositeLock.java @@ -45,6 +45,26 @@ List getLocks() { return this.locks; } + @Override + public boolean tryAcquire() { + List acquiredLocks = new ArrayList<>(this.locks.size()); + for (Lock lock : this.locks) { + if (lock.tryLock()) { + acquiredLocks.add(lock); + } + else { + break; + } + } + if (acquiredLocks.size() == this.locks.size()) { + return true; + } + else { + release(acquiredLocks); + return false; + } + } + @Override public ResourceLock acquire() throws InterruptedException { ForkJoinPool.managedBlock(new CompositeLockManagedBlocker()); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java index ea43494c2b57..e84c3a77fb25 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/NopLock.java @@ -33,6 +33,11 @@ public List getResources() { return emptyList(); } + @Override + public boolean tryAcquire() { + return true; + } + @Override public ResourceLock acquire() { return this; diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java index 5022d88d0101..0f8a1cac639f 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ResourceLock.java @@ -10,6 +10,7 @@ package org.junit.platform.engine.support.hierarchical; +import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.apiguardian.api.API.Status.STABLE; import java.util.List; @@ -26,6 +27,17 @@ @API(status = STABLE, since = "1.10") public interface ResourceLock extends AutoCloseable { + /** + * Try to acquire this resource lock, without blocking. + * + * @return {@code true} if the lock was acquired and {@code false} otherwise + * @since 6.1 + */ + @API(status = EXPERIMENTAL, since = "6.1") + default boolean tryAcquire() { + return false; + } + /** * Acquire this resource lock, potentially blocking. * diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java index d2c39f9d215d..1110ccb42b2b 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/SingleLock.java @@ -41,6 +41,11 @@ Lock getLock() { return this.lock; } + @Override + public boolean tryAcquire() { + return this.lock.tryLock(); + } + @Override public ResourceLock acquire() throws InterruptedException { ForkJoinPool.managedBlock(new SingleLockManagedBlocker()); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java index 3fbbaabf6191..1b062d0b6e25 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/CompositeLockTests.java @@ -15,6 +15,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -30,11 +31,27 @@ */ class CompositeLockTests { + @Test + @SuppressWarnings({ "resource", "ResultOfMethodCallIgnored" }) + void triesToAcquireAllLocksInOrder() { + var lock1 = mock(Lock.class, "lock1"); + var lock2 = mock(Lock.class, "lock2"); + + when(lock1.tryLock()).thenReturn(true); + when(lock2.tryLock()).thenReturn(true); + + new CompositeLock(anyResources(2), List.of(lock1, lock2)).tryAcquire(); + + var inOrder = inOrder(lock1, lock2); + inOrder.verify(lock1).tryLock(); + inOrder.verify(lock2).tryLock(); + } + @Test @SuppressWarnings("resource") void acquiresAllLocksInOrder() throws Exception { - var lock1 = mock(Lock.class); - var lock2 = mock(Lock.class); + var lock1 = mock(Lock.class, "lock1"); + var lock2 = mock(Lock.class, "lock2"); new CompositeLock(anyResources(2), List.of(lock1, lock2)).acquire(); @@ -46,8 +63,8 @@ void acquiresAllLocksInOrder() throws Exception { @Test @SuppressWarnings("resource") void releasesAllLocksInReverseOrder() throws Exception { - var lock1 = mock(Lock.class); - var lock2 = mock(Lock.class); + var lock1 = mock(Lock.class, "lock1"); + var lock2 = mock(Lock.class, "lock2"); new CompositeLock(anyResources(2), List.of(lock1, lock2)).acquire().close(); @@ -83,6 +100,25 @@ void releasesLocksInReverseOrderWhenInterruptedDuringAcquire() throws Exception verify(unavailableLock, never()).unlock(); } + @Test + @SuppressWarnings("resource") + void releasesLocksInReverseOrderOnUnsuccessfulAttempt() { + var firstLock = mock(Lock.class, "firstLock"); + var secondLock = mock(Lock.class, "secondLock"); + var unavailableLock = mock(Lock.class, "unavailableLock"); + + when(firstLock.tryLock()).thenReturn(true); + when(secondLock.tryLock()).thenReturn(true); + when(unavailableLock.tryLock()).thenReturn(false); + + new CompositeLock(anyResources(3), List.of(firstLock, secondLock, unavailableLock)).tryAcquire(); + + var inOrder = inOrder(firstLock, secondLock); + inOrder.verify(secondLock).unlock(); + inOrder.verify(firstLock).unlock(); + verify(unavailableLock, never()).unlock(); + } + private Lock mockLock(String name, Executable lockAction) throws InterruptedException { var lock = mock(Lock.class, name); doAnswer(invocation -> { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java index d988d151435c..8237ea65d308 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/SingleLockTests.java @@ -43,6 +43,20 @@ void release() throws Exception { assertFalse(lock.isLocked()); } + @Test + @SuppressWarnings("resource") + void tryAcquireAndRelease() { + var lock = new ReentrantLock(); + + var singleLock = new SingleLock(anyResource(), lock); + + singleLock.tryAcquire(); + assertTrue(lock.isLocked()); + + singleLock.release(); + assertFalse(lock.isLocked()); + } + private static ExclusiveResource anyResource() { return new ExclusiveResource("key", LockMode.READ); } From bb835e19eda904e2948628862f635afddafdac7f Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 13:29:50 +0200 Subject: [PATCH 012/120] Acquire resource locks for tasks --- ...urrentHierarchicalTestExecutorService.java | 51 ++++++++++++-- ...tHierarchicalTestExecutorServiceTests.java | 67 ++++++++++++++++--- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 86d26899297e..ba5637088d56 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -13,6 +13,7 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; @@ -124,7 +125,11 @@ private List> stealWork(List queueEntries) for (var entry = iterator.previous(); iterator.hasPrevious(); entry = iterator.previous()) { var claimed = workQueue.remove(entry); if (claimed) { - entry.execute(); + var executed = entry.tryExecute(); + if (!executed) { + workQueue.add(entry); + futures.add(entry.future); + } } else { futures.add(entry.future); @@ -206,10 +211,13 @@ private static class WorkQueue { private final BlockingQueue queue = new LinkedBlockingQueue<>(); Entry add(TestTask task) { - var entry = new Entry(task, new CompletableFuture<>()); + return add(new Entry(task, new CompletableFuture<>())); + } + + Entry add(Entry entry) { var added = queue.add(entry); if (!added) { - throw new IllegalStateException("Could not add entry to the queue for task: " + task); + throw new IllegalStateException("Could not add entry to the queue for task: " + entry.task); } return entry; } @@ -235,12 +243,47 @@ void execute() { future.complete(null); } } + + boolean tryExecute() { + try { + var executed = tryExecuteTask(task); + if (executed) { + future.complete(null); + } + return executed; + } + catch (Throwable t) { + future.completeExceptionally(t); + return true; + } + } } } + @SuppressWarnings("try") private static void executeTask(TestTask testTask) { - testTask.execute(); + var executed = tryExecuteTask(testTask); + if (!executed) { + // TODO start another worker to compensate? + try (var ignored = testTask.getResourceLock().acquire()) { + testTask.execute(); + } + catch (InterruptedException ex) { + throw throwAsUncheckedException(ex); + } + } + } + + private static boolean tryExecuteTask(TestTask testTask) { + var resourceLock = testTask.getResourceLock(); + if (resourceLock.tryAcquire()) { + try (resourceLock) { + testTask.execute(); + return true; + } + } + return false; } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 0003d9eaf566..5eec3572336e 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -15,6 +15,13 @@ import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; import java.net.URL; import java.net.URLClassLoader; @@ -49,7 +56,7 @@ class ConcurrentHierarchicalTestExecutorServiceTests { @EnumSource(ExecutionMode.class) void executesSingleTask(ExecutionMode executionMode) throws Exception { - TestTaskStub<@Nullable Object> task = TestTaskStub.withoutResult(executionMode); + var task = TestTaskStub.withoutResult(executionMode); var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); try (customClassLoader) { @@ -80,7 +87,7 @@ void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode c service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); var child = TestTaskStub.withoutResult(childExecutionMode); - var root = new TestTaskStub<@Nullable Void>(CONCURRENT, + var root = new TestTaskStub<>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(List.of(child)))); service.submit(root).get(); @@ -101,7 +108,7 @@ void executesTwoChildrenConcurrently() throws Exception { }; var children = List.of(new TestTaskStub<>(CONCURRENT, behavior), new TestTaskStub<>(CONCURRENT, behavior)); - var root = new TestTaskStub<@Nullable Void>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); + var root = new TestTaskStub<>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); service.submit(root).get(); @@ -114,7 +121,7 @@ void executesTwoChildrenInSameThread() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); var children = List.of(TestTaskStub.withoutResult(SAME_THREAD), TestTaskStub.withoutResult(SAME_THREAD)); - var root = new TestTaskStub<@Nullable Void>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); + var root = new TestTaskStub<>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); service.submit(root).get(); @@ -122,6 +129,44 @@ void executesTwoChildrenInSameThread() throws Exception { assertThat(children).extracting(TestTaskStub::executionThread).containsOnly(root.executionThread()); } + @Test + void acquiresResourceLockForRootTask() throws Exception { + var resourceLock = mock(ResourceLock.class); + when(resourceLock.acquire()).thenReturn(resourceLock); + + var task = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); + + service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); + service.submit(task).get(); + + var inOrder = inOrder(resourceLock); + inOrder.verify(resourceLock).acquire(); + inOrder.verify(resourceLock).close(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + @SuppressWarnings("NullAway") + void acquiresResourceLockForChildTasks() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + + var resourceLock = mock(ResourceLock.class); + when(resourceLock.tryAcquire()).thenReturn(true, false); + when(resourceLock.acquire()).thenReturn(resourceLock); + + var child1 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); + var child2 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); + var children = List.of(child1, child2); + var task = new TestTaskStub<>(SAME_THREAD, Behavior.ofVoid(() -> service.invokeAll(children))); + + service.submit(task).get(); + + verify(resourceLock, atLeast(2)).tryAcquire(); + verify(resourceLock).acquire(); + verify(resourceLock, times(2)).close(); + verifyNoMoreInteractions(resourceLock); + } + private static ParallelExecutionConfiguration configuration(int parallelism) { return new DefaultParallelExecutionConfiguration(parallelism, parallelism, parallelism, parallelism, 0, __ -> true); @@ -132,22 +177,22 @@ private static final class TestTaskStub implements T private final ExecutionMode executionMode; private final Behavior behavior; - private final ResourceLock resourceLock; + private ResourceLock resourceLock = NopLock.INSTANCE; private @Nullable Thread executionThread; private final CompletableFuture<@Nullable T> result = new CompletableFuture<>(); - static TestTaskStub<@Nullable T> withoutResult(ExecutionMode executionMode) { - return new TestTaskStub<@Nullable T>(executionMode, () -> null); + static TestTaskStub withoutResult(ExecutionMode executionMode) { + return new TestTaskStub<@Nullable Void>(executionMode, () -> null); } TestTaskStub(ExecutionMode executionMode, Behavior behavior) { - this(executionMode, behavior, NopLock.INSTANCE); - } - - TestTaskStub(ExecutionMode executionMode, Behavior behavior, ResourceLock resourceLock) { this.executionMode = executionMode; this.behavior = behavior; + } + + TestTaskStub withResourceLock(ResourceLock resourceLock) { this.resourceLock = resourceLock; + return this; } @Override From 67db7bafb3a02101f140aca3c92bf4835d4bea01 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 14:16:49 +0200 Subject: [PATCH 013/120] Polish tests --- ...tHierarchicalTestExecutorServiceTests.java | 69 +++++++++++-------- 1 file changed, 40 insertions(+), 29 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 5eec3572336e..1caa5f291507 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -10,6 +10,8 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.Objects.requireNonNull; +import static java.util.function.Predicate.isEqual; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; @@ -36,6 +38,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.api.function.ThrowingSupplier; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.commons.util.Preconditions; @@ -45,6 +48,7 @@ /** * @since 6.1 */ +@SuppressWarnings("resource") @Timeout(5) class ConcurrentHierarchicalTestExecutorServiceTests { @@ -70,25 +74,22 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { } @Test - @SuppressWarnings("NullAway") void invokeAllMustBeExecutedFromWithinThreadPool() { var tasks = List.of(TestTaskStub.withoutResult(CONCURRENT)); service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); - assertPreconditionViolationFor(() -> service.invokeAll(tasks)) // + assertPreconditionViolationFor(() -> requiredService().invokeAll(tasks)) // .withMessage("invokeAll() must not be called from a thread that is not part of this executor"); } @ParameterizedTest @EnumSource(ExecutionMode.class) - @SuppressWarnings("NullAway") void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode childExecutionMode) throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); var child = TestTaskStub.withoutResult(childExecutionMode); - var root = new TestTaskStub<>(CONCURRENT, - Behavior.ofVoid(() -> service.invokeAll(List.of(child)))); + var root = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(List.of(child))); service.submit(root).get(); @@ -97,18 +98,18 @@ void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode c } @Test - @SuppressWarnings("NullAway") void executesTwoChildrenConcurrently() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); var latch = new CountDownLatch(2); - Behavior behavior = () -> { + ThrowingSupplier behavior = () -> { latch.countDown(); return latch.await(100, TimeUnit.MILLISECONDS); }; - var children = List.of(new TestTaskStub<>(CONCURRENT, behavior), new TestTaskStub<>(CONCURRENT, behavior)); - var root = new TestTaskStub<>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); + var children = List.of(TestTaskStub.withResult(CONCURRENT, behavior), + TestTaskStub.withResult(CONCURRENT, behavior)); + var root = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(children)); service.submit(root).get(); @@ -116,12 +117,11 @@ void executesTwoChildrenConcurrently() throws Exception { } @Test - @SuppressWarnings("NullAway") void executesTwoChildrenInSameThread() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); var children = List.of(TestTaskStub.withoutResult(SAME_THREAD), TestTaskStub.withoutResult(SAME_THREAD)); - var root = new TestTaskStub<>(CONCURRENT, Behavior.ofVoid(() -> service.invokeAll(children))); + var root = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(children)); service.submit(root).get(); @@ -139,6 +139,8 @@ void acquiresResourceLockForRootTask() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); service.submit(task).get(); + assertThat(task.executionThread()).isNotNull(); + var inOrder = inOrder(resourceLock); inOrder.verify(resourceLock).acquire(); inOrder.verify(resourceLock).close(); @@ -146,7 +148,6 @@ void acquiresResourceLockForRootTask() throws Exception { } @Test - @SuppressWarnings("NullAway") void acquiresResourceLockForChildTasks() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); @@ -157,9 +158,14 @@ void acquiresResourceLockForChildTasks() throws Exception { var child1 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); var child2 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); var children = List.of(child1, child2); - var task = new TestTaskStub<>(SAME_THREAD, Behavior.ofVoid(() -> service.invokeAll(children))); + var root = TestTaskStub.withoutResult(SAME_THREAD, () -> requiredService().invokeAll(children)); - service.submit(task).get(); + service.submit(root).get(); + + assertThat(root.executionThread()).isNotNull(); + assertThat(children).extracting(TestTaskStub::executionThread) // + .doesNotContainNull() // + .filteredOn(isEqual(root.executionThread())).hasSizeLessThan(2); verify(resourceLock, atLeast(2)).tryAcquire(); verify(resourceLock).acquire(); @@ -167,6 +173,10 @@ void acquiresResourceLockForChildTasks() throws Exception { verifyNoMoreInteractions(resourceLock); } + private ConcurrentHierarchicalTestExecutorService requiredService() { + return requireNonNull(service); + } + private static ParallelExecutionConfiguration configuration(int parallelism) { return new DefaultParallelExecutionConfiguration(parallelism, parallelism, parallelism, parallelism, 0, __ -> true); @@ -176,7 +186,7 @@ private static ParallelExecutionConfiguration configuration(int parallelism) { private static final class TestTaskStub implements TestTask { private final ExecutionMode executionMode; - private final Behavior behavior; + private final ThrowingSupplier behavior; private ResourceLock resourceLock = NopLock.INSTANCE; private @Nullable Thread executionThread; private final CompletableFuture<@Nullable T> result = new CompletableFuture<>(); @@ -185,7 +195,19 @@ static TestTaskStub withoutResult(ExecutionMode executionMode) { return new TestTaskStub<@Nullable Void>(executionMode, () -> null); } - TestTaskStub(ExecutionMode executionMode, Behavior behavior) { + static TestTaskStub withoutResult(ExecutionMode executionMode, Executable executable) { + return new TestTaskStub<@Nullable Void>(executionMode, () -> { + executable.execute(); + return null; + }); + } + + @SuppressWarnings({ "SameParameterValue", "DataFlowIssue" }) + static TestTaskStub withResult(ExecutionMode executionMode, ThrowingSupplier supplier) { + return new TestTaskStub<>(executionMode, supplier::get); + } + + TestTaskStub(ExecutionMode executionMode, ThrowingSupplier behavior) { this.executionMode = executionMode; this.behavior = behavior; } @@ -211,7 +233,7 @@ public void execute() { executionThread = Thread.currentThread(); try { - result.complete(behavior.execute()); + result.complete(behavior.get()); } catch (Throwable t) { result.completeExceptionally(t); @@ -227,18 +249,7 @@ public T result() { Preconditions.condition(result.isDone(), "task was not executed"); return result.getNow(null); } - } - - @FunctionalInterface - interface Behavior { - - static Behavior<@Nullable Void> ofVoid(Executable executable) { - return () -> { - executable.execute(); - return null; - }; - } - T execute() throws Throwable; } + } From 4a231aad113a7ac08560093af16969f17c68533c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 14:30:19 +0200 Subject: [PATCH 014/120] Fix race condition --- .../ConcurrentHierarchicalTestExecutorService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index ba5637088d56..36d7f6ef942f 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -85,8 +85,9 @@ public void invokeAll(List testTasks) { private WorkQueue.Entry enqueue(TestTask testTask) { // TODO check if worker needs to be started + var entry = workQueue.add(testTask); startWorker(); - return workQueue.add(testTask); + return entry; } private void startWorker() { From 2dc1f3f7af54a81130cee3dbbc3ce89301f3aefe Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 9 Oct 2025 16:27:02 +0200 Subject: [PATCH 015/120] Change thread pool configuration to achieve more parallelism --- ...urrentHierarchicalTestExecutorService.java | 31 ++-- ...tHierarchicalTestExecutorServiceTests.java | 142 ++++++++++++++++-- 2 files changed, 145 insertions(+), 28 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 36d7f6ef942f..69e2173e6465 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -24,6 +24,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; @@ -49,7 +50,7 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { ThreadFactory threadFactory = new CustomThreadFactory(classLoader); threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), - configuration.getKeepAliveSeconds(), SECONDS, new LinkedBlockingQueue<>(), threadFactory); + configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); } @Override @@ -101,7 +102,7 @@ private void startWorker() { // nothing to do -> done break; } - entry.execute(); + executeEntry(entry); } catch (InterruptedException ignore) { // ignore spurious interrupts @@ -233,18 +234,6 @@ boolean remove(Entry entry) { } private record Entry(TestTask task, CompletableFuture<@Nullable Void> future) { - void execute() { - try { - executeTask(task); - } - catch (Throwable t) { - future.completeExceptionally(t); - } - finally { - future.complete(null); - } - } - boolean tryExecute() { try { var executed = tryExecuteTask(task); @@ -262,8 +251,20 @@ boolean tryExecute() { } + private void executeEntry(WorkQueue.Entry entry) { + try { + executeTask(entry.task); + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + } + finally { + entry.future.complete(null); + } + } + @SuppressWarnings("try") - private static void executeTask(TestTask testTask) { + private void executeTask(TestTask testTask) { var executed = tryExecuteTask(testTask); if (!executed) { // TODO start another worker to compensate? diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 1caa5f291507..cd71ff1d1da8 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -10,8 +10,12 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.Comparator.comparing; +import static java.util.Comparator.comparingLong; import static java.util.Objects.requireNonNull; import static java.util.function.Predicate.isEqual; +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.toList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; @@ -27,10 +31,16 @@ import java.net.URL; import java.net.URLClassLoader; +import java.time.Instant; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.ToIntFunction; +import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -173,12 +183,50 @@ void acquiresResourceLockForChildTasks() throws Exception { verifyNoMoreInteractions(resourceLock); } + @Test + void runsTasksWithoutConflictingLocksConcurrently() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(3)); + + var resourceLock = new SingleLock(exclusiveResource(), new ReentrantLock()); + + var latch = new CountDownLatch(3); + ThrowingSupplier behavior = () -> { + latch.countDown(); + return latch.await(100, TimeUnit.MILLISECONDS); + }; + var child1 = TestTaskStub.withResult(CONCURRENT, behavior).withResourceLock(resourceLock).withName("child1"); + var child2 = TestTaskStub.withoutResult(SAME_THREAD).withResourceLock(resourceLock).withName("child2"); + var leaf1 = TestTaskStub.withResult(CONCURRENT, behavior).withName("leaf1"); + var leaf2 = TestTaskStub.withResult(CONCURRENT, behavior).withName("leaf2"); + var leafs = List.of(leaf1, leaf2); + var child3 = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(leafs)).withName( + "child3"); + var children = List.of(child1, child2, child3); + var root = TestTaskStub.withoutResult(SAME_THREAD, () -> requiredService().invokeAll(children)).withName( + "root"); + + service.submit(root).get(); + + printTimeline(Stream.concat(Stream.of(root), Stream.concat(children.stream(), leafs.stream()))); + + assertThat(root.executionThread()).isNotNull(); + assertThat(children).extracting(TestTaskStub::executionThread).doesNotContainNull(); + assertThat(leafs).extracting(TestTaskStub::executionThread).doesNotContainNull(); + assertThat(Stream.concat(Stream.of(child1), leafs.stream())).extracting(TestTaskStub::result) // + .containsOnly( + true); + } + + private static ExclusiveResource exclusiveResource() { + return new ExclusiveResource("key", ExclusiveResource.LockMode.READ_WRITE); + } + private ConcurrentHierarchicalTestExecutorService requiredService() { return requireNonNull(service); } private static ParallelExecutionConfiguration configuration(int parallelism) { - return new DefaultParallelExecutionConfiguration(parallelism, parallelism, parallelism, parallelism, 0, + return new DefaultParallelExecutionConfiguration(parallelism, parallelism, 256 + parallelism, parallelism, 0, __ -> true); } @@ -187,9 +235,14 @@ private static final class TestTaskStub implements T private final ExecutionMode executionMode; private final ThrowingSupplier behavior; + private ResourceLock resourceLock = NopLock.INSTANCE; - private @Nullable Thread executionThread; + private @Nullable String name; + private final CompletableFuture<@Nullable T> result = new CompletableFuture<>(); + private @Nullable Instant startTime; + private @Nullable Instant endTime; + private @Nullable Thread executionThread; static TestTaskStub withoutResult(ExecutionMode executionMode) { return new TestTaskStub<@Nullable Void>(executionMode, () -> null); @@ -202,9 +255,9 @@ static TestTaskStub withoutResult(ExecutionMode executionMode, Executable exe }); } - @SuppressWarnings({ "SameParameterValue", "DataFlowIssue" }) + @SuppressWarnings("SameParameterValue") static TestTaskStub withResult(ExecutionMode executionMode, ThrowingSupplier supplier) { - return new TestTaskStub<>(executionMode, supplier::get); + return new TestTaskStub<>(executionMode, supplier); } TestTaskStub(ExecutionMode executionMode, ThrowingSupplier behavior) { @@ -229,27 +282,90 @@ public ResourceLock getResourceLock() { @Override public void execute() { - Preconditions.condition(!result.isDone(), "task was already executed"); - - executionThread = Thread.currentThread(); + startTime = Instant.now(); try { - result.complete(behavior.get()); + Preconditions.condition(!result.isDone(), "task was already executed"); + + executionThread = Thread.currentThread(); + try { + result.complete(behavior.get()); + } + catch (Throwable t) { + result.completeExceptionally(t); + throw throwAsUncheckedException(t); + } } - catch (Throwable t) { - result.completeExceptionally(t); - throw throwAsUncheckedException(t); + finally { + endTime = Instant.now(); } } - public @Nullable Thread executionThread() { + @Nullable Thread executionThread() { return executionThread; } - public T result() { + T result() { Preconditions.condition(result.isDone(), "task was not executed"); return result.getNow(null); } + TestTaskStub withName(String name) { + this.name = name; + return this; + } + } + + static void printTimeline(Stream> taskStream) { + var allTasks = taskStream.toList(); + assertThat(allTasks.stream().filter(task -> task.executionThread() == null)) // + .describedAs( "Unexecuted tasks").isEmpty(); + var statistics = allTasks.stream() // + .flatMap(task -> Stream.concat(Stream.of(task.startTime), + Optional.ofNullable(task.endTime).stream())).mapToLong( + instant -> requireNonNull(instant).toEpochMilli()) // + .summaryStatistics(); + var rangeMillis = statistics.getMax() - statistics.getMin(); + var width = 100; + var scale = (double) width / rangeMillis; + var sortedTasks = allTasks.stream() // + .sorted(comparing(task -> requireNonNull(task.startTime))) // + .toList(); + var tasksByThread = sortedTasks.stream() // + .sorted(comparingLong( + testTaskStub -> requireNonNull(testTaskStub.executionThread()).threadId())).collect( + groupingBy(task -> requireNonNull(task.executionThread), LinkedHashMap::new, toList())); + ToIntFunction<@Nullable Instant> indexFunction = instant -> (int) ((requireNonNull( + instant).toEpochMilli() - statistics.getMin()) * scale); + tasksByThread.forEach((thread, tasks) -> printTimelineForThread(requireNonNull(thread), tasks, width, indexFunction)); + } + + private static void printTimelineForThread(Thread thread, List> tasks, int width, ToIntFunction<@Nullable Instant> indexFunction) { + System.out.printf("%n%s (%d)%n", thread.getName(), tasks.size()); + StringBuilder builder = new StringBuilder(); + for (var task : tasks) { + builder.append(".".repeat(width + 1)); + int startIndex = indexFunction.applyAsInt(task.startTime); + builder.setCharAt(startIndex, '<'); + if (task.endTime == null) { + builder.setCharAt(startIndex + 1, '-'); + builder.setCharAt(startIndex + 2, '?'); + } + else { + int endIndex = indexFunction.applyAsInt(task.endTime); + if (endIndex == startIndex) { + builder.setCharAt(endIndex, 'O'); + } + else { + for (int i = startIndex + 1; i < endIndex; i++) { + builder.setCharAt(i, '-'); + } + builder.setCharAt(endIndex, '>'); + } + } + builder.append(" ").append(task.name); + System.out.println(builder); + builder.setLength(0); + } } } From b8697064b154963f99c0796ace457b77f5fbeb5c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Oct 2025 14:42:37 +0200 Subject: [PATCH 016/120] Polishing --- ...urrentHierarchicalTestExecutorService.java | 109 +++++++++--------- 1 file changed, 55 insertions(+), 54 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 69e2173e6465..307d1b8679cb 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -53,6 +53,11 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); } + @Override + public void close() { + threadPool.shutdownNow(); + } + @Override public Future<@Nullable Void> submit(TestTask testTask) { return enqueue(testTask).future(); @@ -127,7 +132,7 @@ private List> stealWork(List queueEntries) for (var entry = iterator.previous(); iterator.hasPrevious(); entry = iterator.previous()) { var claimed = workQueue.remove(entry); if (claimed) { - var executed = entry.tryExecute(); + var executed = tryExecute(entry); if (!executed) { workQueue.add(entry); futures.add(entry.future); @@ -165,9 +170,55 @@ private void executeAll(@Nullable List children) { } } - @Override - public void close() { - threadPool.shutdownNow(); + private static boolean tryExecute(WorkQueue.Entry entry) { + try { + var executed = tryExecuteTask(entry.task); + if (executed) { + entry.future.complete(null); + } + return executed; + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + return true; + } + } + + private void executeEntry(WorkQueue.Entry entry) { + try { + executeTask(entry.task); + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + } + finally { + entry.future.complete(null); + } + } + + @SuppressWarnings("try") + private void executeTask(TestTask testTask) { + var executed = tryExecuteTask(testTask); + if (!executed) { + // TODO start another worker to compensate? + try (var ignored = testTask.getResourceLock().acquire()) { + testTask.execute(); + } + catch (InterruptedException ex) { + throw throwAsUncheckedException(ex); + } + } + } + + private static boolean tryExecuteTask(TestTask testTask) { + var resourceLock = testTask.getResourceLock(); + if (resourceLock.tryAcquire()) { + try (resourceLock) { + testTask.execute(); + return true; + } + } + return false; } private class CustomThreadFactory implements ThreadFactory { @@ -234,58 +285,8 @@ boolean remove(Entry entry) { } private record Entry(TestTask task, CompletableFuture<@Nullable Void> future) { - boolean tryExecute() { - try { - var executed = tryExecuteTask(task); - if (executed) { - future.complete(null); - } - return executed; - } - catch (Throwable t) { - future.completeExceptionally(t); - return true; - } - } - } - - } - - private void executeEntry(WorkQueue.Entry entry) { - try { - executeTask(entry.task); - } - catch (Throwable t) { - entry.future.completeExceptionally(t); - } - finally { - entry.future.complete(null); - } - } - - @SuppressWarnings("try") - private void executeTask(TestTask testTask) { - var executed = tryExecuteTask(testTask); - if (!executed) { - // TODO start another worker to compensate? - try (var ignored = testTask.getResourceLock().acquire()) { - testTask.execute(); - } - catch (InterruptedException ex) { - throw throwAsUncheckedException(ex); - } } - } - private static boolean tryExecuteTask(TestTask testTask) { - var resourceLock = testTask.getResourceLock(); - if (resourceLock.tryAcquire()) { - try (resourceLock) { - testTask.execute(); - return true; - } - } - return false; } } From f70433b6379bb50b88ea3102f0a8b616aa1a2d65 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Oct 2025 16:23:38 +0200 Subject: [PATCH 017/120] Introduce worker leases to limit parallelism --- ...urrentHierarchicalTestExecutorService.java | 271 +++++++++++++++--- ...tHierarchicalTestExecutorServiceTests.java | 44 +-- 2 files changed, 254 insertions(+), 61 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 307d1b8679cb..d0c5002f9fd3 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -10,10 +10,10 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.Objects.requireNonNull; import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; -import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; @@ -24,13 +24,18 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; +import org.junit.platform.commons.logging.Logger; +import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; @@ -40,8 +45,11 @@ @API(status = EXPERIMENTAL, since = "6.1") public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + private static final Logger LOGGER = LoggerFactory.getLogger(ConcurrentHierarchicalTestExecutorService.class); + private final WorkQueue workQueue = new WorkQueue(); private final ExecutorService threadPool; + private final WorkerLeaseManager workerLeaseManager; public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); @@ -51,6 +59,7 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration ThreadFactory threadFactory = new CustomThreadFactory(classLoader); threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); + workerLeaseManager = new WorkerLeaseManager(configuration.getParallelism()); } @Override @@ -60,13 +69,16 @@ public void close() { @Override public Future<@Nullable Void> submit(TestTask testTask) { + LOGGER.trace(() -> "submit: " + testTask); return enqueue(testTask).future(); } @Override public void invokeAll(List testTasks) { + LOGGER.trace(() -> "invokeAll: " + testTasks); - Preconditions.condition(WorkerThread.getExecutor() == this, + var workerThread = WorkerThread.get(); + Preconditions.condition(workerThread != null && workerThread.executor() == this, "invokeAll() must not be called from a thread that is not part of this executor"); if (testTasks.isEmpty()) { @@ -81,68 +93,83 @@ public void invokeAll(List testTasks) { var childrenByExecutionMode = testTasks.stream().collect(groupingBy(TestTask::getExecutionMode)); var queueEntries = forkAll(childrenByExecutionMode.get(CONCURRENT)); executeAll(childrenByExecutionMode.get(SAME_THREAD)); - var concurrentlyExecutedChildren = stealWork(queueEntries); - if (!concurrentlyExecutedChildren.isEmpty()) { - // TODO give up worker lease - toCompletableFuture(concurrentlyExecutedChildren).join(); - // TODO get worker lease + var remainingForkedChildren = stealWork(queueEntries); + waitFor(remainingForkedChildren); + } + + private static void waitFor(List children) { + if (children.isEmpty()) { + return; + } + var future = toCombinedFuture(children); + try { + if (future.isDone()) { + // no need to release worker lease + future.join(); + } + else { + WorkerThread.getOrThrow().runBlocking(() -> { + LOGGER.trace(() -> "blocking for forked children: " + children); + return future.join(); + }); + } + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } private WorkQueue.Entry enqueue(TestTask testTask) { - // TODO check if worker needs to be started var entry = workQueue.add(testTask); - startWorker(); + maybeStartWorker(); return entry; } - private void startWorker() { - threadPool.execute(() -> { - while (!threadPool.isShutdown()) { - try { - // TODO get worker lease - var entry = workQueue.poll(); - if (entry == null) { - // TODO give up worker lease - // nothing to do -> done - break; - } - executeEntry(entry); - } - catch (InterruptedException ignore) { - // ignore spurious interrupts - } + private void maybeStartWorker() { + if (threadPool.isShutdown() || !workerLeaseManager.isLeaseAvailable() || workQueue.isEmpty()) { + return; + } + try { + threadPool.execute(() -> WorkerThread.getOrThrow().processQueueEntries()); + } + catch (RejectedExecutionException e) { + if (threadPool.isShutdown()) { + return; } - }); + throw e; + } } - private static CompletableFuture toCompletableFuture(List> futures) { - if (futures.size() == 1) { - return futures.get(0); + private static CompletableFuture toCombinedFuture(List entries) { + if (entries.size() == 1) { + return entries.get(0).future(); } - return CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)); + var futures = entries.stream().map(WorkQueue.Entry::future).toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); } - private List> stealWork(List queueEntries) { + private List stealWork(List queueEntries) { if (queueEntries.isEmpty()) { return List.of(); } - List> futures = new ArrayList<>(queueEntries.size()); + List concurrentlyExecutedChildren = new ArrayList<>(queueEntries.size()); var iterator = queueEntries.listIterator(queueEntries.size()); - for (var entry = iterator.previous(); iterator.hasPrevious(); entry = iterator.previous()) { + while (iterator.hasPrevious()) { + var entry = iterator.previous(); var claimed = workQueue.remove(entry); if (claimed) { + LOGGER.trace(() -> "stole work: " + entry); var executed = tryExecute(entry); if (!executed) { workQueue.add(entry); - futures.add(entry.future); + concurrentlyExecutedChildren.add(entry); } } else { - futures.add(entry.future); + concurrentlyExecutedChildren.add(entry); } } - return futures; + return concurrentlyExecutedChildren; } private List forkAll(@Nullable List children) { @@ -161,6 +188,7 @@ private void executeAll(@Nullable List children) { if (children == null) { return; } + LOGGER.trace(() -> "Running SAME_THREAD children: " + children); if (children.size() == 1) { executeTask(children.get(0)); return; @@ -200,12 +228,16 @@ private void executeEntry(WorkQueue.Entry entry) { private void executeTask(TestTask testTask) { var executed = tryExecuteTask(testTask); if (!executed) { - // TODO start another worker to compensate? - try (var ignored = testTask.getResourceLock().acquire()) { - testTask.execute(); + var resourceLock = testTask.getResourceLock(); + var workerThread = WorkerThread.getOrThrow(); + try (var ignored = workerThread.runBlocking(() -> { + LOGGER.trace(() -> "blocking for resource lock: " + resourceLock); + return resourceLock.acquire(); + })) { + doExecute(testTask); } catch (InterruptedException ex) { - throw throwAsUncheckedException(ex); + Thread.currentThread().interrupt(); } } } @@ -214,13 +246,23 @@ private static boolean tryExecuteTask(TestTask testTask) { var resourceLock = testTask.getResourceLock(); if (resourceLock.tryAcquire()) { try (resourceLock) { - testTask.execute(); + doExecute(testTask); return true; } } return false; } + private static void doExecute(TestTask testTask) { + LOGGER.trace(() -> "executing: " + testTask); + try { + testTask.execute(); + } + finally { + LOGGER.trace(() -> "finished executing: " + testTask); + } + } + private class CustomThreadFactory implements ThreadFactory { private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); @@ -245,12 +287,72 @@ public Thread newThread(Runnable runnable) { private class WorkerThread extends Thread { + @Nullable + WorkerLease workerLease; + WorkerThread(Runnable runnable, String name) { super(runnable, name); } - static @Nullable ConcurrentHierarchicalTestExecutorService getExecutor() { - return Thread.currentThread() instanceof WorkerThread c ? c.executor() : null; + static @Nullable WorkerThread get() { + if (Thread.currentThread() instanceof WorkerThread workerThread) { + return workerThread; + } + return null; + } + + static WorkerThread getOrThrow() { + var workerThread = get(); + if (workerThread == null) { + throw new IllegalStateException("Not on a worker thread"); + } + return workerThread; + } + + void processQueueEntries() { + while (!threadPool.isShutdown()) { + try { + var entry = workQueue.poll(); + if (entry == null) { + break; + } + LOGGER.trace(() -> "processing: " + entry); + workerLease = workerLeaseManager.tryAcquire(); + if (workerLease == null) { + workQueue.add(entry); + break; + } + try { + executeEntry(entry); + } + finally { + workerLease.release(); + } + } + catch (InterruptedException ignore) { + // ignore spurious interrupts + } + } + } + + T runBlocking(BlockingAction blockingAction) throws InterruptedException { + var workerLease = requireNonNull(this.workerLease); + workerLease.release(); + try { + return blockingAction.run(); + } + finally { + try { + workerLease.reacquire(); + } + catch (InterruptedException e) { + interrupt(); + } + } + } + + interface BlockingAction { + T run() throws InterruptedException; } private ConcurrentHierarchicalTestExecutorService executor() { @@ -268,6 +370,7 @@ Entry add(TestTask task) { } Entry add(Entry entry) { + LOGGER.trace(() -> "forking: " + entry); var added = queue.add(entry); if (!added) { throw new IllegalStateException("Could not add entry to the queue for task: " + entry.task); @@ -284,9 +387,91 @@ boolean remove(Entry entry) { return queue.remove(entry); } + boolean isEmpty() { + return queue.isEmpty(); + } + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future) { + @SuppressWarnings("FutureReturnValueIgnored") + Entry { + future.whenComplete((__, t) -> { + if (t == null) { + LOGGER.trace(() -> "completed normally: " + this.task()); + } + else { + LOGGER.trace(t, () -> "completed exceptionally: " + this.task()); + } + }); + } + } + + } + + private class WorkerLeaseManager { + + private final Semaphore semaphore; + + WorkerLeaseManager(int parallelism) { + semaphore = new Semaphore(parallelism); + } + + @Nullable + WorkerLease tryAcquire() { + try { + boolean acquired = semaphore.tryAcquire(1, SECONDS); + return acquired ? new WorkerLease(this::release) : null; + } + catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + + private ReacquisitionToken release() { + LOGGER.trace(() -> "releasing worker lease"); + semaphore.release(); + maybeStartWorker(); + return new ReacquisitionToken(); + } + + boolean isLeaseAvailable() { + return semaphore.availablePermits() > 0; + } + + private class ReacquisitionToken { + + private boolean used = false; + + void reacquire() throws InterruptedException { + Preconditions.condition(!used, "Lease was already reacquired"); + used = true; + LOGGER.trace(() -> "reacquiring worker lease"); + semaphore.acquire(); + } + } + } + + private static class WorkerLease { + + private final Supplier releaseAction; + private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; + + WorkerLease(Supplier releaseAction) { + LOGGER.trace(() -> "acquiring worker lease"); + this.releaseAction = releaseAction; + } + + void release() { + if (reacquisitionToken == null) { + reacquisitionToken = releaseAction.get(); + } } + void reacquire() throws InterruptedException { + Preconditions.notNull(reacquisitionToken, "Cannot reacquire an unreleased WorkerLease"); + reacquisitionToken.reacquire(); + reacquisitionToken = null; + } } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index cd71ff1d1da8..271a7185f07b 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -52,6 +52,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ToStringBuilder; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; @@ -165,10 +166,11 @@ void acquiresResourceLockForChildTasks() throws Exception { when(resourceLock.tryAcquire()).thenReturn(true, false); when(resourceLock.acquire()).thenReturn(resourceLock); - var child1 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); - var child2 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); + var child1 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock).withName("child1"); + var child2 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock).withName("child2"); var children = List.of(child1, child2); - var root = TestTaskStub.withoutResult(SAME_THREAD, () -> requiredService().invokeAll(children)); + var root = TestTaskStub.withoutResult(SAME_THREAD, () -> requiredService().invokeAll(children)).withName( + "root"); service.submit(root).get(); @@ -213,8 +215,7 @@ void runsTasksWithoutConflictingLocksConcurrently() throws Exception { assertThat(children).extracting(TestTaskStub::executionThread).doesNotContainNull(); assertThat(leafs).extracting(TestTaskStub::executionThread).doesNotContainNull(); assertThat(Stream.concat(Stream.of(child1), leafs.stream())).extracting(TestTaskStub::result) // - .containsOnly( - true); + .containsOnly(true); } private static ExclusiveResource exclusiveResource() { @@ -240,9 +241,9 @@ private static final class TestTaskStub implements T private @Nullable String name; private final CompletableFuture<@Nullable T> result = new CompletableFuture<>(); - private @Nullable Instant startTime; - private @Nullable Instant endTime; - private @Nullable Thread executionThread; + private volatile @Nullable Instant startTime; + private volatile @Nullable Instant endTime; + private volatile @Nullable Thread executionThread; static TestTaskStub withoutResult(ExecutionMode executionMode) { return new TestTaskStub<@Nullable Void>(executionMode, () -> null); @@ -300,7 +301,8 @@ public void execute() { } } - @Nullable Thread executionThread() { + @Nullable + Thread executionThread() { return executionThread; } @@ -313,15 +315,20 @@ TestTaskStub withName(String name) { this.name = name; return this; } + + @Override + public String toString() { + return "%s @ %s".formatted(new ToStringBuilder(this).append("name", name), Integer.toHexString(hashCode())); + } } static void printTimeline(Stream> taskStream) { var allTasks = taskStream.toList(); assertThat(allTasks.stream().filter(task -> task.executionThread() == null)) // - .describedAs( "Unexecuted tasks").isEmpty(); + .describedAs("Unexecuted tasks").isEmpty(); var statistics = allTasks.stream() // .flatMap(task -> Stream.concat(Stream.of(task.startTime), - Optional.ofNullable(task.endTime).stream())).mapToLong( + Optional.ofNullable(task.endTime).stream())).mapToLong( instant -> requireNonNull(instant).toEpochMilli()) // .summaryStatistics(); var rangeMillis = statistics.getMax() - statistics.getMin(); @@ -331,15 +338,16 @@ static void printTimeline(Stream> taskStream) { .sorted(comparing(task -> requireNonNull(task.startTime))) // .toList(); var tasksByThread = sortedTasks.stream() // - .sorted(comparingLong( - testTaskStub -> requireNonNull(testTaskStub.executionThread()).threadId())).collect( - groupingBy(task -> requireNonNull(task.executionThread), LinkedHashMap::new, toList())); - ToIntFunction<@Nullable Instant> indexFunction = instant -> (int) ((requireNonNull( - instant).toEpochMilli() - statistics.getMin()) * scale); - tasksByThread.forEach((thread, tasks) -> printTimelineForThread(requireNonNull(thread), tasks, width, indexFunction)); + .sorted(comparingLong(testTaskStub -> requireNonNull(testTaskStub.executionThread()).threadId())) // + .collect(groupingBy(task -> requireNonNull(task.executionThread), LinkedHashMap::new, toList())); + ToIntFunction<@Nullable Instant> indexFunction = instant -> (int) ((requireNonNull(instant).toEpochMilli() + - statistics.getMin()) * scale); + tasksByThread.forEach( + (thread, tasks) -> printTimelineForThread(requireNonNull(thread), tasks, width, indexFunction)); } - private static void printTimelineForThread(Thread thread, List> tasks, int width, ToIntFunction<@Nullable Instant> indexFunction) { + private static void printTimelineForThread(Thread thread, List> tasks, int width, + ToIntFunction<@Nullable Instant> indexFunction) { System.out.printf("%n%s (%d)%n", thread.getName(), tasks.size()); StringBuilder builder = new StringBuilder(); for (var task : tasks) { From ef2c49d3c3ac77b9ad8fdb3f3427c03e5f627ade Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Oct 2025 19:33:15 +0200 Subject: [PATCH 018/120] Add constructor needed by Jupiter --- .../ConcurrentHierarchicalTestExecutorService.java | 5 +++++ .../DefaultParallelExecutionConfigurationStrategy.java | 4 ++++ .../ForkJoinPoolHierarchicalTestExecutorService.java | 8 +------- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index d0c5002f9fd3..7005dbd2b403 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -38,6 +38,7 @@ import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.engine.ConfigurationParameters; /** * @since 6.1 @@ -51,6 +52,10 @@ public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTe private final ExecutorService threadPool; private final WorkerLeaseManager workerLeaseManager; + public ConcurrentHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { + this(DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters)); + } + public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java index 47e88a603deb..c3bbd0d15bf7 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DefaultParallelExecutionConfigurationStrategy.java @@ -216,6 +216,10 @@ public ParallelExecutionConfiguration createConfiguration(ConfigurationParameter */ public static final String CONFIG_CUSTOM_CLASS_PROPERTY_NAME = "custom.class"; + static ParallelExecutionConfiguration toConfiguration(ConfigurationParameters configurationParameters) { + return getStrategy(configurationParameters).createConfiguration(configurationParameters); + } + static ParallelExecutionConfigurationStrategy getStrategy(ConfigurationParameters configurationParameters) { return valueOf( configurationParameters.get(CONFIG_STRATEGY_PROPERTY_NAME).orElse("dynamic").toUpperCase(Locale.ROOT)); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java index 52bd2e05cf5b..5f58bc962951 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java @@ -60,7 +60,7 @@ public class ForkJoinPoolHierarchicalTestExecutorService implements Hierarchical * @see DefaultParallelExecutionConfigurationStrategy */ public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { - this(createConfiguration(configurationParameters)); + this(DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters)); } /** @@ -82,12 +82,6 @@ public ForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguratio LoggerFactory.getLogger(getClass()).config(() -> "Using ForkJoinPool with parallelism of " + parallelism); } - private static ParallelExecutionConfiguration createConfiguration(ConfigurationParameters configurationParameters) { - ParallelExecutionConfigurationStrategy strategy = DefaultParallelExecutionConfigurationStrategy.getStrategy( - configurationParameters); - return strategy.createConfiguration(configurationParameters); - } - private ForkJoinPool createForkJoinPool(ParallelExecutionConfiguration configuration) { try { return new ForkJoinPool(configuration.getParallelism(), new WorkerThreadFactory(), null, false, From ff783e992378a84324085881bfe1c45a8da59921 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 10 Oct 2025 20:24:04 +0200 Subject: [PATCH 019/120] Add support for blocking inside of worker thread --- .../hierarchical/BlockingAwareFuture.java | 62 +++++++++++++++++++ ...urrentHierarchicalTestExecutorService.java | 24 ++++++- .../hierarchical/DelegatingFuture.java | 53 ++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java create mode 100644 junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java new file mode 100644 index 000000000000..639e588995e7 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java @@ -0,0 +1,62 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; + +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.jspecify.annotations.Nullable; + +class BlockingAwareFuture extends DelegatingFuture { + + private final BlockHandler handler; + + BlockingAwareFuture(Future delegate, BlockHandler handler) { + super(delegate); + this.handler = handler; + } + + @Override + public T get() throws InterruptedException, ExecutionException { + if (isDone()) { + return delegate.get(); + } + return handle(delegate::get); + } + + @Override + public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + if (isDone()) { + return delegate.get(timeout, unit); + } + return handle(() -> delegate.get(timeout, unit)); + } + + private T handle(Callable callable) { + try { + return handler.handle(callable); + } + catch (Exception e) { + throw throwAsUncheckedException(e); + } + } + + interface BlockHandler { + + T handle(Callable callable) throws Exception; + + } +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 7005dbd2b403..e9c6020e6b8e 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -14,12 +14,14 @@ import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; import java.util.ArrayList; import java.util.List; import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; @@ -75,7 +77,7 @@ public void close() { @Override public Future<@Nullable Void> submit(TestTask testTask) { LOGGER.trace(() -> "submit: " + testTask); - return enqueue(testTask).future(); + return new BlockingAwareFuture<@Nullable Void>(enqueue(testTask).future(), WorkerThread.BlockHandler.INSTANCE); } @Override @@ -364,6 +366,26 @@ private ConcurrentHierarchicalTestExecutorService executor() { return ConcurrentHierarchicalTestExecutorService.this; } + private static class BlockHandler implements BlockingAwareFuture.BlockHandler { + + private static final BlockHandler INSTANCE = new BlockHandler(); + + @Override + public T handle(Callable callable) throws Exception { + var workerThread = get(); + if (workerThread == null) { + return callable.call(); + } + return workerThread.runBlocking(() -> { + try { + return callable.call(); + } + catch (Exception ex) { + throw throwAsUncheckedException(ex); + } + }); + } + } } private static class WorkQueue { diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java new file mode 100644 index 000000000000..30d17c7cdfbe --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java @@ -0,0 +1,53 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.jspecify.annotations.Nullable; + +class DelegatingFuture implements Future { + + protected final Future delegate; + + DelegatingFuture(Future delegate) { + this.delegate = delegate; + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return delegate.cancel(mayInterruptIfRunning); + } + + @Override + public boolean isCancelled() { + return delegate.isCancelled(); + } + + @Override + public boolean isDone() { + return delegate.isDone(); + } + + @Override + public T get() throws InterruptedException, ExecutionException { + return delegate.get(); + } + + @Override + public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + return delegate.get(timeout, unit); + } + +} From 752d565ad829f4d61fae7c4f74b0495f9dfd54ab Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 12 Oct 2025 15:21:08 +0200 Subject: [PATCH 020/120] Add support for submitting SAME_THREAD child tasks dynamically --- .../ConcurrentHierarchicalTestExecutorService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index e9c6020e6b8e..9d6b694247ac 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -11,6 +11,7 @@ package org.junit.platform.engine.support.hierarchical; import static java.util.Objects.requireNonNull; +import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; @@ -77,6 +78,13 @@ public void close() { @Override public Future<@Nullable Void> submit(TestTask testTask) { LOGGER.trace(() -> "submit: " + testTask); + if (WorkerThread.get() == null) { + return enqueue(testTask).future(); + } + if (testTask.getExecutionMode() == SAME_THREAD) { + executeTask(testTask); + return completedFuture(null); + } return new BlockingAwareFuture<@Nullable Void>(enqueue(testTask).future(), WorkerThread.BlockHandler.INSTANCE); } From 02233362d4248a6096fabca3d7d17d80d1242b42 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 12 Oct 2025 15:21:24 +0200 Subject: [PATCH 021/120] Run isolated tasks last to maximize parallelism --- ...urrentHierarchicalTestExecutorService.java | 53 +++++++++++++------ ...tHierarchicalTestExecutorServiceTests.java | 4 +- .../ParallelExecutionIntegrationTests.java | 2 +- 3 files changed, 40 insertions(+), 19 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 9d6b694247ac..9e8ab5acccea 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -13,10 +13,9 @@ import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.TimeUnit.SECONDS; -import static java.util.stream.Collectors.groupingBy; import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; -import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; +import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; import java.util.ArrayList; @@ -33,6 +32,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import java.util.function.Supplier; import org.apiguardian.api.API; @@ -105,11 +105,13 @@ public void invokeAll(List testTasks) { return; } - var childrenByExecutionMode = testTasks.stream().collect(groupingBy(TestTask::getExecutionMode)); - var queueEntries = forkAll(childrenByExecutionMode.get(CONCURRENT)); - executeAll(childrenByExecutionMode.get(SAME_THREAD)); + List isolatedTasks = new ArrayList<>(testTasks.size()); + List sameThreadTasks = new ArrayList<>(testTasks.size()); + var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks::add); + executeAll(sameThreadTasks); var remainingForkedChildren = stealWork(queueEntries); waitFor(remainingForkedChildren); + executeAll(isolatedTasks); } private static void waitFor(List children) { @@ -145,7 +147,15 @@ private void maybeStartWorker() { return; } try { - threadPool.execute(() -> WorkerThread.getOrThrow().processQueueEntries()); + threadPool.execute(() -> { + LOGGER.trace(() -> "starting worker"); + try { + WorkerThread.getOrThrow().processQueueEntries(); + } + finally { + LOGGER.trace(() -> "stopping worker"); + } + }); } catch (RejectedExecutionException e) { if (threadPool.isShutdown()) { @@ -187,20 +197,33 @@ private List stealWork(List queueEntries) { return concurrentlyExecutedChildren; } - private List forkAll(@Nullable List children) { - if (children == null) { + private List forkConcurrentChildren(List children, + Consumer isolatedTaskCollector, Consumer sameThreadTaskCollector) { + + if (children.isEmpty()) { return List.of(); } - if (children.size() == 1) { - return List.of(enqueue(children.get(0))); + List queueEntries = new ArrayList<>(children.size()); + for (TestTask child : children) { + if (requiresGlobalReadWriteLock(child)) { + isolatedTaskCollector.accept(child); + } + else if (child.getExecutionMode() == SAME_THREAD) { + sameThreadTaskCollector.accept(child); + } + else { + queueEntries.add(enqueue(child)); + } } - return children.stream() // - .map(ConcurrentHierarchicalTestExecutorService.this::enqueue) // - .toList(); + return queueEntries; } - private void executeAll(@Nullable List children) { - if (children == null) { + private static boolean requiresGlobalReadWriteLock(TestTask testTask) { + return testTask.getResourceLock().getResources().contains(GLOBAL_READ_WRITE); + } + + private void executeAll(List children) { + if (children.isEmpty()) { return; } LOGGER.trace(() -> "Running SAME_THREAD children: " + children); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 271a7185f07b..df0e246712ae 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -26,7 +26,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import java.net.URL; @@ -180,9 +179,8 @@ void acquiresResourceLockForChildTasks() throws Exception { .filteredOn(isEqual(root.executionThread())).hasSizeLessThan(2); verify(resourceLock, atLeast(2)).tryAcquire(); - verify(resourceLock).acquire(); + verify(resourceLock, atLeast(1)).acquire(); verify(resourceLock, times(2)).close(); - verifyNoMoreInteractions(resourceLock); } @Test diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index ddeaaf294906..79ac093c6e51 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -262,7 +262,7 @@ void canRunTestsIsolatedFromEachOtherAcrossClassesWithOtherResourceLocks() { void runsIsolatedTestsLastToMaximizeParallelism() { var configParams = Map.of( // DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent", // - PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME, "3" // + PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME, "4" // ); Class[] testClasses = { IsolatedTestCase.class, SuccessfulParallelTestCase.class }; var events = executeWithFixedParallelism(3, configParams, testClasses) // From 332abe1f55617cb392a4e1e1a4bbed3072a4139c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 12 Oct 2025 15:42:07 +0200 Subject: [PATCH 022/120] Polish logging --- .../ConcurrentHierarchicalTestExecutorService.java | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 9e8ab5acccea..95c66af22baa 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -354,7 +354,7 @@ void processQueueEntries() { if (entry == null) { break; } - LOGGER.trace(() -> "processing: " + entry); + LOGGER.trace(() -> "processing: " + entry.task); workerLease = workerLeaseManager.tryAcquire(); if (workerLease == null) { workQueue.add(entry); @@ -424,11 +424,16 @@ private static class WorkQueue { private final BlockingQueue queue = new LinkedBlockingQueue<>(); Entry add(TestTask task) { - return add(new Entry(task, new CompletableFuture<>())); + LOGGER.trace(() -> "forking: " + task); + return doAdd(new Entry(task, new CompletableFuture<>())); } - Entry add(Entry entry) { - LOGGER.trace(() -> "forking: " + entry); + void add(Entry entry) { + LOGGER.trace(() -> "re-enqueuing: " + entry.task); + doAdd(entry); + } + + private Entry doAdd(Entry entry) { var added = queue.add(entry); if (!added) { throw new IllegalStateException("Could not add entry to the queue for task: " + entry.task); From 2df6b28ef3eed65b1fd65f4894f1694597bfe946 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 12 Oct 2025 15:59:07 +0200 Subject: [PATCH 023/120] Stop workers sooner (without waiting for queue entries or worker lease) --- ...urrentHierarchicalTestExecutorService.java | 48 ++++++++----------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 95c66af22baa..ce2f146d493f 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -349,26 +349,21 @@ static WorkerThread getOrThrow() { void processQueueEntries() { while (!threadPool.isShutdown()) { + var entry = workQueue.poll(); + if (entry == null) { + break; + } + LOGGER.trace(() -> "processing: " + entry.task); + workerLease = workerLeaseManager.tryAcquire(); + if (workerLease == null) { + workQueue.add(entry); + break; + } try { - var entry = workQueue.poll(); - if (entry == null) { - break; - } - LOGGER.trace(() -> "processing: " + entry.task); - workerLease = workerLeaseManager.tryAcquire(); - if (workerLease == null) { - workQueue.add(entry); - break; - } - try { - executeEntry(entry); - } - finally { - workerLease.release(); - } + executeEntry(entry); } - catch (InterruptedException ignore) { - // ignore spurious interrupts + finally { + workerLease.release(); } } } @@ -407,6 +402,7 @@ public T handle(Callable callable) throws Exception { if (workerThread == null) { return callable.call(); } + LOGGER.trace(() -> "blocking for child task"); return workerThread.runBlocking(() -> { try { return callable.call(); @@ -442,8 +438,8 @@ private Entry doAdd(Entry entry) { } @Nullable - Entry poll() throws InterruptedException { - return queue.poll(1, SECONDS); + Entry poll() { + return queue.poll(); } boolean remove(Entry entry) { @@ -480,14 +476,8 @@ private class WorkerLeaseManager { @Nullable WorkerLease tryAcquire() { - try { - boolean acquired = semaphore.tryAcquire(1, SECONDS); - return acquired ? new WorkerLease(this::release) : null; - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return null; - } + boolean acquired = semaphore.tryAcquire(); + return acquired ? new WorkerLease(this::release) : null; } private ReacquisitionToken release() { @@ -520,7 +510,7 @@ private static class WorkerLease { private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; WorkerLease(Supplier releaseAction) { - LOGGER.trace(() -> "acquiring worker lease"); + LOGGER.trace(() -> "acquired worker lease"); this.releaseAction = releaseAction; } From c3032be5fd60ff60f7593204f6044353768380bf Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 12 Oct 2025 15:59:33 +0200 Subject: [PATCH 024/120] Use new implementation --- .../main/java/org/junit/jupiter/engine/JupiterTestEngine.java | 4 ++-- .../src/test/resources/junit-platform.properties | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 7394b7ca73bd..568dfff4b081 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -29,7 +29,7 @@ import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; @@ -79,7 +79,7 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return new ForkJoinPoolHierarchicalTestExecutorService(new PrefixedConfigurationParameters( + return new ConcurrentHierarchicalTestExecutorService(new PrefixedConfigurationParameters( request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); diff --git a/platform-tooling-support-tests/src/test/resources/junit-platform.properties b/platform-tooling-support-tests/src/test/resources/junit-platform.properties index d24bbed7d3d9..3d1d2700e7f0 100644 --- a/platform-tooling-support-tests/src/test/resources/junit-platform.properties +++ b/platform-tooling-support-tests/src/test/resources/junit-platform.properties @@ -2,7 +2,6 @@ junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=0.25 -junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor=1 junit.jupiter.testclass.order.default = \ org.junit.jupiter.api.ClassOrderer$OrderAnnotation From 5f3fd131af7fb8431e0fc92ddb7b92a89467c5b3 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 12 Oct 2025 16:07:29 +0200 Subject: [PATCH 025/120] Improve logging pattern * More precise clock * Fixed width thread names * Abbreviated package names --- documentation/src/test/resources/log4j2-test.xml | 2 +- junit-vintage-engine/src/test/resources/log4j2-test.xml | 2 +- jupiter-tests/src/test/resources/log4j2-test.xml | 2 +- platform-tests/src/test/resources/log4j2-test.xml | 2 +- .../src/test/resources/log4j2-test.xml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/documentation/src/test/resources/log4j2-test.xml b/documentation/src/test/resources/log4j2-test.xml index 85bbb36f213e..16e89e753e2d 100644 --- a/documentation/src/test/resources/log4j2-test.xml +++ b/documentation/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/junit-vintage-engine/src/test/resources/log4j2-test.xml b/junit-vintage-engine/src/test/resources/log4j2-test.xml index 3bade9825b86..d3b0e4baeb72 100644 --- a/junit-vintage-engine/src/test/resources/log4j2-test.xml +++ b/junit-vintage-engine/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/jupiter-tests/src/test/resources/log4j2-test.xml b/jupiter-tests/src/test/resources/log4j2-test.xml index c637896979b2..a9bc7e469b5b 100644 --- a/jupiter-tests/src/test/resources/log4j2-test.xml +++ b/jupiter-tests/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/platform-tests/src/test/resources/log4j2-test.xml b/platform-tests/src/test/resources/log4j2-test.xml index b973e5f0bc7c..d4e3bc8e85e5 100644 --- a/platform-tests/src/test/resources/log4j2-test.xml +++ b/platform-tests/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + diff --git a/platform-tooling-support-tests/src/test/resources/log4j2-test.xml b/platform-tooling-support-tests/src/test/resources/log4j2-test.xml index 97726b18a018..89fa6837ce7c 100644 --- a/platform-tooling-support-tests/src/test/resources/log4j2-test.xml +++ b/platform-tooling-support-tests/src/test/resources/log4j2-test.xml @@ -5,7 +5,7 @@ xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-config-2.xsd"> - + From d33beb5962c3630766237cd4964cc14ed2f13a3b Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 10:13:40 +0200 Subject: [PATCH 026/120] Delete debug printing code --- ...tHierarchicalTestExecutorServiceTests.java | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index df0e246712ae..aef5d52b1c68 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -207,8 +207,6 @@ void runsTasksWithoutConflictingLocksConcurrently() throws Exception { service.submit(root).get(); - printTimeline(Stream.concat(Stream.of(root), Stream.concat(children.stream(), leafs.stream()))); - assertThat(root.executionThread()).isNotNull(); assertThat(children).extracting(TestTaskStub::executionThread).doesNotContainNull(); assertThat(leafs).extracting(TestTaskStub::executionThread).doesNotContainNull(); @@ -320,58 +318,4 @@ public String toString() { } } - static void printTimeline(Stream> taskStream) { - var allTasks = taskStream.toList(); - assertThat(allTasks.stream().filter(task -> task.executionThread() == null)) // - .describedAs("Unexecuted tasks").isEmpty(); - var statistics = allTasks.stream() // - .flatMap(task -> Stream.concat(Stream.of(task.startTime), - Optional.ofNullable(task.endTime).stream())).mapToLong( - instant -> requireNonNull(instant).toEpochMilli()) // - .summaryStatistics(); - var rangeMillis = statistics.getMax() - statistics.getMin(); - var width = 100; - var scale = (double) width / rangeMillis; - var sortedTasks = allTasks.stream() // - .sorted(comparing(task -> requireNonNull(task.startTime))) // - .toList(); - var tasksByThread = sortedTasks.stream() // - .sorted(comparingLong(testTaskStub -> requireNonNull(testTaskStub.executionThread()).threadId())) // - .collect(groupingBy(task -> requireNonNull(task.executionThread), LinkedHashMap::new, toList())); - ToIntFunction<@Nullable Instant> indexFunction = instant -> (int) ((requireNonNull(instant).toEpochMilli() - - statistics.getMin()) * scale); - tasksByThread.forEach( - (thread, tasks) -> printTimelineForThread(requireNonNull(thread), tasks, width, indexFunction)); - } - - private static void printTimelineForThread(Thread thread, List> tasks, int width, - ToIntFunction<@Nullable Instant> indexFunction) { - System.out.printf("%n%s (%d)%n", thread.getName(), tasks.size()); - StringBuilder builder = new StringBuilder(); - for (var task : tasks) { - builder.append(".".repeat(width + 1)); - int startIndex = indexFunction.applyAsInt(task.startTime); - builder.setCharAt(startIndex, '<'); - if (task.endTime == null) { - builder.setCharAt(startIndex + 1, '-'); - builder.setCharAt(startIndex + 2, '?'); - } - else { - int endIndex = indexFunction.applyAsInt(task.endTime); - if (endIndex == startIndex) { - builder.setCharAt(endIndex, 'O'); - } - else { - for (int i = startIndex + 1; i < endIndex; i++) { - builder.setCharAt(i, '-'); - } - builder.setCharAt(endIndex, '>'); - } - } - builder.append(" ").append(task.name); - System.out.println(builder); - builder.setLength(0); - } - } - } From 226498914127d36d51fad86d95717f1781e3a0d3 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 10:17:21 +0200 Subject: [PATCH 027/120] Prioritize children of started containers --- ...urrentHierarchicalTestExecutorService.java | 27 ++++++++++--- ...tHierarchicalTestExecutorServiceTests.java | 39 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index ce2f146d493f..0933da8e8d32 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -20,12 +20,12 @@ import java.util.ArrayList; import java.util.List; -import java.util.concurrent.BlockingQueue; +import java.util.Queue; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; -import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.Semaphore; import java.util.concurrent.SynchronousQueue; @@ -417,16 +417,17 @@ public T handle(Callable callable) throws Exception { private static class WorkQueue { - private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final Queue queue = new PriorityBlockingQueue<>(); Entry add(TestTask task) { LOGGER.trace(() -> "forking: " + task); - return doAdd(new Entry(task, new CompletableFuture<>())); + int level = task.getTestDescriptor().getUniqueId().getSegments().size(); + return doAdd(new Entry(task, new CompletableFuture<>(), level, 0)); } void add(Entry entry) { LOGGER.trace(() -> "re-enqueuing: " + entry.task); - doAdd(entry); + doAdd(entry.incrementAttempts()); } private Entry doAdd(Entry entry) { @@ -450,7 +451,8 @@ boolean isEmpty() { return queue.isEmpty(); } - private record Entry(TestTask task, CompletableFuture<@Nullable Void> future) { + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, int attempts) + implements Comparable { @SuppressWarnings("FutureReturnValueIgnored") Entry { future.whenComplete((__, t) -> { @@ -462,6 +464,19 @@ private record Entry(TestTask task, CompletableFuture<@Nullable Void> future) { } }); } + + Entry incrementAttempts() { + return new Entry(task(), future, level, attempts + 1); + } + + @Override + public int compareTo(Entry that) { + var result = Integer.compare(that.level, this.level); + if (result == 0) { + return Integer.compare(that.attempts, this.attempts); + } + return result; + } } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index aef5d52b1c68..b4e253bf11bd 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -214,6 +214,29 @@ void runsTasksWithoutConflictingLocksConcurrently() throws Exception { .containsOnly(true); } + @Test + void prioritizesChildrenOfStartedContainers() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + + var leavesStarted = new CountDownLatch(2); + var leaf = TestTaskStub.withoutResult(CONCURRENT, leavesStarted::countDown) // + .withName("leaf").withLevel(3); + var child1 = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().submit(leaf).get()) // + .withName("child1").withLevel(2); + var child2 = TestTaskStub.withoutResult(CONCURRENT, leavesStarted::countDown) // + .withName("child2").withLevel(2); + var child3 = TestTaskStub.withoutResult(SAME_THREAD, leavesStarted::await) // + .withName("child3").withLevel(2); + + var root = TestTaskStub.withoutResult(SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2, child3))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); + } + private static ExclusiveResource exclusiveResource() { return new ExclusiveResource("key", ExclusiveResource.LockMode.READ_WRITE); } @@ -240,6 +263,7 @@ private static final class TestTaskStub implements T private volatile @Nullable Instant startTime; private volatile @Nullable Instant endTime; private volatile @Nullable Thread executionThread; + private int level = 1; static TestTaskStub withoutResult(ExecutionMode executionMode) { return new TestTaskStub<@Nullable Void>(executionMode, () -> null); @@ -277,6 +301,16 @@ public ResourceLock getResourceLock() { return resourceLock; } + @Override + public TestDescriptor getTestDescriptor() { + var name = String.valueOf(this.name); + var uniqueId = UniqueId.root("root", name); + for (var i = 1; i < level; i++) { + uniqueId = uniqueId.append("child", name); + } + return new TestDescriptorStub(uniqueId, name); + } + @Override public void execute() { startTime = Instant.now(); @@ -312,6 +346,11 @@ TestTaskStub withName(String name) { return this; } + TestTaskStub withLevel(int level) { + this.level = level; + return this; + } + @Override public String toString() { return "%s @ %s".formatted(new ToStringBuilder(this).append("name", name), Integer.toHexString(hashCode())); From 8c9db08034e7ddf89a75032fd70c550956b45ac3 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 10:17:44 +0200 Subject: [PATCH 028/120] Improve naming --- .../ConcurrentHierarchicalTestExecutorService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 0933da8e8d32..6170dadc8aaa 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -64,7 +64,7 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration } ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { - ThreadFactory threadFactory = new CustomThreadFactory(classLoader); + ThreadFactory threadFactory = new WorkerThreadFactory(classLoader); threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); workerLeaseManager = new WorkerLeaseManager(configuration.getParallelism()); @@ -301,7 +301,7 @@ private static void doExecute(TestTask testTask) { } } - private class CustomThreadFactory implements ThreadFactory { + private class WorkerThreadFactory implements ThreadFactory { private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); @@ -309,7 +309,7 @@ private class CustomThreadFactory implements ThreadFactory { private final int poolNumber; private final ClassLoader classLoader; - CustomThreadFactory(ClassLoader classLoader) { + WorkerThreadFactory(ClassLoader classLoader) { this.classLoader = classLoader; this.poolNumber = POOL_NUMBER.getAndIncrement(); } From 539ef1abe78b1a822318aad36c2c53d9de775535 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 10:18:25 +0200 Subject: [PATCH 029/120] Improve logging --- ...ConcurrentHierarchicalTestExecutorService.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 6170dadc8aaa..4095e98799df 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -68,10 +68,12 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); workerLeaseManager = new WorkerLeaseManager(configuration.getParallelism()); + LOGGER.trace(() -> "initialized thread pool for parallelism of " + configuration.getParallelism()); } @Override public void close() { + LOGGER.trace(() -> "shutting down thread pool"); threadPool.shutdownNow(); } @@ -226,7 +228,7 @@ private void executeAll(List children) { if (children.isEmpty()) { return; } - LOGGER.trace(() -> "Running SAME_THREAD children: " + children); + LOGGER.trace(() -> "running %d SAME_THREAD children".formatted(children.size())); if (children.size() == 1) { executeTask(children.get(0)); return; @@ -492,12 +494,16 @@ private class WorkerLeaseManager { @Nullable WorkerLease tryAcquire() { boolean acquired = semaphore.tryAcquire(); - return acquired ? new WorkerLease(this::release) : null; + if (acquired) { + LOGGER.trace(() -> "acquired worker lease (available: %d)".formatted(semaphore.availablePermits())); + return new WorkerLease(this::release); + } + return null; } private ReacquisitionToken release() { - LOGGER.trace(() -> "releasing worker lease"); semaphore.release(); + LOGGER.trace(() -> "release worker lease (available: %d)".formatted(semaphore.availablePermits())); maybeStartWorker(); return new ReacquisitionToken(); } @@ -513,8 +519,8 @@ private class ReacquisitionToken { void reacquire() throws InterruptedException { Preconditions.condition(!used, "Lease was already reacquired"); used = true; - LOGGER.trace(() -> "reacquiring worker lease"); semaphore.acquire(); + LOGGER.trace(() -> "reacquired worker lease (available: %d)".formatted(semaphore.availablePermits())); } } } @@ -525,7 +531,6 @@ private static class WorkerLease { private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; WorkerLease(Supplier releaseAction) { - LOGGER.trace(() -> "acquired worker lease"); this.releaseAction = releaseAction; } From 86dfea44f98908fde8f8a8de185b489b8d83fa4d Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 10:20:06 +0200 Subject: [PATCH 030/120] Poll queue only if worker lease was available To avoid race conditions with other workers that lead to stalling. --- .../ConcurrentHierarchicalTestExecutorService.java | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 4095e98799df..bf49ebb109a1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -351,17 +351,16 @@ static WorkerThread getOrThrow() { void processQueueEntries() { while (!threadPool.isShutdown()) { - var entry = workQueue.poll(); - if (entry == null) { - break; - } - LOGGER.trace(() -> "processing: " + entry.task); workerLease = workerLeaseManager.tryAcquire(); if (workerLease == null) { - workQueue.add(entry); break; } try { + var entry = workQueue.poll(); + if (entry == null) { + break; + } + LOGGER.trace(() -> "processing: " + entry.task); executeEntry(entry); } finally { From 05c45f91d46eb7a581547fbac6a92eb74cd84853 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 10:37:05 +0200 Subject: [PATCH 031/120] Simplify tests --- ...tHierarchicalTestExecutorServiceTests.java | 194 +++++++++--------- 1 file changed, 94 insertions(+), 100 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index b4e253bf11bd..83d9968e6d1a 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -10,17 +10,12 @@ package org.junit.platform.engine.support.hierarchical; -import static java.util.Comparator.comparing; -import static java.util.Comparator.comparingLong; import static java.util.Objects.requireNonNull; +import static java.util.concurrent.Future.State.SUCCESS; import static java.util.function.Predicate.isEqual; -import static java.util.stream.Collectors.groupingBy; -import static java.util.stream.Collectors.toList; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; -import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; -import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -31,15 +26,10 @@ import java.net.URL; import java.net.URLClassLoader; import java.time.Instant; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; -import java.util.function.ToIntFunction; -import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -47,13 +37,16 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; -import org.junit.jupiter.api.function.ThrowingSupplier; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; +import org.junit.platform.engine.TestDescriptor; +import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; +import org.junit.platform.fakes.TestDescriptorStub; +import org.opentest4j.AssertionFailedError; /** * @since 6.1 @@ -70,7 +63,7 @@ class ConcurrentHierarchicalTestExecutorServiceTests { @EnumSource(ExecutionMode.class) void executesSingleTask(ExecutionMode executionMode) throws Exception { - var task = TestTaskStub.withoutResult(executionMode); + var task = new TestTaskStub(executionMode); var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); try (customClassLoader) { @@ -78,14 +71,17 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { service.submit(task).get(); } - assertThat(task.executionThread()).isNotNull().isNotSameAs(Thread.currentThread()); - assertThat(task.executionThread().getName()).matches("junit-\\d+-worker-1"); - assertThat(task.executionThread().getContextClassLoader()).isSameAs(customClassLoader); + task.assertExecutedSuccessfully(); + + var executionThread = task.executionThread(); + assertThat(executionThread).isNotNull().isNotSameAs(Thread.currentThread()); + assertThat(executionThread.getName()).matches("junit-\\d+-worker-1"); + assertThat(executionThread.getContextClassLoader()).isSameAs(customClassLoader); } @Test void invokeAllMustBeExecutedFromWithinThreadPool() { - var tasks = List.of(TestTaskStub.withoutResult(CONCURRENT)); + var tasks = List.of(new TestTaskStub(ExecutionMode.CONCURRENT)); service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); assertPreconditionViolationFor(() -> requiredService().invokeAll(tasks)) // @@ -98,11 +94,14 @@ void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode c throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); - var child = TestTaskStub.withoutResult(childExecutionMode); - var root = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(List.of(child))); + var child = new TestTaskStub(childExecutionMode); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(List.of(child))); service.submit(root).get(); + root.assertExecutedSuccessfully(); + child.assertExecutedSuccessfully(); + assertThat(root.executionThread()).isNotNull(); assertThat(child.executionThread()).isSameAs(root.executionThread()); } @@ -112,31 +111,36 @@ void executesTwoChildrenConcurrently() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); var latch = new CountDownLatch(2); - ThrowingSupplier behavior = () -> { + Executable behavior = () -> { latch.countDown(); - return latch.await(100, TimeUnit.MILLISECONDS); + latch.await(); }; - var children = List.of(TestTaskStub.withResult(CONCURRENT, behavior), - TestTaskStub.withResult(CONCURRENT, behavior)); - var root = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(children)); + var children = List.of(new TestTaskStub(ExecutionMode.CONCURRENT, behavior), + new TestTaskStub(ExecutionMode.CONCURRENT, behavior)); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)); service.submit(root).get(); - assertThat(children).extracting(TestTaskStub::result).containsOnly(true); + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); } @Test void executesTwoChildrenInSameThread() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); - var children = List.of(TestTaskStub.withoutResult(SAME_THREAD), TestTaskStub.withoutResult(SAME_THREAD)); - var root = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(children)); + var children = List.of(new TestTaskStub(ExecutionMode.SAME_THREAD), + new TestTaskStub(ExecutionMode.SAME_THREAD)); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)); service.submit(root).get(); assertThat(root.executionThread()).isNotNull(); assertThat(children).extracting(TestTaskStub::executionThread).containsOnly(root.executionThread()); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); } @Test @@ -144,12 +148,12 @@ void acquiresResourceLockForRootTask() throws Exception { var resourceLock = mock(ResourceLock.class); when(resourceLock.acquire()).thenReturn(resourceLock); - var task = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock); + var task = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock); service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); service.submit(task).get(); - assertThat(task.executionThread()).isNotNull(); + task.assertExecutedSuccessfully(); var inOrder = inOrder(resourceLock); inOrder.verify(resourceLock).acquire(); @@ -165,17 +169,18 @@ void acquiresResourceLockForChildTasks() throws Exception { when(resourceLock.tryAcquire()).thenReturn(true, false); when(resourceLock.acquire()).thenReturn(resourceLock); - var child1 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock).withName("child1"); - var child2 = TestTaskStub.withoutResult(CONCURRENT).withResourceLock(resourceLock).withName("child2"); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock).withName("child1"); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock).withName("child2"); var children = List.of(child1, child2); - var root = TestTaskStub.withoutResult(SAME_THREAD, () -> requiredService().invokeAll(children)).withName( + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> requiredService().invokeAll(children)).withName( "root"); service.submit(root).get(); - assertThat(root.executionThread()).isNotNull(); + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(children).extracting(TestTaskStub::executionThread) // - .doesNotContainNull() // .filteredOn(isEqual(root.executionThread())).hasSizeLessThan(2); verify(resourceLock, atLeast(2)).tryAcquire(); @@ -190,28 +195,27 @@ void runsTasksWithoutConflictingLocksConcurrently() throws Exception { var resourceLock = new SingleLock(exclusiveResource(), new ReentrantLock()); var latch = new CountDownLatch(3); - ThrowingSupplier behavior = () -> { + Executable behavior = () -> { latch.countDown(); - return latch.await(100, TimeUnit.MILLISECONDS); + latch.await(); }; - var child1 = TestTaskStub.withResult(CONCURRENT, behavior).withResourceLock(resourceLock).withName("child1"); - var child2 = TestTaskStub.withoutResult(SAME_THREAD).withResourceLock(resourceLock).withName("child2"); - var leaf1 = TestTaskStub.withResult(CONCURRENT, behavior).withName("leaf1"); - var leaf2 = TestTaskStub.withResult(CONCURRENT, behavior).withName("leaf2"); - var leafs = List.of(leaf1, leaf2); - var child3 = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().invokeAll(leafs)).withName( + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior).withResourceLock(resourceLock).withName( + "child1"); + var child2 = new TestTaskStub(ExecutionMode.SAME_THREAD).withResourceLock(resourceLock).withName("child2"); + var leaf1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior).withName("leaf1"); + var leaf2 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior).withName("leaf2"); + var leaves = List.of(leaf1, leaf2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(leaves)).withName( "child3"); var children = List.of(child1, child2, child3); - var root = TestTaskStub.withoutResult(SAME_THREAD, () -> requiredService().invokeAll(children)).withName( + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> requiredService().invokeAll(children)).withName( "root"); service.submit(root).get(); - assertThat(root.executionThread()).isNotNull(); - assertThat(children).extracting(TestTaskStub::executionThread).doesNotContainNull(); - assertThat(leafs).extracting(TestTaskStub::executionThread).doesNotContainNull(); - assertThat(Stream.concat(Stream.of(child1), leafs.stream())).extracting(TestTaskStub::result) // - .containsOnly(true); + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(leaves).allSatisfy(TestTaskStub::assertExecutedSuccessfully); } @Test @@ -219,21 +223,25 @@ void prioritizesChildrenOfStartedContainers() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); var leavesStarted = new CountDownLatch(2); - var leaf = TestTaskStub.withoutResult(CONCURRENT, leavesStarted::countDown) // + var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // .withName("leaf").withLevel(3); - var child1 = TestTaskStub.withoutResult(CONCURRENT, () -> requiredService().submit(leaf).get()) // + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // .withName("child1").withLevel(2); - var child2 = TestTaskStub.withoutResult(CONCURRENT, leavesStarted::countDown) // + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // .withName("child2").withLevel(2); - var child3 = TestTaskStub.withoutResult(SAME_THREAD, leavesStarted::await) // + var child3 = new TestTaskStub(ExecutionMode.SAME_THREAD, leavesStarted::await) // .withName("child3").withLevel(2); - var root = TestTaskStub.withoutResult(SAME_THREAD, + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> requiredService().invokeAll(List.of(child1, child2, child3))) // - .withName("root").withLevel(1); + .withName("root").withLevel(1); service.submit(root).get(); + root.assertExecutedSuccessfully(); + assertThat(List.of(child1, child2, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + leaf.assertExecutedSuccessfully(); + assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); } @@ -251,42 +259,40 @@ private static ParallelExecutionConfiguration configuration(int parallelism) { } @NullMarked - private static final class TestTaskStub implements TestTask { + private static final class TestTaskStub implements TestTask { private final ExecutionMode executionMode; - private final ThrowingSupplier behavior; + private final Executable behavior; private ResourceLock resourceLock = NopLock.INSTANCE; private @Nullable String name; + private int level = 1; - private final CompletableFuture<@Nullable T> result = new CompletableFuture<>(); + private final CompletableFuture<@Nullable Void> result = new CompletableFuture<>(); private volatile @Nullable Instant startTime; - private volatile @Nullable Instant endTime; private volatile @Nullable Thread executionThread; - private int level = 1; - static TestTaskStub withoutResult(ExecutionMode executionMode) { - return new TestTaskStub<@Nullable Void>(executionMode, () -> null); + TestTaskStub(ExecutionMode executionMode) { + this(executionMode, () -> { + }); } - static TestTaskStub withoutResult(ExecutionMode executionMode, Executable executable) { - return new TestTaskStub<@Nullable Void>(executionMode, () -> { - executable.execute(); - return null; - }); + TestTaskStub(ExecutionMode executionMode, Executable behavior) { + this.executionMode = executionMode; + this.behavior = behavior; } - @SuppressWarnings("SameParameterValue") - static TestTaskStub withResult(ExecutionMode executionMode, ThrowingSupplier supplier) { - return new TestTaskStub<>(executionMode, supplier); + TestTaskStub withName(String name) { + this.name = name; + return this; } - TestTaskStub(ExecutionMode executionMode, ThrowingSupplier behavior) { - this.executionMode = executionMode; - this.behavior = behavior; + TestTaskStub withLevel(int level) { + this.level = level; + return this; } - TestTaskStub withResourceLock(ResourceLock resourceLock) { + TestTaskStub withResourceLock(ResourceLock resourceLock) { this.resourceLock = resourceLock; return this; } @@ -314,21 +320,24 @@ public TestDescriptor getTestDescriptor() { @Override public void execute() { startTime = Instant.now(); + Preconditions.condition(!result.isDone(), "task was already executed"); + + executionThread = Thread.currentThread(); try { - Preconditions.condition(!result.isDone(), "task was already executed"); - - executionThread = Thread.currentThread(); - try { - result.complete(behavior.get()); - } - catch (Throwable t) { - result.completeExceptionally(t); - throw throwAsUncheckedException(t); - } + behavior.execute(); + result.complete(null); + } + catch (Throwable t) { + result.completeExceptionally(t); + throw throwAsUncheckedException(t); } - finally { - endTime = Instant.now(); + } + + void assertExecutedSuccessfully() { + if (result.isCompletedExceptionally()) { + throw new AssertionFailedError("Failure during execution", result.exceptionNow()); } + assertThat(result.state()).isEqualTo(SUCCESS); } @Nullable @@ -336,21 +345,6 @@ Thread executionThread() { return executionThread; } - T result() { - Preconditions.condition(result.isDone(), "task was not executed"); - return result.getNow(null); - } - - TestTaskStub withName(String name) { - this.name = name; - return this; - } - - TestTaskStub withLevel(int level) { - this.level = level; - return this; - } - @Override public String toString() { return "%s @ %s".formatted(new ToStringBuilder(this).append("name", name), Integer.toHexString(hashCode())); From 9d77839067a612c571c7448fc9c74e8d0e5d78d9 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Mon, 13 Oct 2025 13:02:28 +0200 Subject: [PATCH 032/120] Introduce configuration parameter for opting in to new implementation --- .../test/resources/junit-platform.properties | 1 + .../org/junit/jupiter/engine/Constants.java | 20 ++++++- .../jupiter/engine/JupiterTestEngine.java | 5 +- .../config/CachingJupiterConfiguration.java | 6 ++ .../config/DefaultJupiterConfiguration.java | 20 +++++++ ...iatingConfigurationParameterConverter.java | 60 ++++++++++++++++--- .../engine/config/JupiterConfiguration.java | 5 ++ .../ParallelExecutionIntegrationTests.java | 10 +++- .../test/resources/junit-platform.properties | 1 + 9 files changed, 113 insertions(+), 15 deletions(-) diff --git a/documentation/src/test/resources/junit-platform.properties b/documentation/src/test/resources/junit-platform.properties index 0f0255f62dbb..563ab961f558 100644 --- a/documentation/src/test/resources/junit-platform.properties +++ b/documentation/src/test/resources/junit-platform.properties @@ -1,4 +1,5 @@ junit.jupiter.execution.parallel.enabled=true +junit.jupiter.execution.parallel.executor=org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=6 diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java index fb08e6e57dee..1feda4ed9ab5 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java @@ -13,6 +13,7 @@ import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.apiguardian.api.API.Status.MAINTAINED; import static org.apiguardian.api.API.Status.STABLE; +import static org.junit.jupiter.engine.config.JupiterConfiguration.PARALLEL_CONFIG_PREFIX; import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_CUSTOM_CLASS_PROPERTY_NAME; import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME; import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME; @@ -38,6 +39,8 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; +import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ParallelExecutionConfigurationStrategy; /** @@ -210,6 +213,21 @@ public final class Constants { @API(status = STABLE, since = "5.10") public static final String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; + /** + * Property name used to configure the fully qualified class name + * {@link HierarchicalTestExecutorService} implementation to use if parallel + * test execution is + * {@linkplain #PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME enabled}: {@value} + * + *

The implementation class must provide a parameter of type + * + *

By default, {@link ForkJoinPoolHierarchicalTestExecutorService} is used. + * + * @since 6.1 + */ + @API(status = EXPERIMENTAL, since = "6.1") + public static final String PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME = JupiterConfiguration.PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME; + /** * Property name used to enable auto-closing of {@link AutoCloseable} instances * @@ -237,8 +255,6 @@ public final class Constants { @API(status = STABLE, since = "5.10") public static final String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; - static final String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; - /** * Property name used to select the * {@link ParallelExecutionConfigurationStrategy}: {@value} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 568dfff4b081..1f023e04f339 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -27,9 +27,7 @@ import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.UniqueId; -import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; @@ -79,8 +77,7 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return new ConcurrentHierarchicalTestExecutorService(new PrefixedConfigurationParameters( - request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); + return configuration.createParallelExecutorService(); } return super.createExecutorService(request); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java index d849bd609c4d..677bca31dbc3 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java @@ -32,6 +32,7 @@ import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; +import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; /** * Caching implementation of the {@link JupiterConfiguration} API. @@ -69,6 +70,11 @@ public boolean isParallelExecutionEnabled() { __ -> delegate.isParallelExecutionEnabled()); } + @Override + public HierarchicalTestExecutorService createParallelExecutorService() { + return delegate.createParallelExecutorService(); + } + @Override public boolean isClosingStoredAutoCloseablesEnabled() { return (boolean) cache.computeIfAbsent(CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME, diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 50ea9550fe8f..400f7f072646 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -34,13 +34,17 @@ import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.engine.config.InstantiatingConfigurationParameterConverter.Strictness; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.engine.ConfigurationParameters; import org.junit.platform.engine.DiscoveryIssue; import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.OutputDirectoryCreator; +import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; +import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; /** * Default implementation of the {@link JupiterConfiguration} API. @@ -81,6 +85,10 @@ public class DefaultJupiterConfiguration implements JupiterConfiguration { private static final ConfigurationParameterConverter extensionContextScopeConverter = // new EnumConfigurationParameterConverter<>(ExtensionContextScope.class, "extension context scope"); + private static final ConfigurationParameterConverter parallelExecutorServiceConverter = // + new InstantiatingConfigurationParameterConverter<>(HierarchicalTestExecutorService.class, + "parallel executor service", Strictness.FAIL, parameters -> new Object[] { parallelConfig(parameters) }); + private final ConfigurationParameters configurationParameters; private final OutputDirectoryCreator outputDirectoryCreator; @@ -136,6 +144,13 @@ public boolean isParallelExecutionEnabled() { return configurationParameters.getBoolean(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME).orElse(false); } + @Override + public HierarchicalTestExecutorService createParallelExecutorService() { + return parallelExecutorServiceConverter.get(configurationParameters, PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME) // + .orElseGet( + () -> new ForkJoinPoolHierarchicalTestExecutorService(parallelConfig(configurationParameters))); + } + @Override public boolean isClosingStoredAutoCloseablesEnabled() { return configurationParameters.getBoolean(CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME).orElse(true); @@ -214,4 +229,9 @@ public ExtensionContextScope getDefaultTestInstantiationExtensionContextScope() public OutputDirectoryCreator getOutputDirectoryCreator() { return outputDirectoryCreator; } + + private static PrefixedConfigurationParameters parallelConfig(ConfigurationParameters configurationParameters) { + return new PrefixedConfigurationParameters(configurationParameters, PARALLEL_CONFIG_PREFIX); + } + } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java index 7315af7b9f8a..12f1f907cdcf 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java @@ -10,28 +10,43 @@ package org.junit.jupiter.engine.config; +import java.lang.reflect.Constructor; import java.util.Optional; +import java.util.function.Function; import java.util.function.Supplier; +import java.util.stream.IntStream; +import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.function.Try; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.support.ReflectionSupport; +import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ReflectionUtils; import org.junit.platform.engine.ConfigurationParameters; /** * @since 5.5 */ -class InstantiatingConfigurationParameterConverter implements ConfigurationParameterConverter { +final class InstantiatingConfigurationParameterConverter implements ConfigurationParameterConverter { private static final Logger logger = LoggerFactory.getLogger(InstantiatingConfigurationParameterConverter.class); private final Class clazz; private final String name; + private final Strictness strictness; + private final Function argumentResolver; InstantiatingConfigurationParameterConverter(Class clazz, String name) { + this(clazz, name, Strictness.WARN, __ -> new Object[0]); + } + + InstantiatingConfigurationParameterConverter(Class clazz, String name, Strictness strictness, + Function argumentResolver) { this.clazz = clazz; this.name = name; + this.strictness = strictness; + this.argumentResolver = argumentResolver; } @Override @@ -44,15 +59,16 @@ Supplier> supply(ConfigurationParameters configurationParameters, St return configurationParameters.get(key) .map(String::strip) .filter(className -> !className.isEmpty()) - .map(className -> newInstanceSupplier(className, key)) + .map(className -> newInstanceSupplier(className, key, configurationParameters)) .orElse(Optional::empty); // @formatter:on } - private Supplier> newInstanceSupplier(String className, String key) { + private Supplier> newInstanceSupplier(String className, String key, + ConfigurationParameters configurationParameters) { Try> clazz = ReflectionSupport.tryToLoadClass(className); // @formatter:off - return () -> clazz.andThenTry(ReflectionSupport::newInstance) + return () -> clazz.andThenTry(it -> instantiate(it, configurationParameters)) .andThenTry(this.clazz::cast) .ifSuccess(generator -> logSuccessMessage(className, key)) .ifFailure(cause -> logFailureMessage(className, key, cause)) @@ -60,10 +76,36 @@ private Supplier> newInstanceSupplier(String className, String key) // @formatter:on } + @SuppressWarnings("unchecked") + private V instantiate(Class clazz, ConfigurationParameters configurationParameters) { + var arguments = argumentResolver.apply(configurationParameters); + if (arguments.length == 0) { + return ReflectionSupport.newInstance(clazz); + } + var constructors = ReflectionUtils.findConstructors(clazz, it -> { + if (it.getParameterCount() != arguments.length) { + return false; + } + var parameters = it.getParameters(); + return IntStream.range(0, parameters.length) // + .allMatch(i -> parameters[i].getType().isAssignableFrom(arguments[i].getClass())); + }); + Preconditions.condition(constructors.size() == 1, + () -> "Failed to find unambiguous constructor for %s. Candidates: %s".formatted(clazz.getName(), + constructors)); + return ReflectionUtils.newInstance((Constructor) constructors.get(0), arguments); + } + private void logFailureMessage(String className, String key, Exception cause) { - logger.warn(cause, () -> """ - Failed to load default %s class '%s' set via the '%s' configuration parameter. \ - Falling back to default behavior.""".formatted(this.name, className, key)); + switch (strictness) { + case WARN -> logger.warn(cause, () -> """ + Failed to load default %s class '%s' set via the '%s' configuration parameter. \ + Falling back to default behavior.""".formatted(this.name, className, key)); + case FAIL -> throw new JUnitException( + "Failed to load default %s class '%s' set via the '%s' configuration parameter.".formatted(this.name, + className, key), + cause); + } } private void logSuccessMessage(String className, String key) { @@ -71,4 +113,8 @@ private void logSuccessMessage(String className, String key) { className, key)); } + enum Strictness { + FAIL, WARN + } + } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 1d7ec56e40c2..17dd7dd0810a 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; +import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; /** * @since 5.4 @@ -42,6 +43,8 @@ public interface JupiterConfiguration { String EXTENSIONS_AUTODETECTION_EXCLUDE_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.exclude"; String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate"; String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; + String PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME = "junit.jupiter.execution.parallel.executor"; + String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; String CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.store.close.autocloseable.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; @@ -61,6 +64,8 @@ public interface JupiterConfiguration { boolean isParallelExecutionEnabled(); + HierarchicalTestExecutorService createParallelExecutorService(); + boolean isClosingStoredAutoCloseablesEnabled(); boolean isExtensionAutoDetectionEnabled(); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index 79ac093c6e51..dca22c4c962d 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -25,6 +25,7 @@ import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; +import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME; import static org.junit.platform.commons.util.CollectionUtils.getOnlyElement; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClasses; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; @@ -74,6 +75,7 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.engine.DiscoverySelector; @@ -89,7 +91,10 @@ * @since 1.3 */ @SuppressWarnings({ "JUnitMalformedDeclaration", "NewClassNamingConvention" }) -class ParallelExecutionIntegrationTests { +@ParameterizedClass +@ValueSource(classes = { ForkJoinPoolHierarchicalTestExecutorService.class, + ConcurrentHierarchicalTestExecutorService.class }) +record ParallelExecutionIntegrationTests(Class implementation) { @Test void successfulParallelTest(TestReporter reporter) { @@ -579,11 +584,12 @@ private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map< return executeWithFixedParallelism(parallelism, configParams, selectClasses(testClasses)); } - private static EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, + private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, List selectors) { return EngineTestKit.engine("junit-jupiter") // .selectors(selectors) // .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true)) // + .configurationParameter(PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME, implementation.getName()) // .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") // .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism)) // .configurationParameters(configParams) // diff --git a/platform-tooling-support-tests/src/test/resources/junit-platform.properties b/platform-tooling-support-tests/src/test/resources/junit-platform.properties index 3d1d2700e7f0..946b0f1f77c0 100644 --- a/platform-tooling-support-tests/src/test/resources/junit-platform.properties +++ b/platform-tooling-support-tests/src/test/resources/junit-platform.properties @@ -1,4 +1,5 @@ junit.jupiter.execution.parallel.enabled=true +junit.jupiter.execution.parallel.executor=org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=0.25 From 0d201d1ce9cc45a174addb0e2679e3e7673604cf Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 08:19:32 +0200 Subject: [PATCH 033/120] Remove double negative --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 2 +- .../ConcurrentHierarchicalTestExecutorServiceTests.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index bf49ebb109a1..cfbc2ede8924 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -96,7 +96,7 @@ public void invokeAll(List testTasks) { var workerThread = WorkerThread.get(); Preconditions.condition(workerThread != null && workerThread.executor() == this, - "invokeAll() must not be called from a thread that is not part of this executor"); + "invokeAll() must be called from a worker thread that belongs to this executor"); if (testTasks.isEmpty()) { return; diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 83d9968e6d1a..999d6d5efd6e 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -85,7 +85,7 @@ void invokeAllMustBeExecutedFromWithinThreadPool() { service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); assertPreconditionViolationFor(() -> requiredService().invokeAll(tasks)) // - .withMessage("invokeAll() must not be called from a thread that is not part of this executor"); + .withMessage("invokeAll() must be called from a worker thread that belongs to this executor"); } @ParameterizedTest From 08568e4065f9c266ccc1487525d0d9d873ad23ec Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 08:23:39 +0200 Subject: [PATCH 034/120] Let `WorkerLease` implement `AutoCloseable` to simplify handling --- ...ConcurrentHierarchicalTestExecutorService.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index cfbc2ede8924..618f90b9330e 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -351,21 +351,19 @@ static WorkerThread getOrThrow() { void processQueueEntries() { while (!threadPool.isShutdown()) { - workerLease = workerLeaseManager.tryAcquire(); + var workerLease = workerLeaseManager.tryAcquire(); if (workerLease == null) { break; } - try { + try (workerLease) { var entry = workQueue.poll(); if (entry == null) { break; } LOGGER.trace(() -> "processing: " + entry.task); + this.workerLease = workerLease; executeEntry(entry); } - finally { - workerLease.release(); - } } } @@ -524,7 +522,7 @@ void reacquire() throws InterruptedException { } } - private static class WorkerLease { + private static class WorkerLease implements AutoCloseable { private final Supplier releaseAction; private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; @@ -533,6 +531,11 @@ private static class WorkerLease { this.releaseAction = releaseAction; } + @Override + public void close() { + release(); + } + void release() { if (reacquisitionToken == null) { reacquisitionToken = releaseAction.get(); From a05279a984baaa886f46042d1c651d471c13e9e1 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 08:36:22 +0200 Subject: [PATCH 035/120] Use same (reverse) order for work stealing and work queue --- ...urrentHierarchicalTestExecutorService.java | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 618f90b9330e..67229e30b795 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -10,6 +10,7 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.Comparator.reverseOrder; import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.TimeUnit.SECONDS; @@ -19,7 +20,9 @@ import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; import java.util.ArrayList; +import java.util.Collection; import java.util.List; +import java.util.PriorityQueue; import java.util.Queue; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; @@ -175,37 +178,35 @@ private static CompletableFuture toCombinedFuture(List entri return CompletableFuture.allOf(futures); } - private List stealWork(List queueEntries) { + private List stealWork(Collection queueEntries) { if (queueEntries.isEmpty()) { return List.of(); } - List concurrentlyExecutedChildren = new ArrayList<>(queueEntries.size()); - var iterator = queueEntries.listIterator(queueEntries.size()); - while (iterator.hasPrevious()) { - var entry = iterator.previous(); + List concurrentlyExecutingChildren = new ArrayList<>(queueEntries.size()); + for (var entry : queueEntries) { var claimed = workQueue.remove(entry); if (claimed) { LOGGER.trace(() -> "stole work: " + entry); var executed = tryExecute(entry); if (!executed) { workQueue.add(entry); - concurrentlyExecutedChildren.add(entry); + concurrentlyExecutingChildren.add(entry); } } else { - concurrentlyExecutedChildren.add(entry); + concurrentlyExecutingChildren.add(entry); } } - return concurrentlyExecutedChildren; + return concurrentlyExecutingChildren; } - private List forkConcurrentChildren(List children, + private Collection forkConcurrentChildren(List children, Consumer isolatedTaskCollector, Consumer sameThreadTaskCollector) { if (children.isEmpty()) { return List.of(); } - List queueEntries = new ArrayList<>(children.size()); + Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); for (TestTask child : children) { if (requiresGlobalReadWriteLock(child)) { isolatedTaskCollector.accept(child); From 1055dbb92802cf0b88f88ad3335dacb7a7677bbd Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 08:57:01 +0200 Subject: [PATCH 036/120] Prioritize tests over containers --- ...urrentHierarchicalTestExecutorService.java | 10 ++++- ...tHierarchicalTestExecutorServiceTests.java | 42 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 67229e30b795..fc5375f6785d 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -473,10 +473,18 @@ Entry incrementAttempts() { public int compareTo(Entry that) { var result = Integer.compare(that.level, this.level); if (result == 0) { - return Integer.compare(that.attempts, this.attempts); + result = Boolean.compare(this.isContainer(), that.isContainer()); + if (result == 0) { + result = Integer.compare(that.attempts, this.attempts); + } } return result; } + + private boolean isContainer() { + return task.getTestDescriptor().isContainer(); + } + } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 999d6d5efd6e..9a859cc40fb2 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -16,6 +16,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; +import static org.junit.platform.engine.TestDescriptor.Type.CONTAINER; +import static org.junit.platform.engine.TestDescriptor.Type.TEST; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; @@ -245,6 +247,33 @@ void prioritizesChildrenOfStartedContainers() throws Exception { assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); } + @Test + void prioritizesTestsOverContainers() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + + var leavesStarted = new CountDownLatch(2); + var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // + .withName("leaf").withLevel(3).withType(TEST); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // + .withName("child1").withLevel(2).withType(CONTAINER); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // + .withName("child2").withLevel(2).withType(TEST); + var child3 = new TestTaskStub(ExecutionMode.SAME_THREAD, leavesStarted::await) // + .withName("child3").withLevel(2).withType(TEST); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2, child3))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(List.of(child1, child2, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + leaf.assertExecutedSuccessfully(); + + assertThat(child2.startTime).isBeforeOrEqualTo(child1.startTime); + } + private static ExclusiveResource exclusiveResource() { return new ExclusiveResource("key", ExclusiveResource.LockMode.READ_WRITE); } @@ -267,6 +296,7 @@ private static final class TestTaskStub implements TestTask { private ResourceLock resourceLock = NopLock.INSTANCE; private @Nullable String name; private int level = 1; + private TestDescriptor.Type type = TEST; private final CompletableFuture<@Nullable Void> result = new CompletableFuture<>(); private volatile @Nullable Instant startTime; @@ -292,6 +322,11 @@ TestTaskStub withLevel(int level) { return this; } + TestTaskStub withType(TestDescriptor.Type type) { + this.type = type; + return this; + } + TestTaskStub withResourceLock(ResourceLock resourceLock) { this.resourceLock = resourceLock; return this; @@ -314,7 +349,12 @@ public TestDescriptor getTestDescriptor() { for (var i = 1; i < level; i++) { uniqueId = uniqueId.append("child", name); } - return new TestDescriptorStub(uniqueId, name); + return new TestDescriptorStub(uniqueId, name) { + @Override + public Type getType() { + return type; + } + }; } @Override From a545da0d5aab76522e8ddf878b26d5976f7f476a Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 09:07:49 +0200 Subject: [PATCH 037/120] Move `invokeAll()`` code to `WorkerThread` --- ...urrentHierarchicalTestExecutorService.java | 274 +++++++++--------- 1 file changed, 140 insertions(+), 134 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index fc5375f6785d..f0b8a023c338 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -101,44 +101,7 @@ public void invokeAll(List testTasks) { Preconditions.condition(workerThread != null && workerThread.executor() == this, "invokeAll() must be called from a worker thread that belongs to this executor"); - if (testTasks.isEmpty()) { - return; - } - - if (testTasks.size() == 1) { - executeTask(testTasks.get(0)); - return; - } - - List isolatedTasks = new ArrayList<>(testTasks.size()); - List sameThreadTasks = new ArrayList<>(testTasks.size()); - var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks::add); - executeAll(sameThreadTasks); - var remainingForkedChildren = stealWork(queueEntries); - waitFor(remainingForkedChildren); - executeAll(isolatedTasks); - } - - private static void waitFor(List children) { - if (children.isEmpty()) { - return; - } - var future = toCombinedFuture(children); - try { - if (future.isDone()) { - // no need to release worker lease - future.join(); - } - else { - WorkerThread.getOrThrow().runBlocking(() -> { - LOGGER.trace(() -> "blocking for forked children: " + children); - return future.join(); - }); - } - } - catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + workerThread.invokeAll(testTasks); } private WorkQueue.Entry enqueue(TestTask testTask) { @@ -170,101 +133,6 @@ private void maybeStartWorker() { } } - private static CompletableFuture toCombinedFuture(List entries) { - if (entries.size() == 1) { - return entries.get(0).future(); - } - var futures = entries.stream().map(WorkQueue.Entry::future).toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures); - } - - private List stealWork(Collection queueEntries) { - if (queueEntries.isEmpty()) { - return List.of(); - } - List concurrentlyExecutingChildren = new ArrayList<>(queueEntries.size()); - for (var entry : queueEntries) { - var claimed = workQueue.remove(entry); - if (claimed) { - LOGGER.trace(() -> "stole work: " + entry); - var executed = tryExecute(entry); - if (!executed) { - workQueue.add(entry); - concurrentlyExecutingChildren.add(entry); - } - } - else { - concurrentlyExecutingChildren.add(entry); - } - } - return concurrentlyExecutingChildren; - } - - private Collection forkConcurrentChildren(List children, - Consumer isolatedTaskCollector, Consumer sameThreadTaskCollector) { - - if (children.isEmpty()) { - return List.of(); - } - Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); - for (TestTask child : children) { - if (requiresGlobalReadWriteLock(child)) { - isolatedTaskCollector.accept(child); - } - else if (child.getExecutionMode() == SAME_THREAD) { - sameThreadTaskCollector.accept(child); - } - else { - queueEntries.add(enqueue(child)); - } - } - return queueEntries; - } - - private static boolean requiresGlobalReadWriteLock(TestTask testTask) { - return testTask.getResourceLock().getResources().contains(GLOBAL_READ_WRITE); - } - - private void executeAll(List children) { - if (children.isEmpty()) { - return; - } - LOGGER.trace(() -> "running %d SAME_THREAD children".formatted(children.size())); - if (children.size() == 1) { - executeTask(children.get(0)); - return; - } - for (var testTask : children) { - executeTask(testTask); - } - } - - private static boolean tryExecute(WorkQueue.Entry entry) { - try { - var executed = tryExecuteTask(entry.task); - if (executed) { - entry.future.complete(null); - } - return executed; - } - catch (Throwable t) { - entry.future.completeExceptionally(t); - return true; - } - } - - private void executeEntry(WorkQueue.Entry entry) { - try { - executeTask(entry.task); - } - catch (Throwable t) { - entry.future.completeExceptionally(t); - } - finally { - entry.future.complete(null); - } - } - @SuppressWarnings("try") private void executeTask(TestTask testTask) { var executed = tryExecuteTask(testTask); @@ -363,7 +231,7 @@ void processQueueEntries() { } LOGGER.trace(() -> "processing: " + entry.task); this.workerLease = workerLease; - executeEntry(entry); + execute(entry); } } } @@ -384,6 +252,144 @@ T runBlocking(BlockingAction blockingAction) throws InterruptedException } } + void invokeAll(List testTasks) { + + if (testTasks.isEmpty()) { + return; + } + + if (testTasks.size() == 1) { + executeTask(testTasks.get(0)); + return; + } + + List isolatedTasks = new ArrayList<>(testTasks.size()); + List sameThreadTasks = new ArrayList<>(testTasks.size()); + var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks::add); + executeAll(sameThreadTasks); + var remainingForkedChildren = stealWork(queueEntries); + waitFor(remainingForkedChildren); + executeAll(isolatedTasks); + } + + private Collection forkConcurrentChildren(List children, + Consumer isolatedTaskCollector, Consumer sameThreadTaskCollector) { + + if (children.isEmpty()) { + return List.of(); + } + + Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); + for (TestTask child : children) { + if (requiresGlobalReadWriteLock(child)) { + isolatedTaskCollector.accept(child); + } + else if (child.getExecutionMode() == SAME_THREAD) { + sameThreadTaskCollector.accept(child); + } + else { + queueEntries.add(enqueue(child)); + } + } + return queueEntries; + } + + private static CompletableFuture toCombinedFuture(List entries) { + if (entries.size() == 1) { + return entries.get(0).future(); + } + var futures = entries.stream().map(WorkQueue.Entry::future).toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + } + + private List stealWork(Collection queueEntries) { + if (queueEntries.isEmpty()) { + return List.of(); + } + List concurrentlyExecutingChildren = new ArrayList<>(queueEntries.size()); + for (var entry : queueEntries) { + var claimed = workQueue.remove(entry); + if (claimed) { + LOGGER.trace(() -> "stole work: " + entry); + var executed = tryExecute(entry); + if (!executed) { + workQueue.add(entry); + concurrentlyExecutingChildren.add(entry); + } + } + else { + concurrentlyExecutingChildren.add(entry); + } + } + return concurrentlyExecutingChildren; + } + + private void waitFor(List children) { + if (children.isEmpty()) { + return; + } + var future = toCombinedFuture(children); + try { + if (future.isDone()) { + // no need to release worker lease + future.join(); + } + else { + runBlocking(() -> { + LOGGER.trace(() -> "blocking for forked children: " + children); + return future.join(); + }); + } + } + catch (InterruptedException e) { + currentThread().interrupt(); + } + } + + private static boolean requiresGlobalReadWriteLock(TestTask testTask) { + return testTask.getResourceLock().getResources().contains(GLOBAL_READ_WRITE); + } + + private void executeAll(List children) { + if (children.isEmpty()) { + return; + } + LOGGER.trace(() -> "running %d SAME_THREAD children".formatted(children.size())); + if (children.size() == 1) { + executeTask(children.get(0)); + return; + } + for (var testTask : children) { + executeTask(testTask); + } + } + + private static boolean tryExecute(WorkQueue.Entry entry) { + try { + var executed = tryExecuteTask(entry.task); + if (executed) { + entry.future.complete(null); + } + return executed; + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + return true; + } + } + + private void execute(WorkQueue.Entry entry) { + try { + executeTask(entry.task); + } + catch (Throwable t) { + entry.future.completeExceptionally(t); + } + finally { + entry.future.complete(null); + } + } + interface BlockingAction { T run() throws InterruptedException; } From 1196b178a76fc9c1512615df7b39e3ad63a997b9 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 10:03:23 +0200 Subject: [PATCH 038/120] Hold back one task for the current worker when forking --- ...urrentHierarchicalTestExecutorService.java | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index f0b8a023c338..42ec759f1497 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -56,6 +56,7 @@ public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTe private final WorkQueue workQueue = new WorkQueue(); private final ExecutorService threadPool; + private final int parallelism; private final WorkerLeaseManager workerLeaseManager; public ConcurrentHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { @@ -70,7 +71,8 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration ThreadFactory threadFactory = new WorkerThreadFactory(classLoader); threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); - workerLeaseManager = new WorkerLeaseManager(configuration.getParallelism()); + parallelism = configuration.getParallelism(); + workerLeaseManager = new WorkerLeaseManager(parallelism); LOGGER.trace(() -> "initialized thread pool for parallelism of " + configuration.getParallelism()); } @@ -110,6 +112,14 @@ private WorkQueue.Entry enqueue(TestTask testTask) { return entry; } + private void forkAll(Collection entries) { + workQueue.addAll(entries); + // start at most (parallelism - 1) new workers as this method is called from a worker thread holding a lease + for (int i = 1; i < parallelism; i++) { + maybeStartWorker(); + } + } + private void maybeStartWorker() { if (threadPool.isShutdown() || !workerLeaseManager.isLeaseAvailable() || workQueue.isEmpty()) { return; @@ -265,7 +275,7 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks::add); + var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); var remainingForkedChildren = stealWork(queueEntries); waitFor(remainingForkedChildren); @@ -273,11 +283,7 @@ void invokeAll(List testTasks) { } private Collection forkConcurrentChildren(List children, - Consumer isolatedTaskCollector, Consumer sameThreadTaskCollector) { - - if (children.isEmpty()) { - return List.of(); - } + Consumer isolatedTaskCollector, List sameThreadTasks) { Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); for (TestTask child : children) { @@ -285,12 +291,19 @@ private Collection forkConcurrentChildren(List stealWork(Collection queueEntries LOGGER.trace(() -> "stole work: " + entry); var executed = tryExecute(entry); if (!executed) { - workQueue.add(entry); + workQueue.reAdd(entry); concurrentlyExecutingChildren.add(entry); } } @@ -354,7 +367,7 @@ private void executeAll(List children) { if (children.isEmpty()) { return; } - LOGGER.trace(() -> "running %d SAME_THREAD children".formatted(children.size())); + LOGGER.trace(() -> "running %d children directly".formatted(children.size())); if (children.size() == 1) { executeTask(children.get(0)); return; @@ -426,12 +439,16 @@ private static class WorkQueue { private final Queue queue = new PriorityBlockingQueue<>(); Entry add(TestTask task) { - LOGGER.trace(() -> "forking: " + task); - int level = task.getTestDescriptor().getUniqueId().getSegments().size(); - return doAdd(new Entry(task, new CompletableFuture<>(), level, 0)); + Entry entry = Entry.create(task); + LOGGER.trace(() -> "forking: " + entry.task); + return doAdd(entry); + } + + void addAll(Collection entries) { + entries.forEach(this::doAdd); } - void add(Entry entry) { + void reAdd(Entry entry) { LOGGER.trace(() -> "re-enqueuing: " + entry.task); doAdd(entry.incrementAttempts()); } @@ -459,6 +476,12 @@ boolean isEmpty() { private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, int attempts) implements Comparable { + + static Entry create(TestTask task) { + int level = task.getTestDescriptor().getUniqueId().getSegments().size(); + return new Entry(task, new CompletableFuture<>(), level, 0); + } + @SuppressWarnings("FutureReturnValueIgnored") Entry { future.whenComplete((__, t) -> { From e0bc61b624e80e03a89dd202996893d77833a900 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 12:17:11 +0200 Subject: [PATCH 039/120] Implement work stealing for dynamic children --- .../hierarchical/BlockingAwareFuture.java | 12 +- ...urrentHierarchicalTestExecutorService.java | 212 +++++++++++------- ...tHierarchicalTestExecutorServiceTests.java | 1 + 3 files changed, 141 insertions(+), 84 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java index 639e588995e7..06996fca0265 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java @@ -17,6 +17,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; import org.jspecify.annotations.Nullable; @@ -31,7 +32,7 @@ class BlockingAwareFuture extends DelegatingFuture delegate.get(timeout, unit)); } private T handle(Callable callable) { try { - return handler.handle(callable); + return handler.handle(delegate::isDone, callable); } catch (Exception e) { throw throwAsUncheckedException(e); @@ -56,7 +57,8 @@ private T handle(Callable callable) { interface BlockHandler { - T handle(Callable callable) throws Exception; + T handle(Supplier blockingUnnecessary, Callable callable) + throws Exception; } } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 42ec759f1497..e6df672135f3 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -19,8 +19,10 @@ import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; +import java.util.Deque; import java.util.List; import java.util.PriorityQueue; import java.util.Queue; @@ -85,14 +87,20 @@ public void close() { @Override public Future<@Nullable Void> submit(TestTask testTask) { LOGGER.trace(() -> "submit: " + testTask); - if (WorkerThread.get() == null) { + + var workerThread = WorkerThread.get(); + if (workerThread == null) { return enqueue(testTask).future(); } + if (testTask.getExecutionMode() == SAME_THREAD) { - executeTask(testTask); + workerThread.executeTask(testTask); return completedFuture(null); } - return new BlockingAwareFuture<@Nullable Void>(enqueue(testTask).future(), WorkerThread.BlockHandler.INSTANCE); + + var entry = enqueue(testTask); + workerThread.addForkedChild(entry); + return new BlockingAwareFuture<@Nullable Void>(entry.future(), WorkerThread.BlockHandler.INSTANCE); } @Override @@ -143,45 +151,6 @@ private void maybeStartWorker() { } } - @SuppressWarnings("try") - private void executeTask(TestTask testTask) { - var executed = tryExecuteTask(testTask); - if (!executed) { - var resourceLock = testTask.getResourceLock(); - var workerThread = WorkerThread.getOrThrow(); - try (var ignored = workerThread.runBlocking(() -> { - LOGGER.trace(() -> "blocking for resource lock: " + resourceLock); - return resourceLock.acquire(); - })) { - doExecute(testTask); - } - catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } - } - - private static boolean tryExecuteTask(TestTask testTask) { - var resourceLock = testTask.getResourceLock(); - if (resourceLock.tryAcquire()) { - try (resourceLock) { - doExecute(testTask); - return true; - } - } - return false; - } - - private static void doExecute(TestTask testTask) { - LOGGER.trace(() -> "executing: " + testTask); - try { - testTask.execute(); - } - finally { - LOGGER.trace(() -> "finished executing: " + testTask); - } - } - private class WorkerThreadFactory implements ThreadFactory { private static final AtomicInteger POOL_NUMBER = new AtomicInteger(1); @@ -206,6 +175,8 @@ public Thread newThread(Runnable runnable) { private class WorkerThread extends Thread { + private final Deque state = new ArrayDeque<>(); + @Nullable WorkerLease workerLease; @@ -228,6 +199,10 @@ static WorkerThread getOrThrow() { return workerThread; } + ConcurrentHierarchicalTestExecutorService executor() { + return ConcurrentHierarchicalTestExecutorService.this; + } + void processQueueEntries() { while (!threadPool.isShutdown()) { var workerLease = workerLeaseManager.tryAcquire(); @@ -275,15 +250,19 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var remainingForkedChildren = stealWork(queueEntries); + var remainingForkedChildren = stealWork(); waitFor(remainingForkedChildren); executeAll(isolatedTasks); } - private Collection forkConcurrentChildren(List children, - Consumer isolatedTaskCollector, List sameThreadTasks) { + void addForkedChild(WorkQueue.Entry entry) { + getForkedChildren().add(entry); + } + + private void forkConcurrentChildren(List children, Consumer isolatedTaskCollector, + List sameThreadTasks) { Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); for (TestTask child : children) { @@ -303,40 +282,44 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(queueEntries.poll().task); } forkAll(queueEntries); + getForkedChildren().addAll(queueEntries); } - return queueEntries; } - private static CompletableFuture toCombinedFuture(List entries) { - if (entries.size() == 1) { - return entries.get(0).future(); - } - var futures = entries.stream().map(WorkQueue.Entry::future).toArray(CompletableFuture[]::new); - return CompletableFuture.allOf(futures); - } - - private List stealWork(Collection queueEntries) { - if (queueEntries.isEmpty()) { - return List.of(); - } - List concurrentlyExecutingChildren = new ArrayList<>(queueEntries.size()); - for (var entry : queueEntries) { - var claimed = workQueue.remove(entry); - if (claimed) { - LOGGER.trace(() -> "stole work: " + entry); - var executed = tryExecute(entry); - if (!executed) { - workQueue.reAdd(entry); - concurrentlyExecutingChildren.add(entry); - } + private List stealWork() { + var forkedChildren = getForkedChildren(); + List concurrentlyExecutingChildren = new ArrayList<>(forkedChildren.size()); + WorkQueue.Entry entry; + while ((entry = forkedChildren.poll()) != null) { + if (entry.future.isDone()) { + concurrentlyExecutingChildren.add(entry); } else { - concurrentlyExecutingChildren.add(entry); + var claimed = workQueue.remove(entry); + if (claimed) { + var executed = tryExecuteStolenEntry(entry); + if (!executed) { + workQueue.reAdd(entry); + concurrentlyExecutingChildren.add(entry); + } + } + else { + concurrentlyExecutingChildren.add(entry); + } } } return concurrentlyExecutingChildren; } + private Queue getForkedChildren() { + return currentState().forkedChildren; + } + + private boolean tryExecuteStolenEntry(WorkQueue.Entry entry) { + LOGGER.trace(() -> "stole work: " + entry); + return tryExecute(entry); + } + private void waitFor(List children) { if (children.isEmpty()) { return; @@ -349,7 +332,8 @@ private void waitFor(List children) { } else { runBlocking(() -> { - LOGGER.trace(() -> "blocking for forked children: " + children); + LOGGER.trace(() -> "blocking for forked children of %s: %s".formatted( + currentState().executingTask, children)); return future.join(); }); } @@ -367,7 +351,8 @@ private void executeAll(List children) { if (children.isEmpty()) { return; } - LOGGER.trace(() -> "running %d children directly".formatted(children.size())); + LOGGER.trace( + () -> "running %d children of %s directly".formatted(children.size(), currentState().executingTask)); if (children.size() == 1) { executeTask(children.get(0)); return; @@ -377,7 +362,7 @@ private void executeAll(List children) { } } - private static boolean tryExecute(WorkQueue.Entry entry) { + private boolean tryExecute(WorkQueue.Entry entry) { try { var executed = tryExecuteTask(entry.task); if (executed) { @@ -403,12 +388,77 @@ private void execute(WorkQueue.Entry entry) { } } - interface BlockingAction { - T run() throws InterruptedException; + @SuppressWarnings("try") + private void executeTask(TestTask testTask) { + var executed = tryExecuteTask(testTask); + if (!executed) { + var resourceLock = testTask.getResourceLock(); + try (var ignored = runBlocking(() -> { + LOGGER.trace(() -> "blocking for resource lock: " + resourceLock); + return resourceLock.acquire(); + })) { + LOGGER.trace(() -> "acquired resource lock: " + resourceLock); + doExecute(testTask); + } + catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + finally { + LOGGER.trace(() -> "released resource lock: " + resourceLock); + } + } } - private ConcurrentHierarchicalTestExecutorService executor() { - return ConcurrentHierarchicalTestExecutorService.this; + private boolean tryExecuteTask(TestTask testTask) { + var resourceLock = testTask.getResourceLock(); + if (resourceLock.tryAcquire()) { + LOGGER.trace(() -> "acquired resource lock: " + resourceLock); + try (resourceLock) { + doExecute(testTask); + return true; + } + finally { + LOGGER.trace(() -> "released resource lock: " + resourceLock); + } + } + else { + LOGGER.trace(() -> "failed to acquire resource lock: " + resourceLock); + } + return false; + } + + private void doExecute(TestTask testTask) { + LOGGER.trace(() -> "executing: " + testTask); + this.state.push(new State(testTask)); + try { + testTask.execute(); + } + finally { + this.state.pop(); + LOGGER.trace(() -> "finished executing: " + testTask); + } + } + + private State currentState() { + return state.element(); + } + + private static CompletableFuture toCombinedFuture(List entries) { + if (entries.size() == 1) { + return entries.get(0).future(); + } + var futures = entries.stream().map(WorkQueue.Entry::future).toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + } + + private record State(TestTask executingTask, Queue forkedChildren) { + State(TestTask executingTask) { + this(executingTask, new PriorityQueue<>(reverseOrder())); + } + } + + private interface BlockingAction { + T run() throws InterruptedException; } private static class BlockHandler implements BlockingAwareFuture.BlockHandler { @@ -416,9 +466,13 @@ private static class BlockHandler implements BlockingAwareFuture.BlockHandler { private static final BlockHandler INSTANCE = new BlockHandler(); @Override - public T handle(Callable callable) throws Exception { + public T handle(Supplier blockingUnnecessary, Callable callable) throws Exception { var workerThread = get(); - if (workerThread == null) { + if (workerThread == null || blockingUnnecessary.get()) { + return callable.call(); + } + workerThread.stealWork(); + if (blockingUnnecessary.get()) { return callable.call(); } LOGGER.trace(() -> "blocking for child task"); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 9a859cc40fb2..d24e0eccb848 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -245,6 +245,7 @@ void prioritizesChildrenOfStartedContainers() throws Exception { leaf.assertExecutedSuccessfully(); assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); + assertThat(leaf.executionThread).isSameAs(child1.executionThread); } @Test From 261ac0e45d59ca02cacdfad3043d54156cd1dbb4 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 12:36:29 +0200 Subject: [PATCH 040/120] Limit work stealing of dynamic children to current entry --- ...urrentHierarchicalTestExecutorService.java | 96 +++++++------------ 1 file changed, 34 insertions(+), 62 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index e6df672135f3..8f71bd8bcc00 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -19,10 +19,8 @@ import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; -import java.util.Deque; import java.util.List; import java.util.PriorityQueue; import java.util.Queue; @@ -99,8 +97,7 @@ public void close() { } var entry = enqueue(testTask); - workerThread.addForkedChild(entry); - return new BlockingAwareFuture<@Nullable Void>(entry.future(), WorkerThread.BlockHandler.INSTANCE); + return new BlockingAwareFuture<@Nullable Void>(entry.future(), new WorkerThread.BlockHandler(entry)); } @Override @@ -175,8 +172,6 @@ public Thread newThread(Runnable runnable) { private class WorkerThread extends Thread { - private final Deque state = new ArrayDeque<>(); - @Nullable WorkerLease workerLease; @@ -250,19 +245,15 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + var concurrentTasks = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var remainingForkedChildren = stealWork(); + var remainingForkedChildren = stealWork(concurrentTasks); waitFor(remainingForkedChildren); executeAll(isolatedTasks); } - void addForkedChild(WorkQueue.Entry entry) { - getForkedChildren().add(entry); - } - - private void forkConcurrentChildren(List children, Consumer isolatedTaskCollector, - List sameThreadTasks) { + private Queue forkConcurrentChildren(List children, + Consumer isolatedTaskCollector, List sameThreadTasks) { Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); for (TestTask child : children) { @@ -276,48 +267,44 @@ else if (child.getExecutionMode() == SAME_THREAD) { queueEntries.add(WorkQueue.Entry.create(child)); } } + if (!queueEntries.isEmpty()) { if (sameThreadTasks.isEmpty()) { // hold back one task for this thread sameThreadTasks.add(queueEntries.poll().task); } forkAll(queueEntries); - getForkedChildren().addAll(queueEntries); } + + return queueEntries; } - private List stealWork() { - var forkedChildren = getForkedChildren(); - List concurrentlyExecutingChildren = new ArrayList<>(forkedChildren.size()); + private List stealWork(Queue concurrentTasks) { + List concurrentlyExecutingChildren = new ArrayList<>(concurrentTasks.size()); WorkQueue.Entry entry; - while ((entry = forkedChildren.poll()) != null) { - if (entry.future.isDone()) { + while ((entry = concurrentTasks.poll()) != null) { + var executed = tryToStealWork(entry); + if (!executed) { concurrentlyExecutingChildren.add(entry); } - else { - var claimed = workQueue.remove(entry); - if (claimed) { - var executed = tryExecuteStolenEntry(entry); - if (!executed) { - workQueue.reAdd(entry); - concurrentlyExecutingChildren.add(entry); - } - } - else { - concurrentlyExecutingChildren.add(entry); - } - } } return concurrentlyExecutingChildren; } - private Queue getForkedChildren() { - return currentState().forkedChildren; - } - - private boolean tryExecuteStolenEntry(WorkQueue.Entry entry) { - LOGGER.trace(() -> "stole work: " + entry); - return tryExecute(entry); + private boolean tryToStealWork(WorkQueue.Entry entry) { + if (entry.future.isDone()) { + return false; + } + var claimed = workQueue.remove(entry); + if (claimed) { + LOGGER.trace(() -> "stole work: " + entry); + var executed = tryExecute(entry); + if (!executed) { + workQueue.reAdd(entry); + } + return executed; + } + return false; } private void waitFor(List children) { @@ -332,8 +319,7 @@ private void waitFor(List children) { } else { runBlocking(() -> { - LOGGER.trace(() -> "blocking for forked children of %s: %s".formatted( - currentState().executingTask, children)); + LOGGER.trace(() -> "blocking for forked children : %s".formatted(children)); return future.join(); }); } @@ -351,8 +337,7 @@ private void executeAll(List children) { if (children.isEmpty()) { return; } - LOGGER.trace( - () -> "running %d children of %s directly".formatted(children.size(), currentState().executingTask)); + LOGGER.trace(() -> "running %d children directly".formatted(children.size())); if (children.size() == 1) { executeTask(children.get(0)); return; @@ -429,20 +414,14 @@ private boolean tryExecuteTask(TestTask testTask) { private void doExecute(TestTask testTask) { LOGGER.trace(() -> "executing: " + testTask); - this.state.push(new State(testTask)); try { testTask.execute(); } finally { - this.state.pop(); LOGGER.trace(() -> "finished executing: " + testTask); } } - private State currentState() { - return state.element(); - } - private static CompletableFuture toCombinedFuture(List entries) { if (entries.size() == 1) { return entries.get(0).future(); @@ -451,28 +430,20 @@ private static CompletableFuture toCombinedFuture(List entri return CompletableFuture.allOf(futures); } - private record State(TestTask executingTask, Queue forkedChildren) { - State(TestTask executingTask) { - this(executingTask, new PriorityQueue<>(reverseOrder())); - } - } - private interface BlockingAction { T run() throws InterruptedException; } - private static class BlockHandler implements BlockingAwareFuture.BlockHandler { - - private static final BlockHandler INSTANCE = new BlockHandler(); + private record BlockHandler(WorkQueue.Entry entry) implements BlockingAwareFuture.BlockHandler { @Override public T handle(Supplier blockingUnnecessary, Callable callable) throws Exception { var workerThread = get(); - if (workerThread == null || blockingUnnecessary.get()) { + if (workerThread == null || entry.future.isDone()) { return callable.call(); } - workerThread.stealWork(); - if (blockingUnnecessary.get()) { + workerThread.tryToStealWork(entry); + if (entry.future.isDone()) { return callable.call(); } LOGGER.trace(() -> "blocking for child task"); @@ -486,6 +457,7 @@ public T handle(Supplier blockingUnnecessary, Callable callable) }); } } + } private static class WorkQueue { From cf923fca1f9cc8402bad21ea973be03d17f28d4c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 12:41:33 +0200 Subject: [PATCH 041/120] Polishing --- .../ConcurrentHierarchicalTestExecutorService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 8f71bd8bcc00..e2c668a2a230 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -245,9 +245,9 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - var concurrentTasks = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + var allForkedChildren = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var remainingForkedChildren = stealWork(concurrentTasks); + var remainingForkedChildren = stealWork(allForkedChildren); waitFor(remainingForkedChildren); executeAll(isolatedTasks); } @@ -279,10 +279,10 @@ else if (child.getExecutionMode() == SAME_THREAD) { return queueEntries; } - private List stealWork(Queue concurrentTasks) { - List concurrentlyExecutingChildren = new ArrayList<>(concurrentTasks.size()); + private List stealWork(Queue forkedChildren) { + List concurrentlyExecutingChildren = new ArrayList<>(forkedChildren.size()); WorkQueue.Entry entry; - while ((entry = concurrentTasks.poll()) != null) { + while ((entry = forkedChildren.poll()) != null) { var executed = tryToStealWork(entry); if (!executed) { concurrentlyExecutingChildren.add(entry); From afbb9f663934722dc42f52564c260fcb6624fa03 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 12:51:13 +0200 Subject: [PATCH 042/120] Simplify work-stealing `Future` implementation --- .../hierarchical/BlockingAwareFuture.java | 22 +++------ ...urrentHierarchicalTestExecutorService.java | 48 +++++++++++-------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java index 06996fca0265..9ab49765fb8a 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java @@ -17,17 +17,13 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; -import java.util.function.Supplier; import org.jspecify.annotations.Nullable; -class BlockingAwareFuture extends DelegatingFuture { +abstract class BlockingAwareFuture extends DelegatingFuture { - private final BlockHandler handler; - - BlockingAwareFuture(Future delegate, BlockHandler handler) { + BlockingAwareFuture(Future delegate) { super(delegate); - this.handler = handler; } @Override @@ -35,7 +31,7 @@ public T get() throws InterruptedException, ExecutionException { if (delegate.isDone()) { return delegate.get(); } - return handle(delegate::get); + return handleSafely(delegate::get); } @Override @@ -43,22 +39,18 @@ public T get(long timeout, TimeUnit unit) throws InterruptedException, Execution if (delegate.isDone()) { return delegate.get(); } - return handle(() -> delegate.get(timeout, unit)); + return handleSafely(() -> delegate.get(timeout, unit)); } - private T handle(Callable callable) { + private T handleSafely(Callable callable) { try { - return handler.handle(delegate::isDone, callable); + return handle(callable); } catch (Exception e) { throw throwAsUncheckedException(e); } } - interface BlockHandler { - - T handle(Supplier blockingUnnecessary, Callable callable) - throws Exception; + protected abstract T handle(Callable callable) throws Exception; - } } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index e2c668a2a230..23a2645ec66c 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -96,8 +96,7 @@ public void close() { return completedFuture(null); } - var entry = enqueue(testTask); - return new BlockingAwareFuture<@Nullable Void>(entry.future(), new WorkerThread.BlockHandler(entry)); + return new WorkStealingFuture(enqueue(testTask)); } @Override @@ -434,28 +433,36 @@ private interface BlockingAction { T run() throws InterruptedException; } - private record BlockHandler(WorkQueue.Entry entry) implements BlockingAwareFuture.BlockHandler { + } - @Override - public T handle(Supplier blockingUnnecessary, Callable callable) throws Exception { - var workerThread = get(); - if (workerThread == null || entry.future.isDone()) { + private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Void> { + + private final WorkQueue.Entry entry; + + WorkStealingFuture(WorkQueue.Entry entry) { + super(entry.future); + this.entry = entry; + } + + @Override + protected @Nullable Void handle(Callable<@Nullable Void> callable) throws Exception { + var workerThread = WorkerThread.get(); + if (workerThread == null || entry.future.isDone()) { + return callable.call(); + } + workerThread.tryToStealWork(entry); + if (entry.future.isDone()) { + return callable.call(); + } + LOGGER.trace(() -> "blocking for child task"); + return workerThread.runBlocking(() -> { + try { return callable.call(); } - workerThread.tryToStealWork(entry); - if (entry.future.isDone()) { - return callable.call(); + catch (Exception ex) { + throw throwAsUncheckedException(ex); } - LOGGER.trace(() -> "blocking for child task"); - return workerThread.runBlocking(() -> { - try { - return callable.call(); - } - catch (Exception ex) { - throw throwAsUncheckedException(ex); - } - }); - } + }); } } @@ -612,5 +619,4 @@ void reacquire() throws InterruptedException { reacquisitionToken = null; } } - } From a8cec38c2a4cb2305717fd4216b97a74f386804c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 12:59:28 +0200 Subject: [PATCH 043/120] Restore max pool size limit in test --- .../support/hierarchical/ParallelExecutionIntegrationTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index dca22c4c962d..492a561adef8 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -267,7 +267,7 @@ void canRunTestsIsolatedFromEachOtherAcrossClassesWithOtherResourceLocks() { void runsIsolatedTestsLastToMaximizeParallelism() { var configParams = Map.of( // DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent", // - PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME, "4" // + PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME, "3" // ); Class[] testClasses = { IsolatedTestCase.class, SuccessfulParallelTestCase.class }; var events = executeWithFixedParallelism(3, configParams, testClasses) // From 6103e4d62efd11816b92ea3cffb5e2be72887b5b Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 13:01:37 +0200 Subject: [PATCH 044/120] Avoid starting an excessive number of threads --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 23a2645ec66c..9cf9459f61b1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -119,7 +119,7 @@ private WorkQueue.Entry enqueue(TestTask testTask) { private void forkAll(Collection entries) { workQueue.addAll(entries); // start at most (parallelism - 1) new workers as this method is called from a worker thread holding a lease - for (int i = 1; i < parallelism; i++) { + for (int i = 0; i < Math.min(parallelism - 1, entries.size()); i++) { maybeStartWorker(); } } From 855880f69bf043dc360f03dc4d2f94ad766abcc4 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 14 Oct 2025 13:06:40 +0200 Subject: [PATCH 045/120] Polishing --- .../ConcurrentHierarchicalTestExecutorService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 9cf9459f61b1..d7ea51ce5414 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -117,6 +117,9 @@ private WorkQueue.Entry enqueue(TestTask testTask) { } private void forkAll(Collection entries) { + if (entries.isEmpty()) { + return; + } workQueue.addAll(entries); // start at most (parallelism - 1) new workers as this method is called from a worker thread holding a lease for (int i = 0; i < Math.min(parallelism - 1, entries.size()); i++) { From d47f9fc461d81590a17ebb2ca9a0a06f53725ac3 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 16 Oct 2025 12:22:15 +0200 Subject: [PATCH 046/120] Add test for `WorkerLeaseManager` and `WorkerLease` --- ...urrentHierarchicalTestExecutorService.java | 14 ++--- .../hierarchical/WorkerLeaseManagerTests.java | 54 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index d7ea51ce5414..ccfcc69f2b35 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -72,7 +72,7 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); parallelism = configuration.getParallelism(); - workerLeaseManager = new WorkerLeaseManager(parallelism); + workerLeaseManager = new WorkerLeaseManager(parallelism, this::maybeStartWorker); LOGGER.trace(() -> "initialized thread pool for parallelism of " + configuration.getParallelism()); } @@ -554,12 +554,14 @@ private boolean isContainer() { } - private class WorkerLeaseManager { + static class WorkerLeaseManager { private final Semaphore semaphore; + private final Runnable onRelease; - WorkerLeaseManager(int parallelism) { - semaphore = new Semaphore(parallelism); + WorkerLeaseManager(int parallelism, Runnable onRelease) { + this.semaphore = new Semaphore(parallelism); + this.onRelease = onRelease; } @Nullable @@ -575,7 +577,7 @@ WorkerLease tryAcquire() { private ReacquisitionToken release() { semaphore.release(); LOGGER.trace(() -> "release worker lease (available: %d)".formatted(semaphore.availablePermits())); - maybeStartWorker(); + onRelease.run(); return new ReacquisitionToken(); } @@ -596,7 +598,7 @@ void reacquire() throws InterruptedException { } } - private static class WorkerLease implements AutoCloseable { + static class WorkerLease implements AutoCloseable { private final Supplier releaseAction; private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java new file mode 100644 index 000000000000..1a8978ec21a7 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java @@ -0,0 +1,54 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService.WorkerLeaseManager; + +class WorkerLeaseManagerTests { + + @Test + void releasingIsIdempotent() { + var released = new AtomicInteger(); + var manager = new WorkerLeaseManager(1, released::incrementAndGet); + + var lease = manager.tryAcquire(); + assertThat(lease).isNotNull(); + + lease.close(); + assertThat(released.get()).isEqualTo(1); + + lease.close(); + assertThat(released.get()).isEqualTo(1); + } + + @Test + void leaseCanBeReacquired() throws Exception { + var released = new AtomicInteger(); + var manager = new WorkerLeaseManager(1, released::incrementAndGet); + + var lease = manager.tryAcquire(); + assertThat(lease).isNotNull(); + + lease.close(); + assertThat(released.get()).isEqualTo(1); + + lease.reacquire(); + assertThat(released.get()).isEqualTo(1); + + lease.close(); + assertThat(released.get()).isEqualTo(2); + } +} From ba9838334de5b12819c93cf4eec821b473b7c565 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 16 Oct 2025 12:23:28 +0200 Subject: [PATCH 047/120] Avoid race during worker startup --- ...urrentHierarchicalTestExecutorService.java | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index ccfcc69f2b35..26892f1809b0 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -128,21 +128,29 @@ private void forkAll(Collection entries) { } private void maybeStartWorker() { - if (threadPool.isShutdown() || !workerLeaseManager.isLeaseAvailable() || workQueue.isEmpty()) { + if (threadPool.isShutdown() || workQueue.isEmpty()) { + return; + } + var workerLease = workerLeaseManager.tryAcquire(); + if (workerLease == null) { return; } try { threadPool.execute(() -> { LOGGER.trace(() -> "starting worker"); - try { - WorkerThread.getOrThrow().processQueueEntries(); + try (workerLease) { + WorkerThread.getOrThrow().processQueueEntries(workerLease); } finally { LOGGER.trace(() -> "stopping worker"); } + // An attempt to start a worker might have failed due to no worker lease being + // available while this worker was stopping due to lack of work + maybeStartWorker(); }); } catch (RejectedExecutionException e) { + workerLease.release(); if (threadPool.isShutdown()) { return; } @@ -200,21 +208,15 @@ ConcurrentHierarchicalTestExecutorService executor() { return ConcurrentHierarchicalTestExecutorService.this; } - void processQueueEntries() { + void processQueueEntries(WorkerLease workerLease) { + this.workerLease = workerLease; while (!threadPool.isShutdown()) { - var workerLease = workerLeaseManager.tryAcquire(); - if (workerLease == null) { + var entry = workQueue.poll(); + if (entry == null) { break; } - try (workerLease) { - var entry = workQueue.poll(); - if (entry == null) { - break; - } - LOGGER.trace(() -> "processing: " + entry.task); - this.workerLease = workerLease; - execute(entry); - } + LOGGER.trace(() -> "processing: " + entry.task); + execute(entry); } } @@ -581,10 +583,6 @@ private ReacquisitionToken release() { return new ReacquisitionToken(); } - boolean isLeaseAvailable() { - return semaphore.availablePermits() > 0; - } - private class ReacquisitionToken { private boolean used = false; From d11171283d022fa9bb8c8538705151d1587aef1b Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 17 Oct 2025 14:48:15 +0200 Subject: [PATCH 048/120] Add test for race condition when starting workers --- ...tHierarchicalTestExecutorServiceTests.java | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index d24e0eccb848..6a083ea62f2a 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -32,6 +32,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -275,6 +276,45 @@ void prioritizesTestsOverContainers() throws Exception { assertThat(child2.startTime).isBeforeOrEqualTo(child1.startTime); } + @Test + void limitsWorkerThreadsToMaxPoolSize() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(3, 3)); + + CountDownLatch latch = new CountDownLatch(3); + Executable behavior = () -> { + latch.countDown(); + latch.await(); + }; + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1a").withLevel(3); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1b").withLevel(3); + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2a").withLevel(3); + var leaf2b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2b").withLevel(3); + + // When executed, there are 2 worker threads active and 1 available. + // Both invokeAlls race each other trying to start 1 more. + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, + () -> requiredService().invokeAll(List.of(leaf1a, leaf1b))) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, + () -> requiredService().invokeAll(List.of(leaf2a, leaf2b))) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, child1, child2, leaf1a, leaf1b, leaf2a, leaf2b)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(Stream.of(leaf1a, leaf1b, leaf2a, leaf2b).map(TestTaskStub::executionThread).distinct()) // + .hasSize(3); + } + private static ExclusiveResource exclusiveResource() { return new ExclusiveResource("key", ExclusiveResource.LockMode.READ_WRITE); } @@ -284,7 +324,11 @@ private ConcurrentHierarchicalTestExecutorService requiredService() { } private static ParallelExecutionConfiguration configuration(int parallelism) { - return new DefaultParallelExecutionConfiguration(parallelism, parallelism, 256 + parallelism, parallelism, 0, + return configuration(parallelism, 256 + parallelism); + } + + private static ParallelExecutionConfiguration configuration(int parallelism, int maxPoolSize) { + return new DefaultParallelExecutionConfiguration(parallelism, parallelism, maxPoolSize, parallelism, 0, __ -> true); } From 55d8b7185180b07185f70787938e142e536570e0 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 18:49:12 +0200 Subject: [PATCH 049/120] Avoid recursively calling `maybeStartWorker` --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 26892f1809b0..62433ac57caf 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -150,7 +150,6 @@ private void maybeStartWorker() { }); } catch (RejectedExecutionException e) { - workerLease.release(); if (threadPool.isShutdown()) { return; } From c8ddb46e4991cf3b976d48e3a51750f83ee27739 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 18:49:59 +0200 Subject: [PATCH 050/120] Repeat test to increase likelihood of triggering its flakiness --- .../ConcurrentHierarchicalTestExecutorServiceTests.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 6a083ea62f2a..ac065554fa03 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -37,6 +37,7 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; @@ -276,7 +277,7 @@ void prioritizesTestsOverContainers() throws Exception { assertThat(child2.startTime).isBeforeOrEqualTo(child1.startTime); } - @Test + @RepeatedTest(value = 100, failureThreshold = 1) void limitsWorkerThreadsToMaxPoolSize() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(3, 3)); From 4de2d9a5a6308dfd74107921d71f978bfff3de72 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 18:50:34 +0200 Subject: [PATCH 051/120] Temporarily disable stacktrace pruning --- platform-tests/src/test/resources/junit-platform.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/platform-tests/src/test/resources/junit-platform.properties b/platform-tests/src/test/resources/junit-platform.properties index 6efc0d5e85ce..cbed1a38134b 100644 --- a/platform-tests/src/test/resources/junit-platform.properties +++ b/platform-tests/src/test/resources/junit-platform.properties @@ -1 +1,2 @@ junit.jupiter.extensions.autodetection.enabled=true +junit.platform.stacktrace.pruning.enabled=false From f081fde1f66d2e0b8a4d8b98a366dc531cc2d271 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 18:51:43 +0200 Subject: [PATCH 052/120] Temporarily enable logging to have more info when tests fail --- platform-tests/src/test/resources/log4j2-test.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/platform-tests/src/test/resources/log4j2-test.xml b/platform-tests/src/test/resources/log4j2-test.xml index d4e3bc8e85e5..aa720cb4e41c 100644 --- a/platform-tests/src/test/resources/log4j2-test.xml +++ b/platform-tests/src/test/resources/log4j2-test.xml @@ -21,6 +21,7 @@ + From ed9b22d789c3335b2facc3d057b1b5cc4c48b99a Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 16:10:13 +0200 Subject: [PATCH 053/120] Execute unclaimed children in blocking mode prior to joining forked work --- ...urrentHierarchicalTestExecutorService.java | 114 +++++++++++++----- ...tHierarchicalTestExecutorServiceTests.java | 61 +++++++++- 2 files changed, 142 insertions(+), 33 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 62433ac57caf..a41b2cbfd6c2 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -21,11 +21,16 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.PriorityQueue; import java.util.Queue; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.PriorityBlockingQueue; @@ -212,6 +217,7 @@ void processQueueEntries(WorkerLease workerLease) { while (!threadPool.isShutdown()) { var entry = workQueue.poll(); if (entry == null) { + LOGGER.trace(() -> "no queue entry available"); break; } LOGGER.trace(() -> "processing: " + entry.task); @@ -250,8 +256,9 @@ void invokeAll(List testTasks) { List sameThreadTasks = new ArrayList<>(testTasks.size()); var allForkedChildren = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var remainingForkedChildren = stealWork(allForkedChildren); - waitFor(remainingForkedChildren); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(allForkedChildren); + tryToStealWorkWithBlocking(queueEntriesByResult); + waitFor(queueEntriesByResult); executeAll(isolatedTasks); } @@ -282,36 +289,48 @@ else if (child.getExecutionMode() == SAME_THREAD) { return queueEntries; } - private List stealWork(Queue forkedChildren) { - List concurrentlyExecutingChildren = new ArrayList<>(forkedChildren.size()); + private Map> tryToStealWorkWithoutBlocking( + Queue children) { + Map> result = new HashMap<>(WorkStealResult.values().length); WorkQueue.Entry entry; - while ((entry = forkedChildren.poll()) != null) { - var executed = tryToStealWork(entry); - if (!executed) { - concurrentlyExecutingChildren.add(entry); - } + while ((entry = children.poll()) != null) { + var state = tryToStealWork(entry, BlockingMode.NON_BLOCKING); + result.computeIfAbsent(state, __ -> new ArrayList<>()).add(entry); } - return concurrentlyExecutingChildren; + return result; } - private boolean tryToStealWork(WorkQueue.Entry entry) { + private void tryToStealWorkWithBlocking(Map> queueEntriesByResult) { + var entriesRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); + if (entriesRequiringResourceLocks == null) { + return; + } + for (var entry : entriesRequiringResourceLocks) { + var state = tryToStealWork(entry, BlockingMode.BLOCKING); + queueEntriesByResult.computeIfAbsent(state, __ -> new ArrayList<>()).add(entry); + } + } + + private WorkStealResult tryToStealWork(WorkQueue.Entry entry, BlockingMode blockingMode) { if (entry.future.isDone()) { - return false; + return WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER; } var claimed = workQueue.remove(entry); if (claimed) { LOGGER.trace(() -> "stole work: " + entry); - var executed = tryExecute(entry); - if (!executed) { - workQueue.reAdd(entry); + var executed = executeStolenWork(entry, blockingMode); + if (executed) { + return WorkStealResult.EXECUTED_BY_THIS_WORKER; } - return executed; + workQueue.reAdd(entry); + return WorkStealResult.RESOURCE_LOCK_UNAVAILABLE; } - return false; + return WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER; } - private void waitFor(List children) { - if (children.isEmpty()) { + private void waitFor(Map> queueEntriesByResult) { + var children = queueEntriesByResult.get(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); + if (children == null) { return; } var future = toCombinedFuture(children); @@ -350,6 +369,16 @@ private void executeAll(List children) { } } + private boolean executeStolenWork(WorkQueue.Entry entry, BlockingMode blockingMode) { + return switch (blockingMode) { + case NON_BLOCKING -> tryExecute(entry); + case BLOCKING -> { + execute(entry); + yield true; + } + }; + } + private boolean tryExecute(WorkQueue.Entry entry) { try { var executed = tryExecuteTask(entry.task); @@ -433,6 +462,10 @@ private static CompletableFuture toCombinedFuture(List entri return CompletableFuture.allOf(futures); } + private enum WorkStealResult { + EXECUTED_BY_DIFFERENT_WORKER, RESOURCE_LOCK_UNAVAILABLE, EXECUTED_BY_THIS_WORKER + } + private interface BlockingAction { T run() throws InterruptedException; } @@ -454,7 +487,7 @@ private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Vo if (workerThread == null || entry.future.isDone()) { return callable.call(); } - workerThread.tryToStealWork(entry); + workerThread.tryToStealWork(entry, BlockingMode.BLOCKING); if (entry.future.isDone()) { return callable.call(); } @@ -471,8 +504,13 @@ private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Vo } + private enum BlockingMode { + NON_BLOCKING, BLOCKING + } + private static class WorkQueue { + private final EntryOrdering ordering = new EntryOrdering(); private final Queue queue = new PriorityBlockingQueue<>(); Entry add(TestTask task) { @@ -487,7 +525,8 @@ void addAll(Collection entries) { void reAdd(Entry entry) { LOGGER.trace(() -> "re-enqueuing: " + entry.task); - doAdd(entry.incrementAttempts()); + ordering.incrementAttempts(entry); + doAdd(entry); } private Entry doAdd(Entry entry) { @@ -511,12 +550,12 @@ boolean isEmpty() { return queue.isEmpty(); } - private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, int attempts) + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level) implements Comparable { static Entry create(TestTask task) { int level = task.getTestDescriptor().getUniqueId().getSegments().size(); - return new Entry(task, new CompletableFuture<>(), level, 0); + return new Entry(task, new CompletableFuture<>(), level); } @SuppressWarnings("FutureReturnValueIgnored") @@ -531,18 +570,11 @@ static Entry create(TestTask task) { }); } - Entry incrementAttempts() { - return new Entry(task(), future, level, attempts + 1); - } - @Override public int compareTo(Entry that) { var result = Integer.compare(that.level, this.level); if (result == 0) { result = Boolean.compare(this.isContainer(), that.isContainer()); - if (result == 0) { - result = Integer.compare(that.attempts, this.attempts); - } } return result; } @@ -553,6 +585,28 @@ private boolean isContainer() { } + static class EntryOrdering implements Comparator { + + private final ConcurrentMap attempts = new ConcurrentHashMap<>(); + + @Override + public int compare(Entry a, Entry b) { + var result = a.compareTo(b); + if (result == 0) { + result = Integer.compare(attempts(b), attempts(a)); + } + return result; + } + + void incrementAttempts(Entry entry) { + attempts.compute(entry, (__, n) -> n == null ? 1 : n + 1); + } + + private int attempts(Entry entry) { + return attempts.getOrDefault(entry, 0); + } + } + } static class WorkerLeaseManager { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index ac065554fa03..306c7c9dbb36 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -14,6 +14,7 @@ import static java.util.concurrent.Future.State.SUCCESS; import static java.util.function.Predicate.isEqual; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.fail; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.TestDescriptor.Type.CONTAINER; @@ -32,6 +33,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Stream; import org.jspecify.annotations.NullMarked; @@ -47,6 +49,7 @@ import org.junit.platform.commons.util.ToStringBuilder; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService.TestTask; import org.junit.platform.engine.support.hierarchical.Node.ExecutionMode; import org.junit.platform.fakes.TestDescriptorStub; @@ -196,7 +199,7 @@ void acquiresResourceLockForChildTasks() throws Exception { void runsTasksWithoutConflictingLocksConcurrently() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(3)); - var resourceLock = new SingleLock(exclusiveResource(), new ReentrantLock()); + var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); var latch = new CountDownLatch(3); Executable behavior = () -> { @@ -316,8 +319,60 @@ void limitsWorkerThreadsToMaxPoolSize() throws Exception { .hasSize(3); } - private static ExclusiveResource exclusiveResource() { - return new ExclusiveResource("key", ExclusiveResource.LockMode.READ_WRITE); + @Test + void stealsBlockingChildren() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + + var child1Started = new CountDownLatch(1); + var leaf2aStarted = new CountDownLatch(1); + var leaf2bStarted = new CountDownLatch(1); + var readWriteLock = new ReentrantReadWriteLock(); + var readOnlyResourceLock = new SingleLock(exclusiveResource(LockMode.READ), readWriteLock.readLock()) { + @Override + public void release() { + super.release(); + try { + leaf2aStarted.await(); + } + catch (InterruptedException e) { + fail(e); + } + } + }; + var readWriteResourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), readWriteLock.writeLock()); + + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, leaf2aStarted::countDown) // + .withResourceLock(readWriteResourceLock) // + .withName("leaf2a").withLevel(3); + var leaf2b = new TestTaskStub(ExecutionMode.SAME_THREAD, leaf2bStarted::countDown) // + .withName("leaf2b").withLevel(3); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + leaf2bStarted.await(); + }) // + .withResourceLock(readOnlyResourceLock) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + child1Started.await(); + requiredService().invokeAll(List.of(leaf2a, leaf2b)); + }) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(child1, child2))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, child1, child2, leaf2a, leaf2b)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(List.of(leaf2a, leaf2b)).map(TestTaskStub::executionThread) // + .containsOnly(child2.executionThread); + } + + private static ExclusiveResource exclusiveResource(LockMode lockMode) { + return new ExclusiveResource("key", lockMode); } private ConcurrentHierarchicalTestExecutorService requiredService() { From 163b72c16ae11e2f31586495540caa8d8fcef8e7 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 19:54:06 +0200 Subject: [PATCH 054/120] Ignore rejected worker starts if there's at least one active worker --- ...urrentHierarchicalTestExecutorService.java | 39 ++++++++++++------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index a41b2cbfd6c2..dee2af97dd79 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -41,7 +41,7 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; -import java.util.function.Supplier; +import java.util.function.Function; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -155,9 +155,11 @@ private void maybeStartWorker() { }); } catch (RejectedExecutionException e) { - if (threadPool.isShutdown()) { + workerLease.release(false); + if (threadPool.isShutdown() || workerLeaseManager.isAtLeastOneLeaseTaken()) { return; } + LOGGER.error(e, () -> "failed to submit worker to thread pool"); throw e; } } @@ -227,7 +229,7 @@ void processQueueEntries(WorkerLease workerLease) { T runBlocking(BlockingAction blockingAction) throws InterruptedException { var workerLease = requireNonNull(this.workerLease); - workerLease.release(); + workerLease.release(true); try { return blockingAction.run(); } @@ -611,31 +613,40 @@ private int attempts(Entry entry) { static class WorkerLeaseManager { + private final int parallelism; private final Semaphore semaphore; - private final Runnable onRelease; + private final Runnable compensation; - WorkerLeaseManager(int parallelism, Runnable onRelease) { + WorkerLeaseManager(int parallelism, Runnable compensation) { + this.parallelism = parallelism; this.semaphore = new Semaphore(parallelism); - this.onRelease = onRelease; + this.compensation = compensation; } @Nullable WorkerLease tryAcquire() { boolean acquired = semaphore.tryAcquire(); if (acquired) { - LOGGER.trace(() -> "acquired worker lease (available: %d)".formatted(semaphore.availablePermits())); + LOGGER.trace(() -> "acquired worker lease for new worker (available: %d)".formatted( + semaphore.availablePermits())); return new WorkerLease(this::release); } return null; } - private ReacquisitionToken release() { + private ReacquisitionToken release(boolean compensate) { semaphore.release(); LOGGER.trace(() -> "release worker lease (available: %d)".formatted(semaphore.availablePermits())); - onRelease.run(); + if (compensate) { + compensation.run(); + } return new ReacquisitionToken(); } + public boolean isAtLeastOneLeaseTaken() { + return semaphore.availablePermits() < parallelism; + } + private class ReacquisitionToken { private boolean used = false; @@ -651,21 +662,21 @@ void reacquire() throws InterruptedException { static class WorkerLease implements AutoCloseable { - private final Supplier releaseAction; + private final Function releaseAction; private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; - WorkerLease(Supplier releaseAction) { + WorkerLease(Function releaseAction) { this.releaseAction = releaseAction; } @Override public void close() { - release(); + release(true); } - void release() { + void release(boolean compensate) { if (reacquisitionToken == null) { - reacquisitionToken = releaseAction.get(); + reacquisitionToken = releaseAction.apply(compensate); } } From be1ab016113690c57667ea44811d514aaa63a113 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 17 Oct 2025 20:02:17 +0200 Subject: [PATCH 055/120] Reinstate max-pool-size limit --- .../src/test/resources/junit-platform.properties | 1 + 1 file changed, 1 insertion(+) diff --git a/platform-tooling-support-tests/src/test/resources/junit-platform.properties b/platform-tooling-support-tests/src/test/resources/junit-platform.properties index 946b0f1f77c0..95abd2bb9676 100644 --- a/platform-tooling-support-tests/src/test/resources/junit-platform.properties +++ b/platform-tooling-support-tests/src/test/resources/junit-platform.properties @@ -3,6 +3,7 @@ junit.jupiter.execution.parallel.executor=org.junit.platform.engine.support.hier junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=0.25 +junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor=1 junit.jupiter.testclass.order.default = \ org.junit.jupiter.api.ClassOrderer$OrderAnnotation From 7c7497490d478cb97d194eec40a1c7701707ec0e Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 18 Oct 2025 08:42:25 +0200 Subject: [PATCH 056/120] Use unique ID as key --- ...urrentHierarchicalTestExecutorService.java | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index dee2af97dd79..384f4850991f 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -50,6 +50,7 @@ import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.UniqueId; /** * @since 6.1 @@ -589,7 +590,7 @@ private boolean isContainer() { static class EntryOrdering implements Comparator { - private final ConcurrentMap attempts = new ConcurrentHashMap<>(); + private final ConcurrentMap attempts = new ConcurrentHashMap<>(); @Override public int compare(Entry a, Entry b) { @@ -601,11 +602,26 @@ public int compare(Entry a, Entry b) { } void incrementAttempts(Entry entry) { - attempts.compute(entry, (__, n) -> n == null ? 1 : n + 1); + attempts.compute(key(entry), (key, n) -> { + if (n == null) { + registerForKeyRemoval(entry, key); + return 1; + } + return n + 1; + }); } private int attempts(Entry entry) { - return attempts.getOrDefault(entry, 0); + return attempts.getOrDefault(key(entry), 0); + } + + @SuppressWarnings("FutureReturnValueIgnored") + private void registerForKeyRemoval(Entry entry, UniqueId key) { + entry.future.whenComplete((__, ___) -> attempts.remove(key)); + } + + private static UniqueId key(Entry entry) { + return entry.task.getTestDescriptor().getUniqueId(); } } From 03a1dd87eb065132038c8585c8c291a2ad504823 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 18 Oct 2025 08:59:56 +0200 Subject: [PATCH 057/120] Simplify forking and work stealing --- ...urrentHierarchicalTestExecutorService.java | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 384f4850991f..e9857c69d867 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -10,6 +10,7 @@ package org.junit.platform.engine.support.hierarchical; +import static java.util.Comparator.naturalOrder; import static java.util.Comparator.reverseOrder; import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.completedFuture; @@ -25,7 +26,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.PriorityQueue; import java.util.Queue; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; @@ -257,18 +257,18 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - var allForkedChildren = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + var forkedChildren = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var queueEntriesByResult = tryToStealWorkWithoutBlocking(allForkedChildren); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(forkedChildren); tryToStealWorkWithBlocking(queueEntriesByResult); waitFor(queueEntriesByResult); executeAll(isolatedTasks); } - private Queue forkConcurrentChildren(List children, + private List forkConcurrentChildren(List children, Consumer isolatedTaskCollector, List sameThreadTasks) { - Queue queueEntries = new PriorityQueue<>(children.size(), reverseOrder()); + List queueEntries = new ArrayList<>(children.size()); for (TestTask child : children) { if (requiresGlobalReadWriteLock(child)) { isolatedTaskCollector.accept(child); @@ -284,7 +284,9 @@ else if (child.getExecutionMode() == SAME_THREAD) { if (!queueEntries.isEmpty()) { if (sameThreadTasks.isEmpty()) { // hold back one task for this thread - sameThreadTasks.add(queueEntries.poll().task); + var lastEntry = queueEntries.stream().max(naturalOrder()).orElseThrow(); + queueEntries.remove(lastEntry); + sameThreadTasks.add(lastEntry.task); } forkAll(queueEntries); } @@ -293,23 +295,28 @@ else if (child.getExecutionMode() == SAME_THREAD) { } private Map> tryToStealWorkWithoutBlocking( - Queue children) { - Map> result = new HashMap<>(WorkStealResult.values().length); - WorkQueue.Entry entry; - while ((entry = children.poll()) != null) { - var state = tryToStealWork(entry, BlockingMode.NON_BLOCKING); - result.computeIfAbsent(state, __ -> new ArrayList<>()).add(entry); + List forkedChildren) { + + Map> queueEntriesByResult = new HashMap<>(WorkStealResult.values().length); + if (!forkedChildren.isEmpty()) { + forkedChildren.sort(reverseOrder()); + tryToStealWork(forkedChildren, BlockingMode.NON_BLOCKING, queueEntriesByResult); } - return result; + return queueEntriesByResult; } private void tryToStealWorkWithBlocking(Map> queueEntriesByResult) { - var entriesRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); - if (entriesRequiringResourceLocks == null) { + var childrenRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); + if (childrenRequiringResourceLocks == null) { return; } - for (var entry : entriesRequiringResourceLocks) { - var state = tryToStealWork(entry, BlockingMode.BLOCKING); + tryToStealWork(childrenRequiringResourceLocks, BlockingMode.BLOCKING, queueEntriesByResult); + } + + private void tryToStealWork(List children, BlockingMode blocking, + Map> queueEntriesByResult) { + for (var entry : children) { + var state = tryToStealWork(entry, blocking); queueEntriesByResult.computeIfAbsent(state, __ -> new ArrayList<>()).add(entry); } } From 82a2d7f751e6ee3d4e9060f3d66f1f1e0abe6d98 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 18 Oct 2025 10:15:14 +0200 Subject: [PATCH 058/120] Yield worker lease when blocking thread can continue --- ...urrentHierarchicalTestExecutorService.java | 57 ++++++++++++------- .../hierarchical/WorkerLeaseManagerTests.java | 4 +- 2 files changed, 39 insertions(+), 22 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index e9857c69d867..e590e779dde7 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -40,8 +40,9 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.BiFunction; +import java.util.function.BooleanSupplier; import java.util.function.Consumer; -import java.util.function.Function; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -134,7 +135,11 @@ private void forkAll(Collection entries) { } private void maybeStartWorker() { - if (threadPool.isShutdown() || workQueue.isEmpty()) { + maybeStartWorker(() -> false); + } + + private void maybeStartWorker(BooleanSupplier doneCondition) { + if (threadPool.isShutdown() || workQueue.isEmpty() || doneCondition.getAsBoolean()) { return; } var workerLease = workerLeaseManager.tryAcquire(); @@ -144,15 +149,14 @@ private void maybeStartWorker() { try { threadPool.execute(() -> { LOGGER.trace(() -> "starting worker"); - try (workerLease) { - WorkerThread.getOrThrow().processQueueEntries(workerLease); + try { + WorkerThread.getOrThrow().processQueueEntries(workerLease, doneCondition); } finally { + workerLease.release(false); LOGGER.trace(() -> "stopping worker"); } - // An attempt to start a worker might have failed due to no worker lease being - // available while this worker was stopping due to lack of work - maybeStartWorker(); + maybeStartWorker(doneCondition); }); } catch (RejectedExecutionException e) { @@ -215,9 +219,14 @@ ConcurrentHierarchicalTestExecutorService executor() { return ConcurrentHierarchicalTestExecutorService.this; } - void processQueueEntries(WorkerLease workerLease) { + void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) { this.workerLease = workerLease; + this.doneCondition = doneCondition; while (!threadPool.isShutdown()) { + if (doneCondition.getAsBoolean()) { + LOGGER.trace(() -> "yielding resource lock"); + break; + } var entry = workQueue.poll(); if (entry == null) { LOGGER.trace(() -> "no queue entry available"); @@ -228,9 +237,9 @@ void processQueueEntries(WorkerLease workerLease) { } } - T runBlocking(BlockingAction blockingAction) throws InterruptedException { + T runBlocking(BooleanSupplier doneCondition, BlockingAction blockingAction) throws InterruptedException { var workerLease = requireNonNull(this.workerLease); - workerLease.release(true); + workerLease.release(doneCondition); try { return blockingAction.run(); } @@ -350,7 +359,7 @@ private void waitFor(Map> queueEntriesByR future.join(); } else { - runBlocking(() -> { + runBlocking(future::isDone, () -> { LOGGER.trace(() -> "blocking for forked children : %s".formatted(children)); return future.join(); }); @@ -420,7 +429,7 @@ private void executeTask(TestTask testTask) { var executed = tryExecuteTask(testTask); if (!executed) { var resourceLock = testTask.getResourceLock(); - try (var ignored = runBlocking(() -> { + try (var ignored = runBlocking(() -> false, () -> { LOGGER.trace(() -> "blocking for resource lock: " + resourceLock); return resourceLock.acquire(); })) { @@ -502,7 +511,7 @@ private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Vo return callable.call(); } LOGGER.trace(() -> "blocking for child task"); - return workerThread.runBlocking(() -> { + return workerThread.runBlocking(entry.future::isDone, () -> { try { return callable.call(); } @@ -638,9 +647,9 @@ static class WorkerLeaseManager { private final int parallelism; private final Semaphore semaphore; - private final Runnable compensation; + private final Consumer compensation; - WorkerLeaseManager(int parallelism, Runnable compensation) { + WorkerLeaseManager(int parallelism, Consumer compensation) { this.parallelism = parallelism; this.semaphore = new Semaphore(parallelism); this.compensation = compensation; @@ -657,11 +666,11 @@ WorkerLease tryAcquire() { return null; } - private ReacquisitionToken release(boolean compensate) { + private ReacquisitionToken release(boolean compensate, BooleanSupplier doneCondition) { semaphore.release(); LOGGER.trace(() -> "release worker lease (available: %d)".formatted(semaphore.availablePermits())); if (compensate) { - compensation.run(); + compensation.accept(doneCondition); } return new ReacquisitionToken(); } @@ -685,10 +694,10 @@ void reacquire() throws InterruptedException { static class WorkerLease implements AutoCloseable { - private final Function releaseAction; + private final BiFunction releaseAction; private WorkerLeaseManager.@Nullable ReacquisitionToken reacquisitionToken; - WorkerLease(Function releaseAction) { + WorkerLease(BiFunction releaseAction) { this.releaseAction = releaseAction; } @@ -697,9 +706,17 @@ public void close() { release(true); } + public void release(BooleanSupplier doneCondition) { + release(true, doneCondition); + } + void release(boolean compensate) { + release(compensate, () -> false); + } + + void release(boolean compensate, BooleanSupplier doneCondition) { if (reacquisitionToken == null) { - reacquisitionToken = releaseAction.apply(compensate); + reacquisitionToken = releaseAction.apply(compensate, doneCondition); } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java index 1a8978ec21a7..ec3068db2516 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java @@ -22,7 +22,7 @@ class WorkerLeaseManagerTests { @Test void releasingIsIdempotent() { var released = new AtomicInteger(); - var manager = new WorkerLeaseManager(1, released::incrementAndGet); + var manager = new WorkerLeaseManager(1, __ -> released.incrementAndGet()); var lease = manager.tryAcquire(); assertThat(lease).isNotNull(); @@ -37,7 +37,7 @@ void releasingIsIdempotent() { @Test void leaseCanBeReacquired() throws Exception { var released = new AtomicInteger(); - var manager = new WorkerLeaseManager(1, released::incrementAndGet); + var manager = new WorkerLeaseManager(1, __ -> released.incrementAndGet()); var lease = manager.tryAcquire(); assertThat(lease).isNotNull(); From 8cf7f474854900fbcbb827d548eb0b8d928da4bf Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 18 Oct 2025 11:52:15 +0200 Subject: [PATCH 059/120] Add TODO --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index e590e779dde7..6ad14fcb1ec1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -510,6 +510,7 @@ private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Vo if (entry.future.isDone()) { return callable.call(); } + // TODO steal other dynamic children until future is done and check again before blocking LOGGER.trace(() -> "blocking for child task"); return workerThread.runBlocking(entry.future::isDone, () -> { try { From 2cc5311b7e82dc45de270639df3a7ef71090f003 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 18 Oct 2025 12:01:04 +0200 Subject: [PATCH 060/120] fixup! Yield worker lease when blocking thread can continue --- .../ConcurrentHierarchicalTestExecutorService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 6ad14fcb1ec1..7818c0fb48f7 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -221,7 +221,6 @@ ConcurrentHierarchicalTestExecutorService executor() { void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) { this.workerLease = workerLease; - this.doneCondition = doneCondition; while (!threadPool.isShutdown()) { if (doneCondition.getAsBoolean()) { LOGGER.trace(() -> "yielding resource lock"); @@ -306,7 +305,8 @@ else if (child.getExecutionMode() == SAME_THREAD) { private Map> tryToStealWorkWithoutBlocking( List forkedChildren) { - Map> queueEntriesByResult = new HashMap<>(WorkStealResult.values().length); + Map> queueEntriesByResult = new HashMap<>( + WorkStealResult.values().length); if (!forkedChildren.isEmpty()) { forkedChildren.sort(reverseOrder()); tryToStealWork(forkedChildren, BlockingMode.NON_BLOCKING, queueEntriesByResult); From 08986fb2922e4dcce9a4283f0dcce8c07a7480b5 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 16:20:35 +0200 Subject: [PATCH 061/120] Use EnumMap for queueEntriesByResult --- .../ConcurrentHierarchicalTestExecutorService.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 7818c0fb48f7..ccbbe23df4f6 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -23,7 +23,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; -import java.util.HashMap; +import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Queue; @@ -305,8 +305,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { private Map> tryToStealWorkWithoutBlocking( List forkedChildren) { - Map> queueEntriesByResult = new HashMap<>( - WorkStealResult.values().length); + Map> queueEntriesByResult = new EnumMap<>(WorkStealResult.class); if (!forkedChildren.isEmpty()) { forkedChildren.sort(reverseOrder()); tryToStealWork(forkedChildren, BlockingMode.NON_BLOCKING, queueEntriesByResult); From 4567c16dff4e58d465f2d1cc190d5cbd1aa94f5c Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 15:03:11 +0200 Subject: [PATCH 062/120] Verify work is stolen in reverse order --- ...tHierarchicalTestExecutorServiceTests.java | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 306c7c9dbb36..918e550d2ef7 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -14,6 +14,7 @@ import static java.util.concurrent.Future.State.SUCCESS; import static java.util.function.Predicate.isEqual; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; @@ -32,6 +33,7 @@ import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; import java.util.concurrent.locks.ReentrantLock; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.stream.Stream; @@ -371,6 +373,86 @@ public void release() { .containsOnly(child2.executionThread); } + @Test + void executesChildrenInOrder() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(1, 1)); + + Executable behavior = () -> { + + }; + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1a").withLevel(2); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1b").withLevel(2); + var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1c").withLevel(2); + var leaf1d = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1d").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(leaf1a, leaf1b, leaf1c, leaf1d))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf1d)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertAll(() -> assertThat(leaf1a.startTime).isBeforeOrEqualTo(leaf1b.startTime), + () -> assertThat(leaf1b.startTime).isBeforeOrEqualTo(leaf1c.startTime), + () -> assertThat(leaf1c.startTime).isBeforeOrEqualTo(leaf1d.startTime)); + } + + @Test + void workIsStolenInReverseOrder() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + + // Execute tasks pairwise + CyclicBarrier cyclicBarrier = new CyclicBarrier(2); + Executable behavior = cyclicBarrier::await; + + // With half of the leaves to executed normally + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1a").withLevel(2); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1b").withLevel(2); + var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf1c").withLevel(2); + + // And half of the leaves to be stolen + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2a").withLevel(2); + var leaf2b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2b").withLevel(2); + var leaf2c = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("leaf2c").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(List.of(leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c))) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + // If the last node must be stolen. + assertThat(leaf1a.executionThread).isNotEqualTo(leaf2c.executionThread); + // Then must follow that the last half of the nodes were stolen + assertThat(Stream.of(leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c)).extracting( + TestTaskStub::executionThread).containsExactly(leaf1a.executionThread, // + leaf1a.executionThread, // + leaf1a.executionThread, // + leaf2c.executionThread, // + leaf2c.executionThread, // + leaf2c.executionThread // + ); + assertAll(() -> assertThat(leaf1a.startTime).isBeforeOrEqualTo(leaf1b.startTime), + () -> assertThat(leaf1b.startTime).isBeforeOrEqualTo(leaf1c.startTime), + () -> assertThat(leaf2a.startTime).isAfterOrEqualTo(leaf2b.startTime), + () -> assertThat(leaf2b.startTime).isAfterOrEqualTo(leaf2c.startTime)); + } + private static ExclusiveResource exclusiveResource(LockMode lockMode) { return new ExclusiveResource("key", lockMode); } From 6e64a194fe536d20bda51a68358dcea2c32377f6 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 15:16:41 +0200 Subject: [PATCH 063/120] Ensure work is stolen in reverse order --- ...urrentHierarchicalTestExecutorService.java | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index ccbbe23df4f6..918eefea2ec3 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -277,7 +277,8 @@ private List forkConcurrentChildren(List ch Consumer isolatedTaskCollector, List sameThreadTasks) { List queueEntries = new ArrayList<>(children.size()); - for (TestTask child : children) { + for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { + TestTask child = children.get(i); if (requiresGlobalReadWriteLock(child)) { isolatedTaskCollector.accept(child); } @@ -285,7 +286,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(child); } else { - queueEntries.add(WorkQueue.Entry.create(child)); + queueEntries.add(WorkQueue.Entry.createIndexed(i, child)); } } @@ -569,12 +570,17 @@ boolean isEmpty() { return queue.isEmpty(); } - private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level) + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, int index) implements Comparable { static Entry create(TestTask task) { int level = task.getTestDescriptor().getUniqueId().getSegments().size(); - return new Entry(task, new CompletableFuture<>(), level); + return new Entry(task, new CompletableFuture<>(), level, 0); + } + + static Entry createIndexed(int index, TestTask task) { + int level = task.getTestDescriptor().getUniqueId().getSegments().size(); + return new Entry(task, new CompletableFuture<>(), level, index); } @SuppressWarnings("FutureReturnValueIgnored") @@ -592,10 +598,14 @@ static Entry create(TestTask task) { @Override public int compareTo(Entry that) { var result = Integer.compare(that.level, this.level); - if (result == 0) { - result = Boolean.compare(this.isContainer(), that.isContainer()); + if (result != 0) { + return result; } - return result; + result = Boolean.compare(this.isContainer(), that.isContainer()); + if (result != 0) { + return result; + } + return Integer.compare(that.index, this.index); } private boolean isContainer() { From 0d5e8e18711b8b8804748d2e36ac6f334566cf73 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 15:48:33 +0200 Subject: [PATCH 064/120] Fix prioritizesChildrenOfStartedContainers by swapping order --- ...currentHierarchicalTestExecutorServiceTests.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 918e550d2ef7..aeec49d88da0 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -232,13 +232,14 @@ void prioritizesChildrenOfStartedContainers() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); var leavesStarted = new CountDownLatch(2); - var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // - .withName("leaf").withLevel(3); - var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::await) // .withName("child1").withLevel(2); var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // .withName("child2").withLevel(2); - var child3 = new TestTaskStub(ExecutionMode.SAME_THREAD, leavesStarted::await) // + var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // + .withName("leaf").withLevel(3); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // .withName("child3").withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, @@ -248,11 +249,11 @@ void prioritizesChildrenOfStartedContainers() throws Exception { service.submit(root).get(); root.assertExecutedSuccessfully(); - assertThat(List.of(child1, child2, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(List.of(child1, child2, leaf, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); leaf.assertExecutedSuccessfully(); assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); - assertThat(leaf.executionThread).isSameAs(child1.executionThread); + assertThat(leaf.executionThread).isSameAs(child3.executionThread); } @Test From f04513826a9a678bc779807a6bbdf0bb1e269a2b Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 15:21:53 +0200 Subject: [PATCH 065/120] Ensure task entry index is not sparse --- .../ConcurrentHierarchicalTestExecutorService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 918eefea2ec3..72eaba6bcddd 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -276,9 +276,9 @@ void invokeAll(List testTasks) { private List forkConcurrentChildren(List children, Consumer isolatedTaskCollector, List sameThreadTasks) { + int index = 0; List queueEntries = new ArrayList<>(children.size()); - for (int i = 0, childrenSize = children.size(); i < childrenSize; i++) { - TestTask child = children.get(i); + for (TestTask child : children) { if (requiresGlobalReadWriteLock(child)) { isolatedTaskCollector.accept(child); } @@ -286,7 +286,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(child); } else { - queueEntries.add(WorkQueue.Entry.createIndexed(i, child)); + queueEntries.add(WorkQueue.Entry.createWithIndex(child, index++)); } } @@ -578,7 +578,7 @@ static Entry create(TestTask task) { return new Entry(task, new CompletableFuture<>(), level, 0); } - static Entry createIndexed(int index, TestTask task) { + static Entry createWithIndex(TestTask task, int index) { int level = task.getTestDescriptor().getUniqueId().getSegments().size(); return new Entry(task, new CompletableFuture<>(), level, index); } From fdb5fac296f68b76f1f327ebb32d28ff5011de41 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 15:58:51 +0200 Subject: [PATCH 066/120] Typos and formatting --- ...ntHierarchicalTestExecutorServiceTests.java | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index aeec49d88da0..14957004be0e 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -412,7 +412,7 @@ void workIsStolenInReverseOrder() throws Exception { CyclicBarrier cyclicBarrier = new CyclicBarrier(2); Executable behavior = cyclicBarrier::await; - // With half of the leaves to executed normally + // With half of the leaves to be executed normally var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // .withName("leaf1a").withLevel(2); var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // @@ -437,18 +437,16 @@ void workIsStolenInReverseOrder() throws Exception { assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c)) // .allSatisfy(TestTaskStub::assertExecutedSuccessfully); - // If the last node must be stolen. + // If the last node was stolen. assertThat(leaf1a.executionThread).isNotEqualTo(leaf2c.executionThread); - // Then must follow that the last half of the nodes were stolen + // Then it must follow that the last half of the nodes were stolen assertThat(Stream.of(leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c)).extracting( - TestTaskStub::executionThread).containsExactly(leaf1a.executionThread, // - leaf1a.executionThread, // - leaf1a.executionThread, // - leaf2c.executionThread, // - leaf2c.executionThread, // - leaf2c.executionThread // + TestTaskStub::executionThread).containsExactly( // + leaf1a.executionThread, leaf1a.executionThread, leaf1a.executionThread, // + leaf2c.executionThread, leaf2c.executionThread, leaf2c.executionThread // ); - assertAll(() -> assertThat(leaf1a.startTime).isBeforeOrEqualTo(leaf1b.startTime), + assertAll( // + () -> assertThat(leaf1a.startTime).isBeforeOrEqualTo(leaf1b.startTime), () -> assertThat(leaf1b.startTime).isBeforeOrEqualTo(leaf1c.startTime), () -> assertThat(leaf2a.startTime).isAfterOrEqualTo(leaf2b.startTime), () -> assertThat(leaf2b.startTime).isAfterOrEqualTo(leaf2c.startTime)); From 14d5f9a2d6acba1bc4ee6200158ad47875e71d96 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 24 Oct 2025 16:09:19 +0200 Subject: [PATCH 067/120] Polishing --- ...urrentHierarchicalTestExecutorService.java | 3 +- ...tHierarchicalTestExecutorServiceTests.java | 50 ++++++++++--------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 72eaba6bcddd..d5e85e1464c1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -574,8 +574,7 @@ private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, in implements Comparable { static Entry create(TestTask task) { - int level = task.getTestDescriptor().getUniqueId().getSegments().size(); - return new Entry(task, new CompletableFuture<>(), level, 0); + return createWithIndex(task, 0); } static Entry createWithIndex(TestTask task, int index) { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 14957004be0e..b996eb887352 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -14,7 +14,6 @@ import static java.util.concurrent.Future.State.SUCCESS; import static java.util.function.Predicate.isEqual; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationFor; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; @@ -374,20 +373,17 @@ public void release() { .containsOnly(child2.executionThread); } - @Test + @RepeatedTest(value = 100, failureThreshold = 1) void executesChildrenInOrder() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(1, 1)); - Executable behavior = () -> { - - }; - var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("leaf1a").withLevel(2); - var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("leaf1b").withLevel(2); - var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("leaf1c").withLevel(2); - var leaf1d = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + var leaf1d = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("leaf1d").withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, @@ -399,12 +395,12 @@ void executesChildrenInOrder() throws Exception { assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf1d)) // .allSatisfy(TestTaskStub::assertExecutedSuccessfully); - assertAll(() -> assertThat(leaf1a.startTime).isBeforeOrEqualTo(leaf1b.startTime), - () -> assertThat(leaf1b.startTime).isBeforeOrEqualTo(leaf1c.startTime), - () -> assertThat(leaf1c.startTime).isBeforeOrEqualTo(leaf1d.startTime)); + assertThat(Stream.of(leaf1a, leaf1b, leaf1c, leaf1d)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); } - @Test + @RepeatedTest(value = 100, failureThreshold = 1) void workIsStolenInReverseOrder() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); @@ -440,16 +436,19 @@ void workIsStolenInReverseOrder() throws Exception { // If the last node was stolen. assertThat(leaf1a.executionThread).isNotEqualTo(leaf2c.executionThread); // Then it must follow that the last half of the nodes were stolen - assertThat(Stream.of(leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c)).extracting( - TestTaskStub::executionThread).containsExactly( // - leaf1a.executionThread, leaf1a.executionThread, leaf1a.executionThread, // - leaf2c.executionThread, leaf2c.executionThread, leaf2c.executionThread // - ); - assertAll( // - () -> assertThat(leaf1a.startTime).isBeforeOrEqualTo(leaf1b.startTime), - () -> assertThat(leaf1b.startTime).isBeforeOrEqualTo(leaf1c.startTime), - () -> assertThat(leaf2a.startTime).isAfterOrEqualTo(leaf2b.startTime), - () -> assertThat(leaf2b.startTime).isAfterOrEqualTo(leaf2c.startTime)); + assertThat(Stream.of(leaf1a, leaf1b, leaf1c)) // + .extracting(TestTaskStub::executionThread) // + .containsOnly(leaf1a.executionThread); + assertThat(Stream.of(leaf2a, leaf2b, leaf2c)) // + .extracting(TestTaskStub::executionThread) // + .containsOnly(leaf2c.executionThread); + + assertThat(Stream.of(leaf1a, leaf1b, leaf1c)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + assertThat(Stream.of(leaf2c, leaf2b, leaf2a)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); } private static ExclusiveResource exclusiveResource(LockMode lockMode) { @@ -567,6 +566,11 @@ Thread executionThread() { return executionThread; } + @Nullable + Instant startTime() { + return startTime; + } + @Override public String toString() { return "%s @ %s".formatted(new ToStringBuilder(this).append("name", name), Integer.toHexString(hashCode())); From 53af7292d0b6906a0e535eb20c466bc8c8c57277 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 01:26:31 +0200 Subject: [PATCH 068/120] Fix flakey test? --- .../ConcurrentHierarchicalTestExecutorServiceTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index b996eb887352..3f497b58ff7d 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -169,7 +169,7 @@ void acquiresResourceLockForRootTask() throws Exception { inOrder.verifyNoMoreInteractions(); } - @Test + @RepeatedTest(value = 100, failureThreshold = 1) void acquiresResourceLockForChildTasks() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); @@ -189,7 +189,7 @@ void acquiresResourceLockForChildTasks() throws Exception { assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); assertThat(children).extracting(TestTaskStub::executionThread) // - .filteredOn(isEqual(root.executionThread())).hasSizeLessThan(2); + .filteredOn(isEqual(root.executionThread())).hasSizeLessThanOrEqualTo(2); verify(resourceLock, atLeast(2)).tryAcquire(); verify(resourceLock, atLeast(1)).acquire(); From 569edcedb74e5e3aa981d060fddd20b926bdb035 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 15:35:47 +0200 Subject: [PATCH 069/120] Test is not flaky anymore. --- .../ConcurrentHierarchicalTestExecutorServiceTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 3f497b58ff7d..8ee150354e6f 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -169,7 +169,7 @@ void acquiresResourceLockForRootTask() throws Exception { inOrder.verifyNoMoreInteractions(); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void acquiresResourceLockForChildTasks() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); From a9ae858f634fae4046467f88db10fbc791b9146f Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 19 Oct 2025 20:42:56 +0200 Subject: [PATCH 070/120] Improve naming --- ...urrentHierarchicalTestExecutorService.java | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index d5e85e1464c1..62d91b1f327c 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -265,11 +265,11 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - var forkedChildren = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + var reverseQueueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var queueEntriesByResult = tryToStealWorkWithoutBlocking(forkedChildren); - tryToStealWorkWithBlocking(queueEntriesByResult); - waitFor(queueEntriesByResult); + var reverseQueueEntriesByResult = tryToStealWorkWithoutBlocking(reverseQueueEntries); + tryToStealWorkWithBlocking(reverseQueueEntriesByResult); + waitFor(reverseQueueEntriesByResult); executeAll(isolatedTasks); } @@ -299,32 +299,31 @@ else if (child.getExecutionMode() == SAME_THREAD) { } forkAll(queueEntries); } - + queueEntries.sort(reverseOrder()); return queueEntries; } private Map> tryToStealWorkWithoutBlocking( - List forkedChildren) { + List queueEntries) { Map> queueEntriesByResult = new EnumMap<>(WorkStealResult.class); - if (!forkedChildren.isEmpty()) { - forkedChildren.sort(reverseOrder()); - tryToStealWork(forkedChildren, BlockingMode.NON_BLOCKING, queueEntriesByResult); + if (!queueEntries.isEmpty()) { + tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult); } return queueEntriesByResult; } private void tryToStealWorkWithBlocking(Map> queueEntriesByResult) { - var childrenRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); - if (childrenRequiringResourceLocks == null) { + var entriesRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); + if (entriesRequiringResourceLocks == null) { return; } - tryToStealWork(childrenRequiringResourceLocks, BlockingMode.BLOCKING, queueEntriesByResult); + tryToStealWork(entriesRequiringResourceLocks, BlockingMode.BLOCKING, queueEntriesByResult); } - private void tryToStealWork(List children, BlockingMode blocking, + private void tryToStealWork(List entries, BlockingMode blocking, Map> queueEntriesByResult) { - for (var entry : children) { + for (var entry : entries) { var state = tryToStealWork(entry, blocking); queueEntriesByResult.computeIfAbsent(state, __ -> new ArrayList<>()).add(entry); } From bc9d6b28d6c0d9fc530f7fb88a56cbc47594e8bd Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 00:04:29 +0200 Subject: [PATCH 071/120] Skip over unavailable resources --- ...urrentHierarchicalTestExecutorService.java | 33 +++++++-- ...tHierarchicalTestExecutorServiceTests.java | 74 +++++++++++++++++++ 2 files changed, 99 insertions(+), 8 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 62d91b1f327c..91a79307a22e 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -226,13 +226,26 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) LOGGER.trace(() -> "yielding resource lock"); break; } - var entry = workQueue.poll(); - if (entry == null) { - LOGGER.trace(() -> "no queue entry available"); + var queueEntries = workQueue.peekAll(); + if (queueEntries.isEmpty()) { + LOGGER.trace(() -> "no queue entries available"); break; } - LOGGER.trace(() -> "processing: " + entry.task); - execute(entry); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); + maybeTryToStealWorkWithBlocking(queueEntriesByResult); + } + } + + private void maybeTryToStealWorkWithBlocking(Map> queueEntriesByResult) { + if (queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) || // + queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER)) { + // Queue changed. Try to see if there is work that does not need locking + return; + } + // All resources locked, start blocking + var entriesRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); + if (entriesRequiringResourceLocks != null) { + tryToStealWork(entriesRequiringResourceLocks.get(0), BlockingMode.BLOCKING); } } @@ -556,9 +569,13 @@ private Entry doAdd(Entry entry) { return entry; } - @Nullable - Entry poll() { - return queue.poll(); + private List peekAll() { + List entries = new ArrayList<>(queue); + // Iteration order isn't the same as queue order. + // TODO: This makes the queue kinda pointless + // TODO: This also makes retries pointless + entries.sort(naturalOrder()); + return entries; } boolean remove(Entry entry) { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 8ee150354e6f..17db098e1e45 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -226,6 +226,80 @@ void runsTasksWithoutConflictingLocksConcurrently() throws Exception { assertThat(leaves).allSatisfy(TestTaskStub::assertExecutedSuccessfully); } + @Test + void processingQueueEntriesSkipsOverUnavailableResources() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + + var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); + + var lockFreeChildrenStarted = new CountDownLatch(2); + var child1Started = new CountDownLatch(1); + + Executable child1Behaviour = () -> { + child1Started.countDown(); + lockFreeChildrenStarted.await(); + }; + Executable child4Behaviour = () -> { + lockFreeChildrenStarted.countDown(); + child1Started.await(); + }; + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, child1Behaviour) // + .withResourceLock(resourceLock) // + .withName("child1"); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown).withName("child2"); // + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock) // + .withName("child3"); + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, child4Behaviour).withName("child4"); + var children = List.of(child1, child2, child3, child4); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)) // + .withName("root"); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child4.executionThread).isEqualTo(child2.executionThread); + assertThat(child3.startTime).isAfterOrEqualTo(child2.startTime); + } + + @Test + void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + + var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); + + var lockFreeChildrenStarted = new CountDownLatch(2); + var child4Started = new CountDownLatch(1); + + Executable child1Behaviour = () -> { + lockFreeChildrenStarted.countDown(); + child4Started.await(); + }; + Executable child4Behaviour = () -> { + child4Started.countDown(); + lockFreeChildrenStarted.await(); + }; + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, child1Behaviour) // + .withName("child1"); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock) // + .withName("child2"); // + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown).withName("child3"); + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, child4Behaviour).withResourceLock(resourceLock) // + .withName("child4"); + var children = List.of(child1, child2, child3, child4); + var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)) // + .withName("root"); + + service.submit(root).get(); + + root.assertExecutedSuccessfully(); + assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child1.executionThread).isEqualTo(child3.executionThread); + assertThat(child2.startTime).isAfterOrEqualTo(child3.startTime); + } + @Test void prioritizesChildrenOfStartedContainers() throws Exception { service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); From 807ea54f753bd612fc5d60510e50392512fe7142 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 15:20:46 +0200 Subject: [PATCH 072/120] Remove unused entry ordering --- ...urrentHierarchicalTestExecutorService.java | 46 ------------------- 1 file changed, 46 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 91a79307a22e..1c194ca9626a 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -22,15 +22,12 @@ import java.util.ArrayList; import java.util.Collection; -import java.util.Comparator; import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Queue; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.PriorityBlockingQueue; @@ -51,7 +48,6 @@ import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.engine.ConfigurationParameters; -import org.junit.platform.engine.UniqueId; /** * @since 6.1 @@ -542,7 +538,6 @@ private enum BlockingMode { private static class WorkQueue { - private final EntryOrdering ordering = new EntryOrdering(); private final Queue queue = new PriorityBlockingQueue<>(); Entry add(TestTask task) { @@ -557,7 +552,6 @@ void addAll(Collection entries) { void reAdd(Entry entry) { LOGGER.trace(() -> "re-enqueuing: " + entry.task); - ordering.incrementAttempts(entry); doAdd(entry); } @@ -572,8 +566,6 @@ private Entry doAdd(Entry entry) { private List peekAll() { List entries = new ArrayList<>(queue); // Iteration order isn't the same as queue order. - // TODO: This makes the queue kinda pointless - // TODO: This also makes retries pointless entries.sort(naturalOrder()); return entries; } @@ -628,44 +620,6 @@ private boolean isContainer() { } } - - static class EntryOrdering implements Comparator { - - private final ConcurrentMap attempts = new ConcurrentHashMap<>(); - - @Override - public int compare(Entry a, Entry b) { - var result = a.compareTo(b); - if (result == 0) { - result = Integer.compare(attempts(b), attempts(a)); - } - return result; - } - - void incrementAttempts(Entry entry) { - attempts.compute(key(entry), (key, n) -> { - if (n == null) { - registerForKeyRemoval(entry, key); - return 1; - } - return n + 1; - }); - } - - private int attempts(Entry entry) { - return attempts.getOrDefault(key(entry), 0); - } - - @SuppressWarnings("FutureReturnValueIgnored") - private void registerForKeyRemoval(Entry entry, UniqueId key) { - entry.future.whenComplete((__, ___) -> attempts.remove(key)); - } - - private static UniqueId key(Entry entry) { - return entry.task.getTestDescriptor().getUniqueId(); - } - } - } static class WorkerLeaseManager { From 192b0b4b3937cb91d7babdbe36e03621bd9ecc96 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 15:45:34 +0200 Subject: [PATCH 073/120] Polishing --- ...oncurrentHierarchicalTestExecutorService.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 1c194ca9626a..6ce20702d61a 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -227,20 +227,20 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) LOGGER.trace(() -> "no queue entries available"); break; } - var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); - maybeTryToStealWorkWithBlocking(queueEntriesByResult); + processQueueEntries(queueEntries); } } - private void maybeTryToStealWorkWithBlocking(Map> queueEntriesByResult) { - if (queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) || // - queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER)) { - // Queue changed. Try to see if there is work that does not need locking + private void processQueueEntries(List queueEntries) { + var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); + var queueModified = queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) // + || queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); + if (queueModified) { return; } - // All resources locked, start blocking - var entriesRequiringResourceLocks = queueEntriesByResult.remove(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); + var entriesRequiringResourceLocks = queueEntriesByResult.get(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); if (entriesRequiringResourceLocks != null) { + // One entry at a time to avoid over comitting tryToStealWork(entriesRequiringResourceLocks.get(0), BlockingMode.BLOCKING); } } From 2c7202138ea95f2cc0320bb26ee1ef0eca0e1ade Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 15:49:57 +0200 Subject: [PATCH 074/120] Polishing with spotless --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 6ce20702d61a..8effe036558b 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -234,7 +234,7 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) private void processQueueEntries(List queueEntries) { var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); var queueModified = queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) // - || queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); + || queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); if (queueModified) { return; } From 87416c1acd4e89ed3ba3a2f7dc5f521f6f3a17f7 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 16:07:44 +0200 Subject: [PATCH 075/120] Polishing --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 8effe036558b..5f645f589109 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -240,7 +240,7 @@ private void processQueueEntries(List queueEntries) { } var entriesRequiringResourceLocks = queueEntriesByResult.get(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); if (entriesRequiringResourceLocks != null) { - // One entry at a time to avoid over comitting + // One entry at a time to avoid blocking too much tryToStealWork(entriesRequiringResourceLocks.get(0), BlockingMode.BLOCKING); } } From d691b1e062ca57fa24a213b4689817c6d0fd560a Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 20 Oct 2025 16:33:35 +0200 Subject: [PATCH 076/120] Extract RejectedExecutionHandler to avoid control flow by exception --- ...urrentHierarchicalTestExecutorService.java | 66 +++++++++++++------ 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 5f645f589109..3d634a0df9e1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -32,6 +32,7 @@ import java.util.concurrent.Future; import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.Semaphore; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadFactory; @@ -47,6 +48,7 @@ import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; +import org.junit.platform.commons.util.ToStringBuilder; import org.junit.platform.engine.ConfigurationParameters; /** @@ -72,10 +74,12 @@ public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { ThreadFactory threadFactory = new WorkerThreadFactory(classLoader); - threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), - configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory); parallelism = configuration.getParallelism(); workerLeaseManager = new WorkerLeaseManager(parallelism, this::maybeStartWorker); + var rejectedExecutionHandler = new LeaseAwareRejectedExecutionHandler(workerLeaseManager); + threadPool = new ThreadPoolExecutor(configuration.getCorePoolSize(), configuration.getMaxPoolSize(), + configuration.getKeepAliveSeconds(), SECONDS, new SynchronousQueue<>(), threadFactory, + rejectedExecutionHandler); LOGGER.trace(() -> "initialized thread pool for parallelism of " + configuration.getParallelism()); } @@ -142,26 +146,25 @@ private void maybeStartWorker(BooleanSupplier doneCondition) { if (workerLease == null) { return; } - try { - threadPool.execute(() -> { - LOGGER.trace(() -> "starting worker"); - try { - WorkerThread.getOrThrow().processQueueEntries(workerLease, doneCondition); - } - finally { - workerLease.release(false); - LOGGER.trace(() -> "stopping worker"); - } - maybeStartWorker(doneCondition); - }); - } - catch (RejectedExecutionException e) { - workerLease.release(false); - if (threadPool.isShutdown() || workerLeaseManager.isAtLeastOneLeaseTaken()) { - return; + threadPool.execute(new RunLeaseAwareWorker(workerLease, + () -> WorkerThread.getOrThrow().processQueueEntries(workerLease, doneCondition), + () -> this.maybeStartWorker(doneCondition))); + } + + private record RunLeaseAwareWorker(WorkerLease workerLease, Runnable worker, Runnable onWorkerFinished) + implements Runnable { + + @Override + public void run() { + LOGGER.trace(() -> "starting worker"); + try { + worker.run(); } - LOGGER.error(e, () -> "failed to submit worker to thread pool"); - throw e; + finally { + workerLease.release(false); + LOGGER.trace(() -> "stopping worker"); + } + onWorkerFinished.run(); } } @@ -669,6 +672,12 @@ void reacquire() throws InterruptedException { LOGGER.trace(() -> "reacquired worker lease (available: %d)".formatted(semaphore.availablePermits())); } } + + @Override + public String toString() { + return new ToStringBuilder(this).append("parallelism", parallelism).append("semaphore", + semaphore).toString(); + } } static class WorkerLease implements AutoCloseable { @@ -705,4 +714,19 @@ void reacquire() throws InterruptedException { reacquisitionToken = null; } } + + private record LeaseAwareRejectedExecutionHandler(WorkerLeaseManager workerLeaseManager) + implements RejectedExecutionHandler { + @Override + public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { + if (!(r instanceof RunLeaseAwareWorker worker)) { + return; + } + worker.workerLease.release(false); + if (executor.isShutdown() || workerLeaseManager.isAtLeastOneLeaseTaken()) { + return; + } + throw new RejectedExecutionException("Task with " + workerLeaseManager + " rejected from " + executor); + } + } } From b5df82ecf8ffaac9a1955117599ef06e8283c66c Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Tue, 21 Oct 2025 03:47:10 +0200 Subject: [PATCH 077/120] Use ConcurrentSkipListSet with absolute ordering to back work queue --- ...urrentHierarchicalTestExecutorService.java | 59 ++++++++----------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 3d634a0df9e1..05d1f93a1e6b 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -23,14 +23,15 @@ import java.util.ArrayList; import java.util.Collection; import java.util.EnumMap; +import java.util.Iterator; import java.util.List; import java.util.Map; -import java.util.Queue; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentSkipListSet; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; -import java.util.concurrent.PriorityBlockingQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.RejectedExecutionHandler; import java.util.concurrent.Semaphore; @@ -225,17 +226,16 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) LOGGER.trace(() -> "yielding resource lock"); break; } - var queueEntries = workQueue.peekAll(); - if (queueEntries.isEmpty()) { + if (workQueue.isEmpty()) { LOGGER.trace(() -> "no queue entries available"); break; } - processQueueEntries(queueEntries); + processQueueEntries(); } } - private void processQueueEntries(List queueEntries) { - var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); + private void processQueueEntries() { + var queueEntriesByResult = tryToStealWorkWithoutBlocking(workQueue); var queueModified = queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) // || queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); if (queueModified) { @@ -288,7 +288,6 @@ void invokeAll(List testTasks) { private List forkConcurrentChildren(List children, Consumer isolatedTaskCollector, List sameThreadTasks) { - int index = 0; List queueEntries = new ArrayList<>(children.size()); for (TestTask child : children) { if (requiresGlobalReadWriteLock(child)) { @@ -298,7 +297,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(child); } else { - queueEntries.add(WorkQueue.Entry.createWithIndex(child, index++)); + queueEntries.add(workQueue.createEntry(child)); } } @@ -316,12 +315,10 @@ else if (child.getExecutionMode() == SAME_THREAD) { } private Map> tryToStealWorkWithoutBlocking( - List queueEntries) { + Iterable queueEntries) { Map> queueEntriesByResult = new EnumMap<>(WorkStealResult.class); - if (!queueEntries.isEmpty()) { - tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult); - } + tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult); return queueEntriesByResult; } @@ -333,7 +330,7 @@ private void tryToStealWorkWithBlocking(Map entries, BlockingMode blocking, + private void tryToStealWork(Iterable entries, BlockingMode blocking, Map> queueEntriesByResult) { for (var entry : entries) { var state = tryToStealWork(entry, blocking); @@ -539,16 +536,21 @@ private enum BlockingMode { NON_BLOCKING, BLOCKING } - private static class WorkQueue { - - private final Queue queue = new PriorityBlockingQueue<>(); + private static class WorkQueue implements Iterable { + private final AtomicInteger index = new AtomicInteger(); + private final Set queue = new ConcurrentSkipListSet<>(); Entry add(TestTask task) { - Entry entry = Entry.create(task); + Entry entry = createEntry(task); LOGGER.trace(() -> "forking: " + entry.task); return doAdd(entry); } + Entry createEntry(TestTask task) { + int level = task.getTestDescriptor().getUniqueId().getSegments().size(); + return new Entry(task, new CompletableFuture<>(), level, index.getAndIncrement()); + } + void addAll(Collection entries) { entries.forEach(this::doAdd); } @@ -566,13 +568,6 @@ private Entry doAdd(Entry entry) { return entry; } - private List peekAll() { - List entries = new ArrayList<>(queue); - // Iteration order isn't the same as queue order. - entries.sort(naturalOrder()); - return entries; - } - boolean remove(Entry entry) { return queue.remove(entry); } @@ -581,18 +576,14 @@ boolean isEmpty() { return queue.isEmpty(); } + @Override + public Iterator iterator() { + return queue.iterator(); + } + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, int index) implements Comparable { - static Entry create(TestTask task) { - return createWithIndex(task, 0); - } - - static Entry createWithIndex(TestTask task, int index) { - int level = task.getTestDescriptor().getUniqueId().getSegments().size(); - return new Entry(task, new CompletableFuture<>(), level, index); - } - @SuppressWarnings("FutureReturnValueIgnored") Entry { future.whenComplete((__, t) -> { From a4d7ed60137b01f54db624ab82c2bc20dd2e7f47 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Tue, 21 Oct 2025 03:57:33 +0200 Subject: [PATCH 078/120] Use long index, because containers are int size --- .../ConcurrentHierarchicalTestExecutorService.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 05d1f93a1e6b..c7cc3442fa0f 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -39,6 +39,7 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiFunction; import java.util.function.BooleanSupplier; import java.util.function.Consumer; @@ -537,7 +538,7 @@ private enum BlockingMode { } private static class WorkQueue implements Iterable { - private final AtomicInteger index = new AtomicInteger(); + private final AtomicLong index = new AtomicLong(); private final Set queue = new ConcurrentSkipListSet<>(); Entry add(TestTask task) { @@ -581,7 +582,7 @@ public Iterator iterator() { return queue.iterator(); } - private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, int index) + private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, long index) implements Comparable { @SuppressWarnings("FutureReturnValueIgnored") @@ -606,7 +607,7 @@ public int compareTo(Entry that) { if (result != 0) { return result; } - return Integer.compare(that.index, this.index); + return Long.compare(that.index, this.index); } private boolean isContainer() { From b11a481e18aeeb90be2f70869aac581bdfc9f35e Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Fri, 24 Oct 2025 17:02:53 +0200 Subject: [PATCH 079/120] Polishing --- .../ConcurrentHierarchicalTestExecutorService.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index c7cc3442fa0f..cfb340758061 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -153,14 +153,14 @@ private void maybeStartWorker(BooleanSupplier doneCondition) { () -> this.maybeStartWorker(doneCondition))); } - private record RunLeaseAwareWorker(WorkerLease workerLease, Runnable worker, Runnable onWorkerFinished) + private record RunLeaseAwareWorker(WorkerLease workerLease, Runnable work, Runnable onWorkerFinished) implements Runnable { @Override public void run() { LOGGER.trace(() -> "starting worker"); try { - worker.run(); + work.run(); } finally { workerLease.release(false); @@ -667,8 +667,10 @@ void reacquire() throws InterruptedException { @Override public String toString() { - return new ToStringBuilder(this).append("parallelism", parallelism).append("semaphore", - semaphore).toString(); + return new ToStringBuilder(this) // + .append("parallelism", parallelism) // + .append("semaphore", semaphore) // + .toString(); } } From 8ef8d200b1e1e2599c1e5e9cff5b02d778451125 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sat, 25 Oct 2025 16:35:55 +0200 Subject: [PATCH 080/120] Steal other dynamic children before blocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leonard Brünings --- ...urrentHierarchicalTestExecutorService.java | 23 ++++++++++++-- ...tHierarchicalTestExecutorServiceTests.java | 30 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index cfb340758061..1c805da785e1 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -20,8 +20,10 @@ import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; +import java.util.Deque; import java.util.EnumMap; import java.util.Iterator; import java.util.List; @@ -105,7 +107,9 @@ public void close() { return completedFuture(null); } - return new WorkStealingFuture(enqueue(testTask)); + var entry = enqueue(testTask); + workerThread.addDynamicChild(entry); + return new WorkStealingFuture(entry); } @Override @@ -197,6 +201,8 @@ private class WorkerThread extends Thread { @Nullable WorkerLease workerLease; + private Deque dynamicChildren = new ArrayDeque<>(); + WorkerThread(Runnable runnable, String name) { super(runnable, name); } @@ -490,6 +496,16 @@ private static CompletableFuture toCombinedFuture(List entri return CompletableFuture.allOf(futures); } + private void addDynamicChild(WorkQueue.Entry entry) { + dynamicChildren.add(entry); + } + + private void tryToStealWorkFromDynamicChildren() { + for (var entry : dynamicChildren) { + tryToStealWork(entry, BlockingMode.NON_BLOCKING); + } + } + private enum WorkStealResult { EXECUTED_BY_DIFFERENT_WORKER, RESOURCE_LOCK_UNAVAILABLE, EXECUTED_BY_THIS_WORKER } @@ -519,7 +535,10 @@ private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Vo if (entry.future.isDone()) { return callable.call(); } - // TODO steal other dynamic children until future is done and check again before blocking + workerThread.tryToStealWorkFromDynamicChildren(); + if (entry.future.isDone()) { + return callable.call(); + } LOGGER.trace(() -> "blocking for child task"); return workerThread.runBlocking(entry.future::isDone, () -> { try { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 17db098e1e45..6c77db5bd793 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -525,6 +525,36 @@ void workIsStolenInReverseOrder() throws Exception { .isSorted(); } + @RepeatedTest(value = 100, failureThreshold = 1) + void stealsDynamicChildren() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + + var child1Started = new CountDownLatch(1); + var child2Finished = new CountDownLatch(1); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + child2Finished.await(); + }) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Finished::countDown) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + child1Started.await(); + var future2 = requiredService().submit(child2); + future1.get(); + future2.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child2.executionThread).isEqualTo(root.executionThread).isNotEqualTo(child1.executionThread); + } + private static ExclusiveResource exclusiveResource(LockMode lockMode) { return new ExclusiveResource("key", lockMode); } From 80c38d88a18ad383a0711177861ba6f180fd3e30 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 26 Oct 2025 10:23:24 +0100 Subject: [PATCH 081/120] Only steal queue entries for siblings of dynamic children MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leonard Brünings --- ...urrentHierarchicalTestExecutorService.java | 56 +++++++-- ...tHierarchicalTestExecutorServiceTests.java | 108 ++++++++++++++++++ 2 files changed, 154 insertions(+), 10 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 1c805da785e1..5e2a363479ed 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -108,7 +108,7 @@ public void close() { } var entry = enqueue(testTask); - workerThread.addDynamicChild(entry); + workerThread.trackSubmittedChild(entry); return new WorkStealingFuture(entry); } @@ -201,7 +201,7 @@ private class WorkerThread extends Thread { @Nullable WorkerLease workerLease; - private Deque dynamicChildren = new ArrayDeque<>(); + private final Deque stateStack = new ArrayDeque<>(); WorkerThread(Runnable runnable, String name) { super(runnable, name); @@ -480,10 +480,12 @@ private boolean tryExecuteTask(TestTask testTask) { private void doExecute(TestTask testTask) { LOGGER.trace(() -> "executing: " + testTask); + stateStack.push(new State()); try { testTask.execute(); } finally { + stateStack.pop(); LOGGER.trace(() -> "finished executing: " + testTask); } } @@ -496,18 +498,52 @@ private static CompletableFuture toCombinedFuture(List entri return CompletableFuture.allOf(futures); } - private void addDynamicChild(WorkQueue.Entry entry) { - dynamicChildren.add(entry); + private void trackSubmittedChild(WorkQueue.Entry entry) { + stateStack.element().trackSubmittedChild(entry); } - private void tryToStealWorkFromDynamicChildren() { - for (var entry : dynamicChildren) { - tryToStealWork(entry, BlockingMode.NON_BLOCKING); + private void tryToStealWorkFromSubmittedChildren() { + var currentState = stateStack.element(); + var currentSubmittedChildren = currentState.submittedChildren; + if (currentSubmittedChildren == null || currentSubmittedChildren.isEmpty()) { + return; + } + var iterator = currentSubmittedChildren.listIterator(currentSubmittedChildren.size()); + while (iterator.hasPrevious()) { + WorkQueue.Entry entry = iterator.previous(); + var result = tryToStealWork(entry, BlockingMode.NON_BLOCKING); + if (result.isExecuted()) { + iterator.remove(); + } + } + currentState.clearIfEmpty(); + } + + private static class State { + + @Nullable + private List submittedChildren; + + private void trackSubmittedChild(WorkQueue.Entry entry) { + if (submittedChildren == null) { + submittedChildren = new ArrayList<>(); + } + submittedChildren.add(entry); + } + + private void clearIfEmpty() { + if (submittedChildren != null && submittedChildren.isEmpty()) { + submittedChildren = null; + } } } private enum WorkStealResult { - EXECUTED_BY_DIFFERENT_WORKER, RESOURCE_LOCK_UNAVAILABLE, EXECUTED_BY_THIS_WORKER + EXECUTED_BY_DIFFERENT_WORKER, RESOURCE_LOCK_UNAVAILABLE, EXECUTED_BY_THIS_WORKER; + + private boolean isExecuted() { + return this != RESOURCE_LOCK_UNAVAILABLE; + } } private interface BlockingAction { @@ -535,11 +571,11 @@ private static class WorkStealingFuture extends BlockingAwareFuture<@Nullable Vo if (entry.future.isDone()) { return callable.call(); } - workerThread.tryToStealWorkFromDynamicChildren(); + workerThread.tryToStealWorkFromSubmittedChildren(); if (entry.future.isDone()) { return callable.call(); } - LOGGER.trace(() -> "blocking for child task"); + LOGGER.trace(() -> "blocking for child task: " + entry.task); return workerThread.runBlocking(entry.future::isDone, () -> { try { return callable.call(); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java index 6c77db5bd793..1491fbb2f4b1 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java @@ -555,6 +555,114 @@ void stealsDynamicChildren() throws Exception { assertThat(child2.executionThread).isEqualTo(root.executionThread).isNotEqualTo(child1.executionThread); } + @RepeatedTest(value = 100, failureThreshold = 1) + void stealsNestedDynamicChildren() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + + var barrier = new CyclicBarrier(2); + + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1a").withLevel(3); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1b").withLevel(3); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + barrier.await(); + var futureA = requiredService().submit(leaf1a); + barrier.await(); + var futureB = requiredService().submit(leaf1b); + futureA.get(); + futureB.get(); + barrier.await(); + }) // + .withName("child1").withLevel(2); + + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf2a").withLevel(3); + var leaf2b = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf2b").withLevel(3); + + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + barrier.await(); + var futureA = requiredService().submit(leaf2a); + barrier.await(); + var futureB = requiredService().submit(leaf2b); + futureB.get(); + futureA.get(); + barrier.await(); + }) // + .withName("child2").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + var future2 = requiredService().submit(child2); + future1.get(); + future2.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2, leaf1a, leaf1b, leaf2a, leaf2b)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(child2.executionThread).isNotEqualTo(child1.executionThread); + assertThat(child1.executionThread).isEqualTo(leaf1a.executionThread).isEqualTo(leaf1b.executionThread); + assertThat(child2.executionThread).isEqualTo(leaf2a.executionThread).isEqualTo(leaf2b.executionThread); + } + + @RepeatedTest(value = 100, failureThreshold = 1) + void stealsSiblingDynamicChildrenOnly() throws Exception { + service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 3)); + + var child1Started = new CountDownLatch(1); + var child2Started = new CountDownLatch(1); + var leaf1ASubmitted = new CountDownLatch(1); + var leaf1AStarted = new CountDownLatch(1); + + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + leaf1AStarted.countDown(); + child2Started.await(); + }) // + .withName("leaf1a").withLevel(3); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + leaf1ASubmitted.await(); + }) // + .withName("child1").withLevel(2); + + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Started::countDown) // + .withName("child2").withLevel(2); + + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + var futureA = requiredService().submit(leaf1a); + leaf1ASubmitted.countDown(); + leaf1AStarted.await(); + futureA.get(); + }) // + .withName("child3").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + child1Started.await(); + var future2 = requiredService().submit(child2); + var future3 = requiredService().submit(child3); + future1.get(); + future2.get(); + future3.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2, child3, leaf1a)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(child3.executionThread).isNotEqualTo(child1.executionThread).isNotEqualTo(child2.executionThread); + assertThat(child1.executionThread).isNotEqualTo(child2.executionThread); + assertThat(child1.executionThread).isEqualTo(leaf1a.executionThread); + } + private static ExclusiveResource exclusiveResource(LockMode lockMode) { return new ExclusiveResource("key", lockMode); } From b3b13452f9e33072a70138375a3af4f73a49a5ad Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Sun, 26 Oct 2025 10:23:31 +0100 Subject: [PATCH 082/120] Polishing --- .../hierarchical/ConcurrentHierarchicalTestExecutorService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java index 5e2a363479ed..2d5ffaa76d98 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java @@ -351,7 +351,7 @@ private WorkStealResult tryToStealWork(WorkQueue.Entry entry, BlockingMode block } var claimed = workQueue.remove(entry); if (claimed) { - LOGGER.trace(() -> "stole work: " + entry); + LOGGER.trace(() -> "stole work: " + entry.task); var executed = executeStolenWork(entry, blockingMode); if (executed) { return WorkStealResult.EXECUTED_BY_THIS_WORKER; From 3ed8f0ffcd7901326045cde567cfe0f7b07d89ca Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 28 Oct 2025 15:58:21 +0100 Subject: [PATCH 083/120] Revert "Introduce configuration parameter for opting in to new implementation" This reverts commit 9d77839067a612c571c7448fc9c74e8d0e5d78d9. --- .../test/resources/junit-platform.properties | 1 - .../org/junit/jupiter/engine/Constants.java | 20 +------ .../jupiter/engine/JupiterTestEngine.java | 5 +- .../config/CachingJupiterConfiguration.java | 6 -- .../config/DefaultJupiterConfiguration.java | 20 ------- ...iatingConfigurationParameterConverter.java | 60 +++---------------- .../engine/config/JupiterConfiguration.java | 5 -- .../ParallelExecutionIntegrationTests.java | 10 +--- .../test/resources/junit-platform.properties | 1 - 9 files changed, 15 insertions(+), 113 deletions(-) diff --git a/documentation/src/test/resources/junit-platform.properties b/documentation/src/test/resources/junit-platform.properties index 563ab961f558..0f0255f62dbb 100644 --- a/documentation/src/test/resources/junit-platform.properties +++ b/documentation/src/test/resources/junit-platform.properties @@ -1,5 +1,4 @@ junit.jupiter.execution.parallel.enabled=true -junit.jupiter.execution.parallel.executor=org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=6 diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java index 1feda4ed9ab5..fb08e6e57dee 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java @@ -13,7 +13,6 @@ import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.apiguardian.api.API.Status.MAINTAINED; import static org.apiguardian.api.API.Status.STABLE; -import static org.junit.jupiter.engine.config.JupiterConfiguration.PARALLEL_CONFIG_PREFIX; import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_CUSTOM_CLASS_PROPERTY_NAME; import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME; import static org.junit.platform.engine.support.hierarchical.DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME; @@ -39,8 +38,6 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; -import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; -import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ParallelExecutionConfigurationStrategy; /** @@ -213,21 +210,6 @@ public final class Constants { @API(status = STABLE, since = "5.10") public static final String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; - /** - * Property name used to configure the fully qualified class name - * {@link HierarchicalTestExecutorService} implementation to use if parallel - * test execution is - * {@linkplain #PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME enabled}: {@value} - * - *

The implementation class must provide a parameter of type - * - *

By default, {@link ForkJoinPoolHierarchicalTestExecutorService} is used. - * - * @since 6.1 - */ - @API(status = EXPERIMENTAL, since = "6.1") - public static final String PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME = JupiterConfiguration.PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME; - /** * Property name used to enable auto-closing of {@link AutoCloseable} instances * @@ -255,6 +237,8 @@ public final class Constants { @API(status = STABLE, since = "5.10") public static final String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; + static final String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; + /** * Property name used to select the * {@link ParallelExecutionConfigurationStrategy}: {@value} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 1f023e04f339..568dfff4b081 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -27,7 +27,9 @@ import org.junit.platform.engine.ExecutionRequest; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.UniqueId; +import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; @@ -77,7 +79,8 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return configuration.createParallelExecutorService(); + return new ConcurrentHierarchicalTestExecutorService(new PrefixedConfigurationParameters( + request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java index 677bca31dbc3..d849bd609c4d 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/CachingJupiterConfiguration.java @@ -32,7 +32,6 @@ import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; -import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; /** * Caching implementation of the {@link JupiterConfiguration} API. @@ -70,11 +69,6 @@ public boolean isParallelExecutionEnabled() { __ -> delegate.isParallelExecutionEnabled()); } - @Override - public HierarchicalTestExecutorService createParallelExecutorService() { - return delegate.createParallelExecutorService(); - } - @Override public boolean isClosingStoredAutoCloseablesEnabled() { return (boolean) cache.computeIfAbsent(CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME, diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 400f7f072646..50ea9550fe8f 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -34,17 +34,13 @@ import org.junit.jupiter.api.io.CleanupMode; import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.api.parallel.ExecutionMode; -import org.junit.jupiter.engine.config.InstantiatingConfigurationParameterConverter.Strictness; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.engine.ConfigurationParameters; import org.junit.platform.engine.DiscoveryIssue; import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.OutputDirectoryCreator; -import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService; -import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; /** * Default implementation of the {@link JupiterConfiguration} API. @@ -85,10 +81,6 @@ public class DefaultJupiterConfiguration implements JupiterConfiguration { private static final ConfigurationParameterConverter extensionContextScopeConverter = // new EnumConfigurationParameterConverter<>(ExtensionContextScope.class, "extension context scope"); - private static final ConfigurationParameterConverter parallelExecutorServiceConverter = // - new InstantiatingConfigurationParameterConverter<>(HierarchicalTestExecutorService.class, - "parallel executor service", Strictness.FAIL, parameters -> new Object[] { parallelConfig(parameters) }); - private final ConfigurationParameters configurationParameters; private final OutputDirectoryCreator outputDirectoryCreator; @@ -144,13 +136,6 @@ public boolean isParallelExecutionEnabled() { return configurationParameters.getBoolean(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME).orElse(false); } - @Override - public HierarchicalTestExecutorService createParallelExecutorService() { - return parallelExecutorServiceConverter.get(configurationParameters, PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME) // - .orElseGet( - () -> new ForkJoinPoolHierarchicalTestExecutorService(parallelConfig(configurationParameters))); - } - @Override public boolean isClosingStoredAutoCloseablesEnabled() { return configurationParameters.getBoolean(CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME).orElse(true); @@ -229,9 +214,4 @@ public ExtensionContextScope getDefaultTestInstantiationExtensionContextScope() public OutputDirectoryCreator getOutputDirectoryCreator() { return outputDirectoryCreator; } - - private static PrefixedConfigurationParameters parallelConfig(ConfigurationParameters configurationParameters) { - return new PrefixedConfigurationParameters(configurationParameters, PARALLEL_CONFIG_PREFIX); - } - } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java index 12f1f907cdcf..7315af7b9f8a 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/InstantiatingConfigurationParameterConverter.java @@ -10,43 +10,28 @@ package org.junit.jupiter.engine.config; -import java.lang.reflect.Constructor; import java.util.Optional; -import java.util.function.Function; import java.util.function.Supplier; -import java.util.stream.IntStream; -import org.junit.platform.commons.JUnitException; import org.junit.platform.commons.function.Try; import org.junit.platform.commons.logging.Logger; import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.support.ReflectionSupport; -import org.junit.platform.commons.util.Preconditions; -import org.junit.platform.commons.util.ReflectionUtils; import org.junit.platform.engine.ConfigurationParameters; /** * @since 5.5 */ -final class InstantiatingConfigurationParameterConverter implements ConfigurationParameterConverter { +class InstantiatingConfigurationParameterConverter implements ConfigurationParameterConverter { private static final Logger logger = LoggerFactory.getLogger(InstantiatingConfigurationParameterConverter.class); private final Class clazz; private final String name; - private final Strictness strictness; - private final Function argumentResolver; InstantiatingConfigurationParameterConverter(Class clazz, String name) { - this(clazz, name, Strictness.WARN, __ -> new Object[0]); - } - - InstantiatingConfigurationParameterConverter(Class clazz, String name, Strictness strictness, - Function argumentResolver) { this.clazz = clazz; this.name = name; - this.strictness = strictness; - this.argumentResolver = argumentResolver; } @Override @@ -59,16 +44,15 @@ Supplier> supply(ConfigurationParameters configurationParameters, St return configurationParameters.get(key) .map(String::strip) .filter(className -> !className.isEmpty()) - .map(className -> newInstanceSupplier(className, key, configurationParameters)) + .map(className -> newInstanceSupplier(className, key)) .orElse(Optional::empty); // @formatter:on } - private Supplier> newInstanceSupplier(String className, String key, - ConfigurationParameters configurationParameters) { + private Supplier> newInstanceSupplier(String className, String key) { Try> clazz = ReflectionSupport.tryToLoadClass(className); // @formatter:off - return () -> clazz.andThenTry(it -> instantiate(it, configurationParameters)) + return () -> clazz.andThenTry(ReflectionSupport::newInstance) .andThenTry(this.clazz::cast) .ifSuccess(generator -> logSuccessMessage(className, key)) .ifFailure(cause -> logFailureMessage(className, key, cause)) @@ -76,36 +60,10 @@ private Supplier> newInstanceSupplier(String className, String key, // @formatter:on } - @SuppressWarnings("unchecked") - private V instantiate(Class clazz, ConfigurationParameters configurationParameters) { - var arguments = argumentResolver.apply(configurationParameters); - if (arguments.length == 0) { - return ReflectionSupport.newInstance(clazz); - } - var constructors = ReflectionUtils.findConstructors(clazz, it -> { - if (it.getParameterCount() != arguments.length) { - return false; - } - var parameters = it.getParameters(); - return IntStream.range(0, parameters.length) // - .allMatch(i -> parameters[i].getType().isAssignableFrom(arguments[i].getClass())); - }); - Preconditions.condition(constructors.size() == 1, - () -> "Failed to find unambiguous constructor for %s. Candidates: %s".formatted(clazz.getName(), - constructors)); - return ReflectionUtils.newInstance((Constructor) constructors.get(0), arguments); - } - private void logFailureMessage(String className, String key, Exception cause) { - switch (strictness) { - case WARN -> logger.warn(cause, () -> """ - Failed to load default %s class '%s' set via the '%s' configuration parameter. \ - Falling back to default behavior.""".formatted(this.name, className, key)); - case FAIL -> throw new JUnitException( - "Failed to load default %s class '%s' set via the '%s' configuration parameter.".formatted(this.name, - className, key), - cause); - } + logger.warn(cause, () -> """ + Failed to load default %s class '%s' set via the '%s' configuration parameter. \ + Falling back to default behavior.""".formatted(this.name, className, key)); } private void logSuccessMessage(String className, String key) { @@ -113,8 +71,4 @@ private void logSuccessMessage(String className, String key) { className, key)); } - enum Strictness { - FAIL, WARN - } - } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 17dd7dd0810a..1d7ec56e40c2 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -31,7 +31,6 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; -import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; /** * @since 5.4 @@ -43,8 +42,6 @@ public interface JupiterConfiguration { String EXTENSIONS_AUTODETECTION_EXCLUDE_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.exclude"; String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate"; String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; - String PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME = "junit.jupiter.execution.parallel.executor"; - String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; String CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.store.close.autocloseable.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; @@ -64,8 +61,6 @@ public interface JupiterConfiguration { boolean isParallelExecutionEnabled(); - HierarchicalTestExecutorService createParallelExecutorService(); - boolean isClosingStoredAutoCloseablesEnabled(); boolean isExtensionAutoDetectionEnabled(); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index 492a561adef8..ddeaaf294906 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -25,7 +25,6 @@ import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; -import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME; import static org.junit.platform.commons.util.CollectionUtils.getOnlyElement; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClasses; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; @@ -75,7 +74,6 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.api.parallel.ResourceLock; -import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.engine.DiscoverySelector; @@ -91,10 +89,7 @@ * @since 1.3 */ @SuppressWarnings({ "JUnitMalformedDeclaration", "NewClassNamingConvention" }) -@ParameterizedClass -@ValueSource(classes = { ForkJoinPoolHierarchicalTestExecutorService.class, - ConcurrentHierarchicalTestExecutorService.class }) -record ParallelExecutionIntegrationTests(Class implementation) { +class ParallelExecutionIntegrationTests { @Test void successfulParallelTest(TestReporter reporter) { @@ -584,12 +579,11 @@ private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map< return executeWithFixedParallelism(parallelism, configParams, selectClasses(testClasses)); } - private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, + private static EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, List selectors) { return EngineTestKit.engine("junit-jupiter") // .selectors(selectors) // .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true)) // - .configurationParameter(PARALLEL_EXECUTION_EXECUTOR_PROPERTY_NAME, implementation.getName()) // .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") // .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism)) // .configurationParameters(configParams) // diff --git a/platform-tooling-support-tests/src/test/resources/junit-platform.properties b/platform-tooling-support-tests/src/test/resources/junit-platform.properties index 95abd2bb9676..d24bbed7d3d9 100644 --- a/platform-tooling-support-tests/src/test/resources/junit-platform.properties +++ b/platform-tooling-support-tests/src/test/resources/junit-platform.properties @@ -1,5 +1,4 @@ junit.jupiter.execution.parallel.enabled=true -junit.jupiter.execution.parallel.executor=org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService junit.jupiter.execution.parallel.mode.default=concurrent junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=0.25 From fc59fc2e1ef27339520dd2b98c34683e8f356cc3 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 28 Oct 2025 16:01:37 +0100 Subject: [PATCH 084/120] Rename new implementation --- .../jupiter/engine/JupiterTestEngine.java | 4 +- ...dPoolHierarchicalTestExecutorService.java} | 15 ++++--- .../hierarchical/WorkerLeaseManagerTests.java | 2 +- ...HierarchicalTestExecutorServiceTests.java} | 44 +++++++++---------- .../src/test/resources/log4j2-test.xml | 2 +- 5 files changed, 34 insertions(+), 33 deletions(-) rename junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/{ConcurrentHierarchicalTestExecutorService.java => WorkerThreadPoolHierarchicalTestExecutorService.java} (97%) rename platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/{ConcurrentHierarchicalTestExecutorServiceTests.java => WorkerThreadPoolHierarchicalTestExecutorServiceTests.java} (93%) diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 568dfff4b081..ee4a1d884595 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -29,10 +29,10 @@ import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; +import org.junit.platform.engine.support.hierarchical.WorkerThreadPoolHierarchicalTestExecutorService; /** * The JUnit Jupiter {@link org.junit.platform.engine.TestEngine TestEngine}. @@ -79,7 +79,7 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return new ConcurrentHierarchicalTestExecutorService(new PrefixedConfigurationParameters( + return new WorkerThreadPoolHierarchicalTestExecutorService(new PrefixedConfigurationParameters( request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java similarity index 97% rename from junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java rename to junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 2d5ffaa76d98..57ae9b9aa231 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -59,24 +59,25 @@ * @since 6.1 */ @API(status = EXPERIMENTAL, since = "6.1") -public class ConcurrentHierarchicalTestExecutorService implements HierarchicalTestExecutorService { +public class WorkerThreadPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { - private static final Logger LOGGER = LoggerFactory.getLogger(ConcurrentHierarchicalTestExecutorService.class); + private static final Logger LOGGER = LoggerFactory.getLogger(WorkerThreadPoolHierarchicalTestExecutorService.class); private final WorkQueue workQueue = new WorkQueue(); private final ExecutorService threadPool; private final int parallelism; private final WorkerLeaseManager workerLeaseManager; - public ConcurrentHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { + public WorkerThreadPoolHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { this(DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters)); } - public ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { + public WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } - ConcurrentHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { + WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, + ClassLoader classLoader) { ThreadFactory threadFactory = new WorkerThreadFactory(classLoader); parallelism = configuration.getParallelism(); workerLeaseManager = new WorkerLeaseManager(parallelism, this::maybeStartWorker); @@ -222,8 +223,8 @@ static WorkerThread getOrThrow() { return workerThread; } - ConcurrentHierarchicalTestExecutorService executor() { - return ConcurrentHierarchicalTestExecutorService.this; + WorkerThreadPoolHierarchicalTestExecutorService executor() { + return WorkerThreadPoolHierarchicalTestExecutorService.this; } void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java index ec3068db2516..7bd12990e0a8 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerLeaseManagerTests.java @@ -15,7 +15,7 @@ import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorService.WorkerLeaseManager; +import org.junit.platform.engine.support.hierarchical.WorkerThreadPoolHierarchicalTestExecutorService.WorkerLeaseManager; class WorkerLeaseManagerTests { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java similarity index 93% rename from platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java rename to platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 1491fbb2f4b1..2ae4c353b6a0 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -61,11 +61,11 @@ */ @SuppressWarnings("resource") @Timeout(5) -class ConcurrentHierarchicalTestExecutorServiceTests { +class WorkerThreadPoolHierarchicalTestExecutorServiceTests { @AutoClose @Nullable - ConcurrentHierarchicalTestExecutorService service; + WorkerThreadPoolHierarchicalTestExecutorService service; @ParameterizedTest @EnumSource(ExecutionMode.class) @@ -75,7 +75,7 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { var customClassLoader = new URLClassLoader(new URL[0], this.getClass().getClassLoader()); try (customClassLoader) { - service = new ConcurrentHierarchicalTestExecutorService(configuration(1), customClassLoader); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1), customClassLoader); service.submit(task).get(); } @@ -90,7 +90,7 @@ void executesSingleTask(ExecutionMode executionMode) throws Exception { @Test void invokeAllMustBeExecutedFromWithinThreadPool() { var tasks = List.of(new TestTaskStub(ExecutionMode.CONCURRENT)); - service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); assertPreconditionViolationFor(() -> requiredService().invokeAll(tasks)) // .withMessage("invokeAll() must be called from a worker thread that belongs to this executor"); @@ -100,7 +100,7 @@ void invokeAllMustBeExecutedFromWithinThreadPool() { @EnumSource(ExecutionMode.class) void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode childExecutionMode) throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); var child = new TestTaskStub(childExecutionMode); var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(List.of(child))); @@ -116,7 +116,7 @@ void executesSingleChildInSameThreadRegardlessOfItsExecutionMode(ExecutionMode c @Test void executesTwoChildrenConcurrently() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); var latch = new CountDownLatch(2); Executable behavior = () -> { @@ -136,7 +136,7 @@ void executesTwoChildrenConcurrently() throws Exception { @Test void executesTwoChildrenInSameThread() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); var children = List.of(new TestTaskStub(ExecutionMode.SAME_THREAD), new TestTaskStub(ExecutionMode.SAME_THREAD)); @@ -158,7 +158,7 @@ void acquiresResourceLockForRootTask() throws Exception { var task = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock); - service = new ConcurrentHierarchicalTestExecutorService(configuration(1)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1)); service.submit(task).get(); task.assertExecutedSuccessfully(); @@ -171,7 +171,7 @@ void acquiresResourceLockForRootTask() throws Exception { @Test void acquiresResourceLockForChildTasks() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); var resourceLock = mock(ResourceLock.class); when(resourceLock.tryAcquire()).thenReturn(true, false); @@ -198,7 +198,7 @@ void acquiresResourceLockForChildTasks() throws Exception { @Test void runsTasksWithoutConflictingLocksConcurrently() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(3)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(3)); var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); @@ -228,7 +228,7 @@ void runsTasksWithoutConflictingLocksConcurrently() throws Exception { @Test void processingQueueEntriesSkipsOverUnavailableResources() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); @@ -265,7 +265,7 @@ void processingQueueEntriesSkipsOverUnavailableResources() throws Exception { @Test void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); @@ -302,7 +302,7 @@ void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { @Test void prioritizesChildrenOfStartedContainers() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); var leavesStarted = new CountDownLatch(2); @@ -331,7 +331,7 @@ void prioritizesChildrenOfStartedContainers() throws Exception { @Test void prioritizesTestsOverContainers() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); var leavesStarted = new CountDownLatch(2); var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // @@ -358,7 +358,7 @@ void prioritizesTestsOverContainers() throws Exception { @RepeatedTest(value = 100, failureThreshold = 1) void limitsWorkerThreadsToMaxPoolSize() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(3, 3)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(3, 3)); CountDownLatch latch = new CountDownLatch(3); Executable behavior = () -> { @@ -397,7 +397,7 @@ void limitsWorkerThreadsToMaxPoolSize() throws Exception { @Test void stealsBlockingChildren() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); var child1Started = new CountDownLatch(1); var leaf2aStarted = new CountDownLatch(1); @@ -449,7 +449,7 @@ public void release() { @RepeatedTest(value = 100, failureThreshold = 1) void executesChildrenInOrder() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(1, 1)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("leaf1a").withLevel(2); @@ -476,7 +476,7 @@ void executesChildrenInOrder() throws Exception { @RepeatedTest(value = 100, failureThreshold = 1) void workIsStolenInReverseOrder() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); // Execute tasks pairwise CyclicBarrier cyclicBarrier = new CyclicBarrier(2); @@ -527,7 +527,7 @@ void workIsStolenInReverseOrder() throws Exception { @RepeatedTest(value = 100, failureThreshold = 1) void stealsDynamicChildren() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); var child1Started = new CountDownLatch(1); var child2Finished = new CountDownLatch(1); @@ -557,7 +557,7 @@ void stealsDynamicChildren() throws Exception { @RepeatedTest(value = 100, failureThreshold = 1) void stealsNestedDynamicChildren() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); var barrier = new CyclicBarrier(2); @@ -612,7 +612,7 @@ void stealsNestedDynamicChildren() throws Exception { @RepeatedTest(value = 100, failureThreshold = 1) void stealsSiblingDynamicChildrenOnly() throws Exception { - service = new ConcurrentHierarchicalTestExecutorService(configuration(2, 3)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 3)); var child1Started = new CountDownLatch(1); var child2Started = new CountDownLatch(1); @@ -667,7 +667,7 @@ private static ExclusiveResource exclusiveResource(LockMode lockMode) { return new ExclusiveResource("key", lockMode); } - private ConcurrentHierarchicalTestExecutorService requiredService() { + private WorkerThreadPoolHierarchicalTestExecutorService requiredService() { return requireNonNull(service); } diff --git a/platform-tests/src/test/resources/log4j2-test.xml b/platform-tests/src/test/resources/log4j2-test.xml index aa720cb4e41c..4edabb15bb2f 100644 --- a/platform-tests/src/test/resources/log4j2-test.xml +++ b/platform-tests/src/test/resources/log4j2-test.xml @@ -21,7 +21,7 @@ - + From d178918bf1bddaa7864cc7a8c4ebcd44741642a8 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 28 Oct 2025 16:42:04 +0100 Subject: [PATCH 085/120] Introduce `ConcurrentHierarchicalTestExecutorServiceFactory` --- .../test/resources/junit-platform.properties | 1 + .../org/junit/jupiter/engine/Constants.java | 18 +++ .../jupiter/engine/JupiterTestEngine.java | 4 +- ...ierarchicalTestExecutorServiceFactory.java | 110 ++++++++++++++++++ ...adPoolHierarchicalTestExecutorService.java | 5 - .../ParallelExecutionIntegrationTests.java | 11 +- .../test/resources/junit-platform.properties | 1 + 7 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java diff --git a/documentation/src/test/resources/junit-platform.properties b/documentation/src/test/resources/junit-platform.properties index 0f0255f62dbb..835e9ba5e796 100644 --- a/documentation/src/test/resources/junit-platform.properties +++ b/documentation/src/test/resources/junit-platform.properties @@ -1,5 +1,6 @@ junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.config.executor-service=worker_thread_pool junit.jupiter.execution.parallel.config.strategy=fixed junit.jupiter.execution.parallel.config.fixed.parallelism=6 diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java index fb08e6e57dee..87241cdec90c 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java @@ -38,6 +38,8 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.engine.support.hierarchical.ParallelExecutionConfigurationStrategy; /** @@ -239,6 +241,22 @@ public final class Constants { static final String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; + /** + * Property name used to determine the desired + * {@link ConcurrentExecutorServiceType ConcurrentExecutorServiceType}. + * + *

Value must be + * {@link ConcurrentExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or + * {@link ConcurrentExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, + * ignoring case. + * + * @since 6.1 + * @see ConcurrentHierarchicalTestExecutorServiceFactory + */ + @API(status = EXPERIMENTAL, since = "6.1") + public static final String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + + ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME; + /** * Property name used to select the * {@link ParallelExecutionConfigurationStrategy}: {@value} diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index ee4a1d884595..b45b8df74523 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -29,10 +29,10 @@ import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; -import org.junit.platform.engine.support.hierarchical.WorkerThreadPoolHierarchicalTestExecutorService; /** * The JUnit Jupiter {@link org.junit.platform.engine.TestEngine TestEngine}. @@ -79,7 +79,7 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return new WorkerThreadPoolHierarchicalTestExecutorService(new PrefixedConfigurationParameters( + return ConcurrentHierarchicalTestExecutorServiceFactory.create(new PrefixedConfigurationParameters( request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java new file mode 100644 index 000000000000..3c2d288fea59 --- /dev/null +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java @@ -0,0 +1,110 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.engine.support.hierarchical; + +import static org.apiguardian.api.API.Status.EXPERIMENTAL; + +import java.util.Locale; + +import org.apiguardian.api.API; +import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; + +/** + * Factory for {@link HierarchicalTestExecutorService} instances that support + * concurrent execution. + * + * @since 6.1 + * @see ConcurrentExecutorServiceType + * @see ForkJoinPoolHierarchicalTestExecutorService + * @see WorkerThreadPoolHierarchicalTestExecutorService + */ +@API(status = EXPERIMENTAL, since = "6.1") +public class ConcurrentHierarchicalTestExecutorServiceFactory { + + /** + * Property name used to determine the desired + * {@link ConcurrentExecutorServiceType ConcurrentExecutorServiceType}. + * + *

Value must be + * {@link ConcurrentExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or + * {@link ConcurrentExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, + * ignoring case. + */ + public static final String EXECUTOR_SERVICE_PROPERTY_NAME = "executor-service"; + + /** + * Create a new {@link HierarchicalTestExecutorService} based on the + * supplied {@link ConfigurationParameters}. + * + *

This method is typically invoked with an instance of + * {@link PrefixedConfigurationParameters} that was created with an + * engine-specific prefix. + * + *

The {@value #EXECUTOR_SERVICE_PROPERTY_NAME} key is used to determine + * which service implementation is to be used. Which other parameters are + * read depends on the configured + * {@link ParallelExecutionConfigurationStrategy} which is determined by the + * {@value DefaultParallelExecutionConfigurationStrategy#CONFIG_STRATEGY_PROPERTY_NAME} + * key. + * + * @see #EXECUTOR_SERVICE_PROPERTY_NAME + * @see ConcurrentExecutorServiceType + * @see ParallelExecutionConfigurationStrategy + * @see PrefixedConfigurationParameters + */ + public static HierarchicalTestExecutorService create(ConfigurationParameters configurationParameters) { + var executorServiceType = configurationParameters.get(EXECUTOR_SERVICE_PROPERTY_NAME, + it -> ConcurrentExecutorServiceType.valueOf(it.toUpperCase(Locale.ROOT))) // + .orElse(ConcurrentExecutorServiceType.FORK_JOIN_POOL); + var configuration = DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters); + return create(executorServiceType, configuration); + } + + /** + * Create a new {@link HierarchicalTestExecutorService} based on the + * supplied {@link ConfigurationParameters}. + * + * @see ConcurrentExecutorServiceType + * @see ParallelExecutionConfigurationStrategy + */ + public static HierarchicalTestExecutorService create(ConcurrentExecutorServiceType type, + ParallelExecutionConfiguration configuration) { + return switch (type) { + case FORK_JOIN_POOL -> new ForkJoinPoolHierarchicalTestExecutorService(configuration); + case WORKER_THREAD_POOL -> new WorkerThreadPoolHierarchicalTestExecutorService(configuration); + }; + } + + private ConcurrentHierarchicalTestExecutorServiceFactory() { + } + + /** + * Type of {@link HierarchicalTestExecutorService} that supports concurrent + * execution. + */ + public enum ConcurrentExecutorServiceType { + + /** + * Indicates that {@link ForkJoinPoolHierarchicalTestExecutorService} + * should be used. + */ + FORK_JOIN_POOL, + + /** + * Indicates that {@link WorkerThreadPoolHierarchicalTestExecutorService} + * should be used. + */ + WORKER_THREAD_POOL + + } + +} diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 57ae9b9aa231..a45cda9253f6 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -53,7 +53,6 @@ import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; -import org.junit.platform.engine.ConfigurationParameters; /** * @since 6.1 @@ -68,10 +67,6 @@ public class WorkerThreadPoolHierarchicalTestExecutorService implements Hierarch private final int parallelism; private final WorkerLeaseManager workerLeaseManager; - public WorkerThreadPoolHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { - this(DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters)); - } - public WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index ddeaaf294906..2f2be2aece27 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.parallel.ResourceAccessMode.READ_WRITE; import static org.junit.jupiter.engine.Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE; +import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME; @@ -74,12 +75,15 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.engine.DiscoverySelector; import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.reporting.ReportEntry; import org.junit.platform.engine.support.descriptor.MethodSource; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Event; @@ -89,7 +93,9 @@ * @since 1.3 */ @SuppressWarnings({ "JUnitMalformedDeclaration", "NewClassNamingConvention" }) -class ParallelExecutionIntegrationTests { +@ParameterizedClass +@EnumSource(ConcurrentExecutorServiceType.class) +record ParallelExecutionIntegrationTests(ConcurrentExecutorServiceType executorServiceType) { @Test void successfulParallelTest(TestReporter reporter) { @@ -579,11 +585,12 @@ private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map< return executeWithFixedParallelism(parallelism, configParams, selectClasses(testClasses)); } - private static EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, + private EngineExecutionResults executeWithFixedParallelism(int parallelism, Map configParams, List selectors) { return EngineTestKit.engine("junit-jupiter") // .selectors(selectors) // .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, String.valueOf(true)) // + .configurationParameter(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name()) // .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") // .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, String.valueOf(parallelism)) // .configurationParameters(configParams) // diff --git a/platform-tooling-support-tests/src/test/resources/junit-platform.properties b/platform-tooling-support-tests/src/test/resources/junit-platform.properties index d24bbed7d3d9..fde4f9aa54a2 100644 --- a/platform-tooling-support-tests/src/test/resources/junit-platform.properties +++ b/platform-tooling-support-tests/src/test/resources/junit-platform.properties @@ -1,5 +1,6 @@ junit.jupiter.execution.parallel.enabled=true junit.jupiter.execution.parallel.mode.default=concurrent +junit.jupiter.execution.parallel.config.executor-service=worker_thread_pool junit.jupiter.execution.parallel.config.strategy=dynamic junit.jupiter.execution.parallel.config.dynamic.factor=0.25 junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor=1 From de88e0811e1eec6aaa74aab736add5b3b45e7994 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 13:10:36 +0100 Subject: [PATCH 086/120] Disable logging --- platform-tests/src/test/resources/log4j2-test.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-tests/src/test/resources/log4j2-test.xml b/platform-tests/src/test/resources/log4j2-test.xml index 4edabb15bb2f..9e69004caa27 100644 --- a/platform-tests/src/test/resources/log4j2-test.xml +++ b/platform-tests/src/test/resources/log4j2-test.xml @@ -21,7 +21,7 @@ - + From 9bfc272c0fe7b434d9aa8d2334a432c78d7b72f9 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 13:11:42 +0100 Subject: [PATCH 087/120] Use regular `@Test` methods --- ...eadPoolHierarchicalTestExecutorServiceTests.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 2ae4c353b6a0..4439ff89b495 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -40,7 +40,6 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; -import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; @@ -356,7 +355,7 @@ void prioritizesTestsOverContainers() throws Exception { assertThat(child2.startTime).isBeforeOrEqualTo(child1.startTime); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void limitsWorkerThreadsToMaxPoolSize() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(3, 3)); @@ -447,7 +446,7 @@ public void release() { .containsOnly(child2.executionThread); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void executesChildrenInOrder() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); @@ -474,7 +473,7 @@ void executesChildrenInOrder() throws Exception { .isSorted(); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void workIsStolenInReverseOrder() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); @@ -525,7 +524,7 @@ void workIsStolenInReverseOrder() throws Exception { .isSorted(); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void stealsDynamicChildren() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); @@ -555,7 +554,7 @@ void stealsDynamicChildren() throws Exception { assertThat(child2.executionThread).isEqualTo(root.executionThread).isNotEqualTo(child1.executionThread); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void stealsNestedDynamicChildren() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); @@ -610,7 +609,7 @@ void stealsNestedDynamicChildren() throws Exception { assertThat(child2.executionThread).isEqualTo(leaf2a.executionThread).isEqualTo(leaf2b.executionThread); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void stealsSiblingDynamicChildrenOnly() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 3)); From 87c8bd9f621de952607ca623c20eee60acd72052 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 13:46:25 +0100 Subject: [PATCH 088/120] Document new public types --- .../hierarchical/BlockingAwareFuture.java | 3 ++ .../hierarchical/DelegatingFuture.java | 3 ++ ...adPoolHierarchicalTestExecutorService.java | 51 +++++++++++++++++++ 3 files changed, 57 insertions(+) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java index 9ab49765fb8a..bd58ae926af8 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/BlockingAwareFuture.java @@ -20,6 +20,9 @@ import org.jspecify.annotations.Nullable; +/** + * @since 6.1 + */ abstract class BlockingAwareFuture extends DelegatingFuture { BlockingAwareFuture(Future delegate) { diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java index 30d17c7cdfbe..db74f8ed78b3 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/DelegatingFuture.java @@ -17,6 +17,9 @@ import org.jspecify.annotations.Nullable; +/** + * @since 6.1 + */ class DelegatingFuture implements Future { protected final Future delegate; diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index a45cda9253f6..2700d7655ea8 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -53,13 +53,37 @@ import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; /** + * An {@linkplain HierarchicalTestExecutorService executor service} based on a + * regular thread pool that executes {@linkplain TestTask test tasks} with the + * configured parallelism. + * * @since 6.1 + * @see ConcurrentHierarchicalTestExecutorServiceFactory + * @see ConcurrentExecutorServiceType#WORKER_THREAD_POOL + * @see DefaultParallelExecutionConfigurationStrategy */ @API(status = EXPERIMENTAL, since = "6.1") public class WorkerThreadPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { + /* + This implementation is based on a regular thread pool and a work queue shared among all worker threads. Whenever + a task is submitted to the work queue to be executed concurrently, an attempt is made to acquire a worker lease. + The number of total worker leases is initialized with the desired parallelism. This ensures that at most + `parallelism` tests are running concurrently, regardless whether the user code performs any blocking operations. + If a worker lease was acquired, a worker thread is started. Each worker thread polls the shared work queue for + tasks to run. Since the tasks represent hierarchically structured tests, container tasks will call + `submit(TestTask)` or `invokeAll(List)` for their children, recursively. Child tasks with execution + mode `CONCURRENT` are submitted to the shared queue be prior to executing those with execution mode + `SAME_THREAD` directly. Each worker thread attempts to "steal" queue entries for its children and execute them + itself prior to waiting for its children to finish. In case it does need to block, it temporarily gives up its + worker lease and starts another worker thread to compensate for the reduced `parallelism`. If the max pool size + does not permit starting another thread, that is ignored in case there are still other active worker threads. + The same happens in case a resource lock needs to be acquired. + */ + private static final Logger LOGGER = LoggerFactory.getLogger(WorkerThreadPoolHierarchicalTestExecutorService.class); private final WorkQueue workQueue = new WorkQueue(); @@ -67,6 +91,27 @@ public class WorkerThreadPoolHierarchicalTestExecutorService implements Hierarch private final int parallelism; private final WorkerLeaseManager workerLeaseManager; + /** + * Create a new {@code WorkerThreadPoolHierarchicalTestExecutorService} + * based on the supplied {@link ParallelExecutionConfiguration}. + * + *

The following attributes of the supplied configuration are applied to + * the thread pool used by this executor service: + * + *

    + *
  • {@link ParallelExecutionConfiguration#getParallelism()}
  • + *
  • {@link ParallelExecutionConfiguration#getCorePoolSize()}
  • + *
  • {@link ParallelExecutionConfiguration#getMaxPoolSize()}
  • + *
  • {@link ParallelExecutionConfiguration#getKeepAliveSeconds()}
  • + *
+ * + *

The remaining attributes, such as + * {@link ParallelExecutionConfiguration#getMinimumRunnable()} and + * {@link ParallelExecutionConfiguration#getSaturatePredicate()}, are + * ignored. + * + * @see ConcurrentHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters) + */ public WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } @@ -108,6 +153,12 @@ public void close() { return new WorkStealingFuture(entry); } + /** + * {@inheritDoc} + * + * @implNote This method must be called from within a worker thread that + * belongs to this executor. + */ @Override public void invokeAll(List testTasks) { LOGGER.trace(() -> "invokeAll: " + testTasks); From 78b58ec0fdbef3f23e3375bf08c66b88b05ab43a Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 13:49:00 +0100 Subject: [PATCH 089/120] Polishing --- .../WorkerThreadPoolHierarchicalTestExecutorService.java | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 2700d7655ea8..3d3fb97f4274 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -53,6 +53,7 @@ import org.junit.platform.commons.util.ClassLoaderUtils; import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; +import org.junit.platform.engine.ConfigurationParameters; import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; /** @@ -66,7 +67,7 @@ * @see DefaultParallelExecutionConfigurationStrategy */ @API(status = EXPERIMENTAL, since = "6.1") -public class WorkerThreadPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { +public final class WorkerThreadPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { /* This implementation is based on a regular thread pool and a work queue shared among all worker threads. Whenever @@ -112,7 +113,7 @@ public class WorkerThreadPoolHierarchicalTestExecutorService implements Hierarch * * @see ConcurrentHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters) */ - public WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { + WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } @@ -245,11 +246,11 @@ public Thread newThread(Runnable runnable) { private class WorkerThread extends Thread { + private final Deque stateStack = new ArrayDeque<>(); + @Nullable WorkerLease workerLease; - private final Deque stateStack = new ArrayDeque<>(); - WorkerThread(Runnable runnable, String name) { super(runnable, name); } From 2cf5caf0907470672243f31bbfb8227154ee4213 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 14:07:33 +0100 Subject: [PATCH 090/120] Deprecate `ForkJoinPoolHierarchicalTestExecutorService` constructors --- ...ierarchicalTestExecutorServiceFactory.java | 25 +++++++++++++----- ...inPoolHierarchicalTestExecutorService.java | 13 +++++++++- ...lHierarchicalTestExecutorServiceTests.java | 14 +++++----- .../HierarchicalTestExecutorTests.java | 26 +++++++++++++------ 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java index 3c2d288fea59..03b77b561f00 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java @@ -11,12 +11,14 @@ package org.junit.platform.engine.support.hierarchical; import static org.apiguardian.api.API.Status.EXPERIMENTAL; +import static org.apiguardian.api.API.Status.MAINTAINED; import java.util.Locale; import org.apiguardian.api.API; import org.junit.platform.engine.ConfigurationParameters; import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; +import org.junit.platform.engine.support.hierarchical.ForkJoinPoolHierarchicalTestExecutorService.TaskEventListener; /** * Factory for {@link HierarchicalTestExecutorService} instances that support @@ -27,7 +29,7 @@ * @see ForkJoinPoolHierarchicalTestExecutorService * @see WorkerThreadPoolHierarchicalTestExecutorService */ -@API(status = EXPERIMENTAL, since = "6.1") +@API(status = MAINTAINED, since = "6.1") public class ConcurrentHierarchicalTestExecutorServiceFactory { /** @@ -62,24 +64,28 @@ public class ConcurrentHierarchicalTestExecutorServiceFactory { * @see PrefixedConfigurationParameters */ public static HierarchicalTestExecutorService create(ConfigurationParameters configurationParameters) { - var executorServiceType = configurationParameters.get(EXECUTOR_SERVICE_PROPERTY_NAME, - it -> ConcurrentExecutorServiceType.valueOf(it.toUpperCase(Locale.ROOT))) // + var type = configurationParameters.get(EXECUTOR_SERVICE_PROPERTY_NAME, ConcurrentExecutorServiceType::parse) // .orElse(ConcurrentExecutorServiceType.FORK_JOIN_POOL); var configuration = DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters); - return create(executorServiceType, configuration); + return create(type, configuration); } /** * Create a new {@link HierarchicalTestExecutorService} based on the * supplied {@link ConfigurationParameters}. * + *

The {@value #EXECUTOR_SERVICE_PROPERTY_NAME} key is ignored in favor + * of the supplied {@link ConcurrentExecutorServiceType} parameter when + * invoking this method. + * * @see ConcurrentExecutorServiceType * @see ParallelExecutionConfigurationStrategy */ public static HierarchicalTestExecutorService create(ConcurrentExecutorServiceType type, ParallelExecutionConfiguration configuration) { return switch (type) { - case FORK_JOIN_POOL -> new ForkJoinPoolHierarchicalTestExecutorService(configuration); + case FORK_JOIN_POOL -> new ForkJoinPoolHierarchicalTestExecutorService(configuration, + TaskEventListener.NOOP); case WORKER_THREAD_POOL -> new WorkerThreadPoolHierarchicalTestExecutorService(configuration); }; } @@ -90,7 +96,10 @@ private ConcurrentHierarchicalTestExecutorServiceFactory() { /** * Type of {@link HierarchicalTestExecutorService} that supports concurrent * execution. + * + * @since 6.1 */ + @API(status = MAINTAINED, since = "6.1") public enum ConcurrentExecutorServiceType { /** @@ -103,8 +112,12 @@ public enum ConcurrentExecutorServiceType { * Indicates that {@link WorkerThreadPoolHierarchicalTestExecutorService} * should be used. */ - WORKER_THREAD_POOL + @API(status = EXPERIMENTAL, since = "6.1") + WORKER_THREAD_POOL; + private static ConcurrentExecutorServiceType parse(String value) { + return valueOf(value.toUpperCase(Locale.ROOT)); + } } } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java index 5f58bc962951..15690f95ee83 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java @@ -11,6 +11,7 @@ package org.junit.platform.engine.support.hierarchical; import static java.util.concurrent.CompletableFuture.completedFuture; +import static org.apiguardian.api.API.Status.DEPRECATED; import static org.apiguardian.api.API.Status.STABLE; import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; @@ -33,6 +34,7 @@ import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.util.ExceptionUtils; import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; /** * A {@link ForkJoinPool}-based @@ -58,7 +60,12 @@ public class ForkJoinPoolHierarchicalTestExecutorService implements Hierarchical * the supplied {@link ConfigurationParameters}. * * @see DefaultParallelExecutionConfigurationStrategy + * @deprecated Please use + * {@link ConcurrentHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters)} + * instead. */ + @API(status = DEPRECATED, since = "6.1") + @Deprecated(since = "6.1") public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters configurationParameters) { this(DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters)); } @@ -68,8 +75,12 @@ public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters confi * the supplied {@link ParallelExecutionConfiguration}. * * @since 1.7 + * @deprecated Please use + * {@link ConcurrentHierarchicalTestExecutorServiceFactory#create(ConcurrentExecutorServiceType, ParallelExecutionConfiguration)} + * instead. */ - @API(status = STABLE, since = "1.10") + @API(status = DEPRECATED, since = "6.1") + @Deprecated(since = "6.1") public ForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, TaskEventListener.NOOP); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java index 5845bd164892..b04e34a64586 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorServiceTests.java @@ -33,6 +33,8 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicReference; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -58,7 +60,7 @@ void exceptionsFromInvalidConfigurationAreNotSwallowed() { var configuration = new DefaultParallelExecutionConfiguration(2, 1, 1, 1, 0, __ -> true); JUnitException exception = assertThrows(JUnitException.class, () -> { - try (var pool = new ForkJoinPoolHierarchicalTestExecutorService(configuration)) { + try (var pool = new ForkJoinPoolHierarchicalTestExecutorService(configuration, TaskEventListener.NOOP)) { assertNotNull(pool, "we won't get here"); } }); @@ -168,7 +170,7 @@ static List compatibleLockCombinations() { ); } - @SuppressWarnings("DataFlowIssue") + @SuppressWarnings("NullAway") @ParameterizedTest @MethodSource("compatibleLockCombinations") void canWorkStealTaskWithCompatibleLocks(Set initialResources, @@ -293,8 +295,8 @@ private static void await(CountDownLatch latch, String message) { } private void withForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, - TaskEventListener taskEventListener, ThrowingConsumer action) - throws Throwable { + TaskEventListener taskEventListener, + ThrowingConsumer<@NonNull ForkJoinPoolHierarchicalTestExecutorService> action) throws Throwable { try (var service = new ForkJoinPoolHierarchicalTestExecutorService(configuration, taskEventListener)) { action.accept(service); @@ -304,14 +306,14 @@ private void withForkJoinPoolHierarchicalTestExecutorService(ParallelExecutionCo } } + @NullMarked static final class DummyTestTask implements TestTask { private final String identifier; private final ResourceLock resourceLock; private final Executable action; - @Nullable - private volatile String threadName; + private volatile @Nullable String threadName; private final CountDownLatch started = new CountDownLatch(1); private final CompletableFuture completion = new CompletableFuture<>(); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java index 2602b22e5d5f..e18aa4952681 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java @@ -34,10 +34,14 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.function.ThrowingConsumer; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.engine.CancellationToken; import org.junit.platform.engine.EngineExecutionListener; import org.junit.platform.engine.ExecutionRequest; @@ -45,6 +49,7 @@ import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; import org.junit.platform.engine.support.hierarchical.Node.DynamicTestExecutor; import org.junit.platform.launcher.core.ConfigurationParametersFactoryForTests; @@ -73,14 +78,14 @@ class HierarchicalTestExecutorTests { CancellationToken cancellationToken = CancellationToken.create(); MyEngineExecutionContext rootContext = new MyEngineExecutionContext(); - HierarchicalTestExecutor executor; + HierarchicalTestExecutor<@NonNull MyEngineExecutionContext> executor; @BeforeEach void init() { executor = createExecutor(new SameThreadHierarchicalTestExecutorService()); } - private HierarchicalTestExecutor createExecutor( + private HierarchicalTestExecutor<@NonNull MyEngineExecutionContext> createExecutor( HierarchicalTestExecutorService executorService) { ExecutionRequest request = mock(); when(request.getRootTestDescriptor()).thenReturn(root); @@ -555,9 +560,10 @@ void executesDynamicTestDescriptorsWithCustomListener() { inOrder.verify(anotherListener).executionFinished(dynamicTestDescriptor, successful()); } - @Test + @ParameterizedTest + @EnumSource(ConcurrentExecutorServiceType.class) @MockitoSettings(strictness = LENIENT) - void canAbortExecutionOfDynamicChild() throws Exception { + void canAbortExecutionOfDynamicChild(ConcurrentExecutorServiceType executorServiceType) throws Exception { var leafUniqueId = UniqueId.root("leaf", "child leaf"); var child = spy(new MyLeaf(leafUniqueId)); @@ -587,10 +593,11 @@ void canAbortExecutionOfDynamicChild() throws Exception { }); var parameters = ConfigurationParametersFactoryForTests.create(Map.of(// + ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name(), // DefaultParallelExecutionConfigurationStrategy.CONFIG_STRATEGY_PROPERTY_NAME, "fixed", // DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, "2")); - try (var executorService = new ForkJoinPoolHierarchicalTestExecutorService(parameters)) { + try (var executorService = ConcurrentHierarchicalTestExecutorServiceFactory.create(parameters)) { createExecutor(executorService).execute().get(); } @@ -602,7 +609,7 @@ private Answer execute(TestDescriptor dynamicChild) { return useDynamicTestExecutor(executor -> executor.execute(dynamicChild)); } - private Answer useDynamicTestExecutor(ThrowingConsumer action) { + private Answer useDynamicTestExecutor(ThrowingConsumer<@NonNull DynamicTestExecutor> action) { return invocation -> { DynamicTestExecutor dynamicTestExecutor = invocation.getArgument(1); action.accept(dynamicTestExecutor); @@ -658,7 +665,7 @@ void exceptionInAfterDoesNotHideEarlierException() throws Exception { inOrder.verify(listener).executionFinished(eq(child), childExecutionResult.capture()); assertThat(childExecutionResult.getValue().getStatus()).isEqualTo(FAILED); - assertThat(childExecutionResult.getValue().getThrowable().get()).isSameAs( + assertThat(childExecutionResult.getValue().getThrowable().orElseThrow()).isSameAs( exceptionInExecute).hasSuppressedException(exceptionInAfter); } @@ -707,7 +714,7 @@ void exceptionInAfterIsReportedInsteadOfEarlierTestAbortedException() throws Exc inOrder.verify(listener).executionFinished(eq(child), childExecutionResult.capture()); assertThat(childExecutionResult.getValue().getStatus()).isEqualTo(FAILED); - assertThat(childExecutionResult.getValue().getThrowable().get()).isSameAs( + assertThat(childExecutionResult.getValue().getThrowable().orElseThrow()).isSameAs( exceptionInAfter).hasSuppressedException(exceptionInExecute); } @@ -776,6 +783,7 @@ void reportsNodeAsSkippedWhenCancelledDuringBefore() throws Exception { private static class MyEngineExecutionContext implements EngineExecutionContext { } + @NullMarked private static class MyContainer extends AbstractTestDescriptor implements Node { MyContainer(UniqueId uniqueId) { @@ -788,6 +796,7 @@ public Type getType() { } } + @NullMarked private static class MyLeaf extends AbstractTestDescriptor implements Node { MyLeaf(UniqueId uniqueId) { @@ -806,6 +815,7 @@ public Type getType() { } } + @NullMarked private static class MyContainerAndTestTestCase extends AbstractTestDescriptor implements Node { From 94ad1932fd03578246292d32df35c4fb51ecfa9e Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 14:51:05 +0100 Subject: [PATCH 091/120] Report info-level discovery message when `ForkJoinPool` is used --- .../org/junit/jupiter/engine/Constants.java | 20 ++- .../jupiter/engine/JupiterTestEngine.java | 2 +- .../config/DefaultJupiterConfiguration.java | 13 ++ .../engine/config/JupiterConfiguration.java | 4 + ...onfigurationParametersFactoryForTests.java | 11 +- .../DefaultJupiterConfigurationTests.java | 117 ++++++++++++------ .../HierarchicalTestExecutorTests.java | 4 +- 7 files changed, 119 insertions(+), 52 deletions(-) diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java index 87241cdec90c..7db3767ce28c 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java @@ -239,11 +239,10 @@ public final class Constants { @API(status = STABLE, since = "5.10") public static final String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; - static final String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; - /** * Property name used to determine the desired - * {@link ConcurrentExecutorServiceType ConcurrentExecutorServiceType}. + * {@link ConcurrentExecutorServiceType ConcurrentExecutorServiceType}: + * {@value} * *

Value must be * {@link ConcurrentExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or @@ -254,8 +253,7 @@ public final class Constants { * @see ConcurrentHierarchicalTestExecutorServiceFactory */ @API(status = EXPERIMENTAL, since = "6.1") - public static final String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX - + ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME; + public static final String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; /** * Property name used to select the @@ -267,7 +265,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_STRATEGY_PROPERTY_NAME; /** @@ -279,7 +277,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; /** @@ -293,7 +291,7 @@ public final class Constants { * @since 5.10 */ @API(status = MAINTAINED, since = "5.13.3") - public static final String PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_FIXED_MAX_POOL_SIZE_PROPERTY_NAME; /** @@ -309,7 +307,7 @@ public final class Constants { * @since 5.10 */ @API(status = MAINTAINED, since = "5.13.3") - public static final String PARALLEL_CONFIG_FIXED_SATURATE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_FIXED_SATURATE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_FIXED_SATURATE_PROPERTY_NAME; /** @@ -322,7 +320,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_DYNAMIC_FACTOR_PROPERTY_NAME; /** @@ -333,7 +331,7 @@ public final class Constants { * @since 5.3 */ @API(status = STABLE, since = "5.10") - public static final String PARALLEL_CONFIG_CUSTOM_CLASS_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + public static final String PARALLEL_CONFIG_CUSTOM_CLASS_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_PREFIX + CONFIG_CUSTOM_CLASS_PROPERTY_NAME; /** diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index b45b8df74523..91bdc287c2ac 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -80,7 +80,7 @@ protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { return ConcurrentHierarchicalTestExecutorServiceFactory.create(new PrefixedConfigurationParameters( - request.getConfigurationParameters(), Constants.PARALLEL_CONFIG_PREFIX)); + request.getConfigurationParameters(), JupiterConfiguration.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); } diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 50ea9550fe8f..71c0c3e1a73c 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -16,6 +16,8 @@ import static org.junit.jupiter.api.io.TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME; import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import static org.junit.jupiter.engine.config.FilteringConfigurationParameterConverter.exclude; +import static org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType.FORK_JOIN_POOL; +import static org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType.WORKER_THREAD_POOL; import java.util.List; import java.util.Optional; @@ -100,6 +102,17 @@ private void validateConfigurationParameters(DiscoveryIssueReporter issueReporte Please remove it from your configuration.""".formatted(key)); issueReporter.reportIssue(warning); })); + if (isParallelExecutionEnabled() + && configurationParameters.get(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME).isEmpty()) { + var info = DiscoveryIssue.create(Severity.INFO, + "Parallel test execution is enabled but the default ForkJoinPool-based executor service will be used. " + + "Please give the new implementation based on a regular thread pool a try by setting the '" + + PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME + "' configuration parameter to '" + + WORKER_THREAD_POOL + "' and report any issues to the JUnit team. " + + "Alternatively, set the configuration parameter to '" + FORK_JOIN_POOL + + "' to hide this message."); + issueReporter.reportIssue(info); + } } @Override diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 1d7ec56e40c2..26abafa10d50 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -31,6 +31,7 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory; /** * @since 5.4 @@ -42,6 +43,9 @@ public interface JupiterConfiguration { String EXTENSIONS_AUTODETECTION_EXCLUDE_PROPERTY_NAME = "junit.jupiter.extensions.autodetection.exclude"; String DEACTIVATE_CONDITIONS_PATTERN_PROPERTY_NAME = "junit.jupiter.conditions.deactivate"; String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; + String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; + String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX + + ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME; String CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.store.close.autocloseable.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; diff --git a/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java b/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java index 45d9af72b788..be5f4dad130e 100644 --- a/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java +++ b/junit-platform-launcher/src/testFixtures/java/org/junit/platform/launcher/core/ConfigurationParametersFactoryForTests.java @@ -10,6 +10,9 @@ package org.junit.platform.launcher.core; +import static java.util.stream.Collectors.toMap; +import static org.junit.platform.commons.util.StringUtils.nullSafeToString; + import java.util.Map; import org.junit.platform.engine.ConfigurationParameters; @@ -19,10 +22,14 @@ public class ConfigurationParametersFactoryForTests { private ConfigurationParametersFactoryForTests() { } - public static ConfigurationParameters create(Map configParams) { + public static ConfigurationParameters create(Map configParams) { return LauncherConfigurationParameters.builder() // - .explicitParameters(configParams) // + .explicitParameters(withStringValues(configParams)) // .enableImplicitProviders(false) // .build(); } + + private static Map withStringValues(Map configParams) { + return configParams.entrySet().stream().collect(toMap(Map.Entry::getKey, e -> nullSafeToString(e.getValue()))); + } } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java index 37f0dcc046f3..6bec052d8352 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java @@ -20,12 +20,16 @@ import static org.junit.platform.commons.test.PreconditionAssertions.assertPreconditionViolationNotNullFor; import static org.junit.platform.launcher.core.OutputDirectoryCreators.dummyOutputDirectoryCreator; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Supplier; +import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.MethodOrderer; @@ -37,7 +41,14 @@ import org.junit.jupiter.api.io.TempDirFactory; import org.junit.jupiter.engine.Constants; import org.junit.jupiter.engine.descriptor.CustomDisplayNameGenerator; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.DiscoveryIssue; +import org.junit.platform.engine.DiscoveryIssue.Severity; +import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.launcher.core.ConfigurationParametersFactoryForTests; class DefaultJupiterConfigurationTests { @@ -52,16 +63,16 @@ void getDefaultTestInstanceLifecyclePreconditions() { @Test void getDefaultTestInstanceLifecycleWithNoConfigParamSet() { - JupiterConfiguration configuration = new DefaultJupiterConfiguration(mock(), dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); Lifecycle lifecycle = configuration.getDefaultTestInstanceLifecycle(); assertThat(lifecycle).isEqualTo(PER_METHOD); } @Test void getDefaultTempDirCleanupModeWithNoConfigParamSet() { - JupiterConfiguration configuration = new DefaultJupiterConfiguration(mock(), dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); CleanupMode cleanupMode = configuration.getDefaultTempDirCleanupMode(); assertThat(cleanupMode).isEqualTo(ALWAYS); } @@ -88,9 +99,9 @@ void getDefaultTestInstanceLifecycleWithConfigParamSet() { @Test void shouldGetDefaultDisplayNameGeneratorWithConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.of(CustomDisplayNameGenerator.class.getName())); + var parameters = configurationParameters( + Map.of(Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME, CustomDisplayNameGenerator.class.getName())); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), mock()); @@ -101,11 +112,8 @@ void shouldGetDefaultDisplayNameGeneratorWithConfigParamSet() { @Test void shouldGetStandardAsDefaultDisplayNameGeneratorWithoutConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_DISPLAY_NAME_GENERATOR_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.empty()); - JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); DisplayNameGenerator defaultDisplayNameGenerator = configuration.getDefaultDisplayNameGenerator(); @@ -114,11 +122,8 @@ void shouldGetStandardAsDefaultDisplayNameGeneratorWithoutConfigParamSet() { @Test void shouldGetNothingAsDefaultTestMethodOrderWithoutConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.empty()); - JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); final Optional defaultTestMethodOrder = configuration.getDefaultTestMethodOrderer(); @@ -127,9 +132,8 @@ void shouldGetNothingAsDefaultTestMethodOrderWithoutConfigParamSet() { @Test void shouldGetDefaultTempDirFactorySupplierWithConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.of(CustomFactory.class.getName())); + var parameters = configurationParameters( + Map.of(Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME, CustomFactory.class.getName())); JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), mock()); @@ -138,37 +142,78 @@ void shouldGetDefaultTempDirFactorySupplierWithConfigParamSet() { assertThat(supplier.get()).isInstanceOf(CustomFactory.class); } - private static class CustomFactory implements TempDirFactory { - - @Override - public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) { - throw new UnsupportedOperationException(); - } - } - @Test void shouldGetStandardAsDefaultTempDirFactorySupplierWithoutConfigParamSet() { - ConfigurationParameters parameters = mock(); - String key = Constants.DEFAULT_TEMP_DIR_FACTORY_PROPERTY_NAME; - when(parameters.get(key)).thenReturn(Optional.empty()); - JupiterConfiguration configuration = new DefaultJupiterConfiguration(parameters, dummyOutputDirectoryCreator(), - mock()); + JupiterConfiguration configuration = new DefaultJupiterConfiguration(configurationParameters(Map.of()), + dummyOutputDirectoryCreator(), mock()); Supplier supplier = configuration.getDefaultTempDirFactorySupplier(); assertThat(supplier.get()).isSameAs(TempDirFactory.Standard.INSTANCE); } + @Test + void doesNotReportAnyIssuesIfConfigurationParametersAreEmpty() { + List issues = new ArrayList<>(); + + new DefaultJupiterConfiguration(configurationParameters(Map.of()), dummyOutputDirectoryCreator(), + DiscoveryIssueReporter.collecting(issues)).getDefaultTestInstanceLifecycle(); + + assertThat(issues).isEmpty(); + } + + @ParameterizedTest + @EnumSource(ConcurrentExecutorServiceType.class) + void doesNotReportAnyIssuesIfParallelExecutionIsEnabledAndConfigurationParameterIsSet( + ConcurrentExecutorServiceType executorServiceType) { + var parameters = Map.of(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, true, // + JupiterConfiguration.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType); + List issues = new ArrayList<>(); + + new DefaultJupiterConfiguration(ConfigurationParametersFactoryForTests.create(parameters), + dummyOutputDirectoryCreator(), DiscoveryIssueReporter.collecting(issues)).getDefaultTestInstanceLifecycle(); + + assertThat(issues).isEmpty(); + } + + @Test + void asksUsersToTryWorkerThreadPoolHierarchicalExecutorServiceIfParallelExecutionIsEnabled() { + var parameters = Map.of(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, true); + List issues = new ArrayList<>(); + + new DefaultJupiterConfiguration(configurationParameters(parameters), dummyOutputDirectoryCreator(), + DiscoveryIssueReporter.collecting(issues)).getDefaultTestInstanceLifecycle(); + + assertThat(issues).containsExactly(DiscoveryIssue.create(Severity.INFO, """ + Parallel test execution is enabled but the default ForkJoinPool-based executor service will be used. \ + Please give the new implementation based on a regular thread pool a try by setting the \ + 'junit.jupiter.execution.parallel.config.executor-service' configuration parameter to \ + 'WORKER_THREAD_POOL' and report any issues to the JUnit team. \ + Alternatively, set the configuration parameter to 'FORK_JOIN_POOL' to hide this message.""")); + } + private void assertDefaultConfigParam(@Nullable String configValue, Lifecycle expected) { var lifecycle = getDefaultTestInstanceLifecycleConfigParam(configValue); assertThat(lifecycle).isEqualTo(expected); } private static Lifecycle getDefaultTestInstanceLifecycleConfigParam(@Nullable String configValue) { - ConfigurationParameters configParams = mock(); - when(configParams.get(KEY)).thenReturn(Optional.ofNullable(configValue)); + var configParams = configurationParameters(configValue == null ? Map.of() : Map.of(KEY, configValue)); return new DefaultJupiterConfiguration(configParams, dummyOutputDirectoryCreator(), mock()).getDefaultTestInstanceLifecycle(); } + private static ConfigurationParameters configurationParameters(Map<@NotNull String, ?> parameters) { + return ConfigurationParametersFactoryForTests.create(parameters); + } + + @NullMarked + private static class CustomFactory implements TempDirFactory { + + @Override + public Path createTempDirectory(AnnotatedElementContext elementContext, ExtensionContext extensionContext) { + throw new UnsupportedOperationException(); + } + } + } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java index e18aa4952681..6879bc715973 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java @@ -593,9 +593,9 @@ void canAbortExecutionOfDynamicChild(ConcurrentExecutorServiceType executorServi }); var parameters = ConfigurationParametersFactoryForTests.create(Map.of(// - ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name(), // + ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType, // DefaultParallelExecutionConfigurationStrategy.CONFIG_STRATEGY_PROPERTY_NAME, "fixed", // - DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, "2")); + DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, 2)); try (var executorService = ConcurrentHierarchicalTestExecutorServiceFactory.create(parameters)) { createExecutor(executorService).execute().get(); From b3d17ad79296fb587277cb2eccdcb6de3d77d3cc Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 15:31:10 +0100 Subject: [PATCH 092/120] Document how to use `worker_thread_pool` executor service --- .../asciidoc/user-guide/writing-tests.adoc | 178 +++++++++--------- 1 file changed, 92 insertions(+), 86 deletions(-) diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index b181e75ecf95..f08d814e2e3e 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3429,6 +3429,33 @@ used instead. [[writing-tests-parallel-execution-config]] ==== Configuration +[[writing-tests-parallel-execution-config-executor-service]] +===== Executor Service + +If parallel execution is enabled, a thread pool is used behind the scenes to execute tests +concurrently. You can configure which implementation of `HierarchicalTestExecutorService` +is used be setting the `junit.jupiter.execution.parallel.config.executor-service` +configuration parameter to one of the following options: + +`fork_join_pool` (default):: +Use an executor service that is backed by a `ForkJoinPool` from the JDK. This will cause +tests to be executed in a `ForkJoinWorkerThread` and, in some cases, cause usages of +`ForkJoinPool` in test or production code or calls to blocking JDK APIs to increase the +number of concurrently executing tests. To avoid this limitation, please use +`worker_thread_pool`. + +`worker_thread_pool` (experimental):: +Use an executor service that is backed by a regular thread pool and does not create +additional threads if test or production code uses `ForkJoinPool` or calls a blocking +API in the JDK. + +WARNING: Using `worker_thread_pool` is currently an _experimental_ feature. You're invited +to give it a try and provide feedback to the JUnit team so they can improve and eventually +<> this feature. + +[[writing-tests-parallel-execution-config-strategies]] +===== Strategies + Properties such as the desired parallelism and the maximum pool size can be configured using a `{ParallelExecutionConfigurationStrategy}`. The JUnit Platform provides two implementations out of the box: `dynamic` and `fixed`. Alternatively, you may implement a @@ -3460,13 +3487,12 @@ strategy with a factor of `1`. Consequently, the desired parallelism will be equ number of available processors/cores. .Parallelism alone does not imply maximum number of concurrent threads -NOTE: By default JUnit Jupiter does not guarantee that the number of concurrently -executing tests will not exceed the configured parallelism. For example, when using one -of the synchronization mechanisms described in the next section, the `ForkJoinPool` that -is used behind the scenes may spawn additional threads to ensure execution continues with -sufficient parallelism. -If you require such guarantees, it is possible to limit the maximum number of concurrent -threads by controlling the maximum pool size of the `dynamic`, `fixed` and `custom` +NOTE: By default, JUnit Jupiter does not guarantee that the number of threads used to +execute test will not exceed the configured parallelism. For example, when using one +of the synchronization mechanisms described in the next section, the executor service +implementation may spawn additional threads to ensure execution continues with sufficient +parallelism. If you require such guarantees, it is possible to limit the maximum number of +threads by configuring the maximum pool size of the `dynamic`, `fixed` and `custom` strategies. [[writing-tests-parallel-execution-config-properties]] @@ -3475,86 +3501,66 @@ strategies. The following table lists relevant properties for configuring parallel execution. See <> for details on how to set such properties. -[cols="d,d,a,d"] -|=== -|Property |Description |Supported Values |Default Value - -| ```junit.jupiter.execution.parallel.enabled``` -| Enable parallel test execution -| - * `true` - * `false` -| ```false``` - -| ```junit.jupiter.execution.parallel.mode.default``` -| Default execution mode of nodes in the test tree -| - * `concurrent` - * `same_thread` -| ```same_thread``` - -| ```junit.jupiter.execution.parallel.mode.classes.default``` -| Default execution mode of top-level classes -| - * `concurrent` - * `same_thread` -| ```same_thread``` - -| ```junit.jupiter.execution.parallel.config.strategy``` -| Execution strategy for desired parallelism and maximum pool size -| - * `dynamic` - * `fixed` - * `custom` -| ```dynamic``` - -| ```junit.jupiter.execution.parallel.config.dynamic.factor``` -| Factor to be multiplied by the number of available processors/cores to determine the - desired parallelism for the ```dynamic``` configuration strategy -| a positive decimal number -| ```1.0``` - -| ```junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor``` -| Factor to be multiplied by the number of available processors/cores and the value of +====== General + +`junit.jupiter.execution.parallel.enabled=true|false`:: + Enable/disable parallel test execution (defaults to `false`). + +`junit.jupiter.execution.parallel.mode.default=concurrent|same_thread`:: + Default execution mode of nodes in the test tree (defaults to `same_thread`). + +`junit.jupiter.execution.parallel.mode.classes.default=concurrent|same_thread`:: + Default execution mode of top-level classes (defaults to `same_thread`). + +`junit.jupiter.execution.parallel.config.executor-service=fork_join_pool|worker_thread_pool`:: + Type of `HierarchicalTestExecutorService` to use for parallel execution (defaults to + `fork_join_pool`). + +`junit.jupiter.execution.parallel.config.strategy=dynamic|fixed|custom`:: + Execution strategy for desired parallelism, maximum pool size, etc. (defaults to `dynamic`). + +====== Dynamic strategy + +`junit.jupiter.execution.parallel.config.dynamic.factor=decimal`:: + Factor to be multiplied by the number of available processors/cores to determine the + desired parallelism for the ```dynamic``` configuration strategy. + Must be a positive decimal number (defaults to `1.0`). + +`junit.jupiter.execution.parallel.config.dynamic.max-pool-size-factor=decimal`:: + Factor to be multiplied by the number of available processors/cores and the value of `junit.jupiter.execution.parallel.config.dynamic.factor` to determine the desired - parallelism for the ```dynamic``` configuration strategy -| a positive decimal number, must be greater than or equal to `1.0` -| 256 + the value of `junit.jupiter.execution.parallel.config.dynamic.factor` multiplied - by the number of available processors/cores - -| ```junit.jupiter.execution.parallel.config.dynamic.saturate``` -| Disable saturation of the underlying fork-join pool for the ```dynamic``` configuration -strategy -| -* `true` -* `false` -| ```true``` - -| ```junit.jupiter.execution.parallel.config.fixed.parallelism``` -| Desired parallelism for the ```fixed``` configuration strategy -| a positive integer -| no default value - -| ```junit.jupiter.execution.parallel.config.fixed.max-pool-size``` -| Desired maximum pool size of the underlying fork-join pool for the ```fixed``` - configuration strategy -| a positive integer, must be greater than or equal to `junit.jupiter.execution.parallel.config.fixed.parallelism` -| 256 + the value of `junit.jupiter.execution.parallel.config.fixed.parallelism` - -| ```junit.jupiter.execution.parallel.config.fixed.saturate``` -| Disable saturation of the underlying fork-join pool for the ```fixed``` configuration - strategy -| - * `true` - * `false` -| ```true``` - -| ```junit.jupiter.execution.parallel.config.custom.class``` -| Fully qualified class name of the _ParallelExecutionConfigurationStrategy_ to be - used for the ```custom``` configuration strategy -| for example, _org.example.CustomStrategy_ -| no default value -|=== + parallelism for the ```dynamic``` configuration strategy. + Must be a positive decimal number greater than or equal to `1.0` (defaults to 256 plus + the value of `junit.jupiter.execution.parallel.config.dynamic.factor` multiplied by the + number of available processors/cores) + +`junit.jupiter.execution.parallel.config.dynamic.saturate=true|false`:: + Enable/disable saturation of the underlying `ForkJoinPool` for the ```dynamic``` + configuration strategy (defaults to `true`). Only used if + `junit.jupiter.execution.parallel.config.executor-service` is set to `fork_join_pool`. + +====== Fixed strategy + +`junit.jupiter.execution.parallel.config.fixed.parallelism=integer`:: + Desired parallelism for the ```fixed``` configuration strategy (no default value). Must + be a positive integer. + +`junit.jupiter.execution.parallel.config.fixed.max-pool-size=integer`:: + Desired maximum pool size of the underlying fork-join pool for the ```fixed``` + configuration strategy. Must be a positive integer greater than or equal to + `junit.jupiter.execution.parallel.config.fixed.parallelism` (defaults to 256 plus the + value of `junit.jupiter.execution.parallel.config.fixed.parallelism`). + +`junit.jupiter.execution.parallel.config.fixed.saturate=true|false`:: + Enable/disable saturation of the underlying `ForkJoinPool` for the ```fixed``` + configuration strategy (defaults to `true`). Only used if + `junit.jupiter.execution.parallel.config.executor-service` is set to `fork_join_pool`. + +====== Custom strategy + +`junit.jupiter.execution.parallel.config.custom.class=classname`:: + Fully qualified class name of the `ParallelExecutionConfigurationStrategy` to be used + for the ```custom``` configuration strategy (no default value). [[writing-tests-parallel-execution-synchronization]] ==== Synchronization From 1e645e47633f0f8a9e1e2001f9f7f531209e6f82 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 15:38:44 +0100 Subject: [PATCH 093/120] Add to release notes --- .../release-notes/release-notes-6.1.0-M1.adoc | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc index ee0700a3c59f..f0cabf54b99f 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc @@ -21,13 +21,20 @@ repository on GitHub. [[release-notes-6.1.0-M1-junit-platform-deprecations-and-breaking-changes]] ==== Deprecations and Breaking Changes -* ❓ +* Deprecate constructors for `ForkJoinPoolHierarchicalTestExecutorService` in favor of the + new `ConcurrentHierarchicalTestExecutorServiceFactory` that also supports + `WorkerThreadPoolHierarchicalTestExecutorService`. [[release-notes-6.1.0-M1-junit-platform-new-features-and-improvements]] ==== New Features and Improvements * Support for creating a `ModuleSelector` from a `java.lang.Module` and using its classloader for test discovery. +* New `WorkerThreadPoolHierarchicalTestExecutorService` implementation of parallel test + execution that is backed by a regular thread pool rather than a `ForkJoinPool`. Engine + authors should switch to use `ConcurrentHierarchicalTestExecutorServiceFactory` rather + than instantiating a concrete `HierarchicalTestExecutorService` implementation for + parallel execution directly. [[release-notes-6.1.0-M1-junit-jupiter]] @@ -55,6 +62,13 @@ repository on GitHub. * Enrich `assertInstanceOf` failure using the test subject `Throwable` as cause. It results in the stack trace of the test subject `Throwable` to get reported along with the failure. +* Make implementation of `HierarchicalTestExecutorService` used for parallel test + execution configurable via the new + `junit.jupiter.execution.parallel.config.executor-service` configuration parameter to + in order to add support for `WorkerThreadPoolHierarchicalTestExecutorService`. Please + refer to the + <<../user-guide/index.adoc#writing-tests-parallel-execution-config-executor-service, User Guide>> + for details. [[release-notes-6.1.0-M1-junit-vintage]] === JUnit Vintage From 7a250e00101c442955ab555879a542143ffb5dc2 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 17:02:16 +0100 Subject: [PATCH 094/120] Apply feedback --- .../src/docs/asciidoc/user-guide/writing-tests.adoc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc index f08d814e2e3e..8cd2abc268f6 100644 --- a/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc +++ b/documentation/src/docs/asciidoc/user-guide/writing-tests.adoc @@ -3439,9 +3439,9 @@ configuration parameter to one of the following options: `fork_join_pool` (default):: Use an executor service that is backed by a `ForkJoinPool` from the JDK. This will cause -tests to be executed in a `ForkJoinWorkerThread` and, in some cases, cause usages of -`ForkJoinPool` in test or production code or calls to blocking JDK APIs to increase the -number of concurrently executing tests. To avoid this limitation, please use +tests to be executed in a `ForkJoinWorkerThread`. In some cases, usages of +`ForkJoinPool` in test or production code or calls to blocking JDK APIs may cause the +number of concurrently executing tests to increase. To avoid this situation, please use `worker_thread_pool`. `worker_thread_pool` (experimental):: From a392f003cc2c5abc82a3be0617c3cd0ddf5371dd Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 29 Oct 2025 17:17:09 +0100 Subject: [PATCH 095/120] Use `@NonNull` from JSpecify --- .../engine/config/DefaultJupiterConfigurationTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java index 6bec052d8352..af22d1d29ab9 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java @@ -28,7 +28,7 @@ import java.util.Optional; import java.util.function.Supplier; -import org.jetbrains.annotations.NotNull; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.DisplayNameGenerator; @@ -203,7 +203,7 @@ private static Lifecycle getDefaultTestInstanceLifecycleConfigParam(@Nullable St mock()).getDefaultTestInstanceLifecycle(); } - private static ConfigurationParameters configurationParameters(Map<@NotNull String, ?> parameters) { + private static ConfigurationParameters configurationParameters(Map<@NonNull String, ?> parameters) { return ConfigurationParametersFactoryForTests.create(parameters); } From 979a1beedcb8ddb6100bbc914bbef06f0476a8ea Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 30 Oct 2025 09:50:44 +0100 Subject: [PATCH 096/120] Run more tests against both implementations --- ...gistrationViaParametersAndFieldsTests.java | 13 ++++++++---- .../engine/extension/OrderedMethodTests.java | 21 +++++++++++++------ .../engine/extension/RepeatedTestTests.java | 16 +++++++++----- .../hierarchical/ForkJoinDeadLockTests.java | 11 ++++++++-- 4 files changed, 44 insertions(+), 17 deletions(-) diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java index 7be354083dfc..f821f69209b3 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java @@ -70,14 +70,16 @@ import org.junit.jupiter.api.fixtures.TrackLogRecords; import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; -import org.junit.jupiter.engine.config.JupiterConfiguration; +import org.junit.jupiter.engine.Constants; import org.junit.jupiter.engine.execution.injection.sample.LongParameterResolver; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.commons.PreconditionViolationException; import org.junit.platform.commons.logging.LogRecordListener; import org.junit.platform.commons.support.ModifierSupport; import org.junit.platform.commons.util.ExceptionUtils; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.testkit.engine.EngineExecutionResults; /** @@ -205,11 +207,14 @@ void registersProgrammaticTestInstancePostProcessors() { assertOneTestSucceeded(ProgrammaticTestInstancePostProcessorTestCase.class); } - @Test - void createsExtensionPerInstance() { + @ParameterizedTest + @EnumSource(ConcurrentExecutorServiceType.class) + void createsExtensionPerInstance(ConcurrentExecutorServiceType executorServiceType) { var results = executeTests(request() // .selectors(selectClass(InitializationPerInstanceTestCase.class)) // - .configurationParameter(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // + .configurationParameter(Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // + .configurationParameter(Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, + executorServiceType.name()) // ); assertTestsSucceeded(results, 100); } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java index 1a353645e773..3c052cf330b6 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Order.DEFAULT; import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE; import static org.junit.jupiter.engine.Constants.DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME; +import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass; import static org.junit.platform.launcher.LauncherConstants.CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME; @@ -34,6 +35,7 @@ import java.util.logging.LogRecord; import java.util.regex.Pattern; +import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -57,7 +59,9 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.jupiter.engine.JupiterTestEngine; +import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.junit.platform.commons.logging.LogRecordListener; import org.junit.platform.commons.util.ClassUtils; @@ -65,6 +69,7 @@ import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.support.descriptor.ClassSource; import org.junit.platform.engine.support.descriptor.MethodSource; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.testkit.engine.EngineDiscoveryResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Events; @@ -76,7 +81,9 @@ * * @since 5.4 */ -class OrderedMethodTests { +@ParameterizedClass +@EnumSource(ConcurrentExecutorServiceType.class) +record OrderedMethodTests(ConcurrentExecutorServiceType executorServiceType) { private static final Set callSequence = Collections.synchronizedSet(new LinkedHashSet<>()); private static final Set threadNames = Collections.synchronizedSet(new LinkedHashSet<>()); @@ -361,12 +368,13 @@ private Events executeTestsInParallel(Class testClass, @Nullable Class testClass, - @Nullable Class defaultOrderer, Severity criticalSeverity) { + private EngineTestKit.Builder testKit(Class testClass, @Nullable Class defaultOrderer, + Severity criticalSeverity) { var testKit = EngineTestKit.engine("junit-jupiter") // .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // .configurationParameter(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent") // + .configurationParameter(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name()) // .configurationParameter(CRITICAL_DISCOVERY_ISSUE_SEVERITY_PROPERTY_NAME, criticalSeverity.name()); if (defaultOrderer != null) { testKit.configurationParameter(DEFAULT_TEST_METHOD_ORDER_PROPERTY_NAME, defaultOrderer.getName()); @@ -767,7 +775,8 @@ public void orderMethods(MethodOrdererContext context) { @SuppressWarnings("unchecked") static T createMethodDescriptorImpersonator(MethodDescriptor method) { - MethodDescriptor stub = new MethodDescriptor() { + @NullMarked + class Stub implements MethodDescriptor { @Override public Method getMethod() { throw new UnsupportedOperationException(); @@ -803,8 +812,8 @@ public boolean equals(Object obj) { public int hashCode() { return method.hashCode(); } - }; - return (T) stub; + } + return (T) new Stub(); } } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java index f64a970dd9f7..c78bede00d74 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.engine.Constants.DEFAULT_PARALLEL_EXECUTION_MODE; +import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME; import static org.junit.jupiter.engine.Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME; @@ -46,8 +47,11 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.engine.AbstractJupiterTestEngineTests; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.engine.DiscoveryIssue.Severity; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.launcher.LauncherDiscoveryRequest; import org.junit.platform.testkit.engine.Events; @@ -135,7 +139,7 @@ static void afterAll() { @BeforeEach @AfterEach void beforeAndAfterEach(TestInfo testInfo, RepetitionInfo repetitionInfo) { - switch (testInfo.getTestMethod().get().getName()) { + switch (testInfo.getTestMethod().orElseThrow().getName()) { case "repeatedOnce" -> { assertThat(repetitionInfo.getCurrentRepetition()).isEqualTo(1); assertThat(repetitionInfo.getTotalRepetitions()).isEqualTo(1); @@ -291,14 +295,16 @@ void failureThreshold3() { // @formatter:on } - @Test - void failureThresholdWithConcurrentExecution() { + @ParameterizedTest + @EnumSource(ConcurrentExecutorServiceType.class) + void failureThresholdWithConcurrentExecution(ConcurrentExecutorServiceType executorServiceType) { Class testClass = TestCase.class; String methodName = "failureThresholdWithConcurrentExecution"; - Method method = ReflectionSupport.findMethod(testClass, methodName).get(); + Method method = ReflectionSupport.findMethod(testClass, methodName).orElseThrow(); LauncherDiscoveryRequest request = request()// .selectors(selectMethod(testClass, method))// .configurationParameter(PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true")// + .configurationParameter(PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType.name()) // .configurationParameter(DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent")// .configurationParameter(PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed")// .configurationParameter(PARALLEL_CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, "4")// @@ -323,7 +329,7 @@ void failureThresholdWithConcurrentExecution() { private Events executeTest(String methodName) { Class testClass = TestCase.class; - Method method = ReflectionSupport.findMethod(testClass, methodName).get(); + Method method = ReflectionSupport.findMethod(testClass, methodName).orElseThrow(); return executeTests(selectMethod(testClass, method)).allEvents(); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java index 1df6e43eb0b0..a7668934489d 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java @@ -32,11 +32,16 @@ import org.junit.jupiter.api.parallel.Isolated; import org.junit.jupiter.api.parallel.ResourceLock; import org.junit.jupiter.engine.Constants; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.testkit.engine.EngineTestKit; // https://github.com/junit-team/junit-framework/issues/3945 @Timeout(10) -public class ForkJoinDeadLockTests { +@ParameterizedClass +@EnumSource(ConcurrentExecutorServiceType.class) +record ForkJoinDeadLockTests(ConcurrentExecutorServiceType executorServiceType) { @Test void forkJoinExecutionDoesNotLeadToDeadLock() { @@ -53,10 +58,12 @@ void multiLevelLocks() { run(ClassLevelTestCase.class); } - private static void run(Class... classes) { + private void run(Class... classes) { EngineTestKit.engine("junit-jupiter") // .selectors(selectClasses(classes)) // .configurationParameter(Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // + .configurationParameter(Constants.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, + executorServiceType.name()) // .configurationParameter(Constants.DEFAULT_PARALLEL_EXECUTION_MODE, "concurrent") // .configurationParameter(Constants.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME, "concurrent") // .configurationParameter(Constants.PARALLEL_CONFIG_STRATEGY_PROPERTY_NAME, "fixed") // From 663b6901f28a9328cc532ac1f997e4942dce490b Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 30 Oct 2025 10:09:34 +0100 Subject: [PATCH 097/120] Rename to `Parallel...` instead of `Concurrent...` for consistency --- .../release-notes/release-notes-6.1.0-M1.adoc | 4 +-- .../org/junit/jupiter/engine/Constants.java | 12 +++---- .../jupiter/engine/JupiterTestEngine.java | 4 +-- .../config/DefaultJupiterConfiguration.java | 4 +-- .../engine/config/JupiterConfiguration.java | 4 +-- ...inPoolHierarchicalTestExecutorService.java | 20 +++++++---- ...erarchicalTestExecutorServiceFactory.java} | 34 +++++++++---------- ...adPoolHierarchicalTestExecutorService.java | 9 ++--- .../DefaultJupiterConfigurationTests.java | 6 ++-- ...gistrationViaParametersAndFieldsTests.java | 8 +++-- .../engine/extension/OrderedMethodTests.java | 6 ++-- .../engine/extension/RepeatedTestTests.java | 6 ++-- .../hierarchical/ForkJoinDeadLockTests.java | 6 ++-- .../HierarchicalTestExecutorTests.java | 10 +++--- .../ParallelExecutionIntegrationTests.java | 6 ++-- 15 files changed, 75 insertions(+), 64 deletions(-) rename junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/{ConcurrentHierarchicalTestExecutorServiceFactory.java => ParallelHierarchicalTestExecutorServiceFactory.java} (78%) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc index b07e1a1b332b..204bcd5d7afb 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc @@ -22,7 +22,7 @@ repository on GitHub. ==== Deprecations and Breaking Changes * Deprecate constructors for `ForkJoinPoolHierarchicalTestExecutorService` in favor of the - new `ConcurrentHierarchicalTestExecutorServiceFactory` that also supports + new `ParallelHierarchicalTestExecutorServiceFactory` that also supports `WorkerThreadPoolHierarchicalTestExecutorService`. [[release-notes-6.1.0-M1-junit-platform-new-features-and-improvements]] @@ -32,7 +32,7 @@ repository on GitHub. its classloader for test discovery. * New `WorkerThreadPoolHierarchicalTestExecutorService` implementation of parallel test execution that is backed by a regular thread pool rather than a `ForkJoinPool`. Engine - authors should switch to use `ConcurrentHierarchicalTestExecutorServiceFactory` rather + authors should switch to use `ParallelHierarchicalTestExecutorServiceFactory` rather than instantiating a concrete `HierarchicalTestExecutorService` implementation for parallel execution directly. * `OpenTestReportGeneratingListener` now supports redirecting XML events to a socket via diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java index 7db3767ce28c..477e6187841b 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/Constants.java @@ -38,9 +38,9 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.engine.config.JupiterConfiguration; import org.junit.platform.commons.util.ClassNamePatternFilterUtils; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.engine.support.hierarchical.ParallelExecutionConfigurationStrategy; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; /** * Collection of constants related to the {@link JupiterTestEngine}. @@ -241,16 +241,16 @@ public final class Constants { /** * Property name used to determine the desired - * {@link ConcurrentExecutorServiceType ConcurrentExecutorServiceType}: + * {@link ParallelExecutorServiceType ParallelExecutorServiceType}: * {@value} * *

Value must be - * {@link ConcurrentExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or - * {@link ConcurrentExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or + * {@link ParallelExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, * ignoring case. * * @since 6.1 - * @see ConcurrentHierarchicalTestExecutorServiceFactory + * @see ParallelHierarchicalTestExecutorServiceFactory */ @API(status = EXPERIMENTAL, since = "6.1") public static final String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = JupiterConfiguration.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME; diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java index 91bdc287c2ac..261ba352c8ef 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/JupiterTestEngine.java @@ -29,9 +29,9 @@ import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.config.PrefixedConfigurationParameters; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory; import org.junit.platform.engine.support.hierarchical.HierarchicalTestEngine; import org.junit.platform.engine.support.hierarchical.HierarchicalTestExecutorService; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; import org.junit.platform.engine.support.hierarchical.ThrowableCollector; /** @@ -79,7 +79,7 @@ public TestDescriptor discover(EngineDiscoveryRequest discoveryRequest, UniqueId protected HierarchicalTestExecutorService createExecutorService(ExecutionRequest request) { JupiterConfiguration configuration = getJupiterConfiguration(request); if (configuration.isParallelExecutionEnabled()) { - return ConcurrentHierarchicalTestExecutorServiceFactory.create(new PrefixedConfigurationParameters( + return ParallelHierarchicalTestExecutorServiceFactory.create(new PrefixedConfigurationParameters( request.getConfigurationParameters(), JupiterConfiguration.PARALLEL_CONFIG_PREFIX)); } return super.createExecutorService(request); diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 71c0c3e1a73c..48cd87b6501e 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -16,8 +16,8 @@ import static org.junit.jupiter.api.io.TempDir.DEFAULT_CLEANUP_MODE_PROPERTY_NAME; import static org.junit.jupiter.api.io.TempDir.DEFAULT_FACTORY_PROPERTY_NAME; import static org.junit.jupiter.engine.config.FilteringConfigurationParameterConverter.exclude; -import static org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType.FORK_JOIN_POOL; -import static org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType.WORKER_THREAD_POOL; +import static org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType.FORK_JOIN_POOL; +import static org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType.WORKER_THREAD_POOL; import java.util.List; import java.util.Optional; diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java index 26abafa10d50..0934899552ee 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/JupiterConfiguration.java @@ -31,7 +31,7 @@ import org.junit.jupiter.api.parallel.Execution; import org.junit.jupiter.api.parallel.ExecutionMode; import org.junit.platform.engine.OutputDirectoryCreator; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; /** * @since 5.4 @@ -45,7 +45,7 @@ public interface JupiterConfiguration { String PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME = "junit.jupiter.execution.parallel.enabled"; String PARALLEL_CONFIG_PREFIX = "junit.jupiter.execution.parallel.config."; String PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME = PARALLEL_CONFIG_PREFIX - + ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME; + + ParallelHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME; String CLOSING_STORED_AUTO_CLOSEABLE_ENABLED_PROPERTY_NAME = "junit.jupiter.extensions.store.close.autocloseable.enabled"; String DEFAULT_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_EXECUTION_MODE_PROPERTY_NAME; String DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME = Execution.DEFAULT_CLASSES_EXECUTION_MODE_PROPERTY_NAME; diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java index 15690f95ee83..f0b72fd99777 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ForkJoinPoolHierarchicalTestExecutorService.java @@ -12,7 +12,7 @@ import static java.util.concurrent.CompletableFuture.completedFuture; import static org.apiguardian.api.API.Status.DEPRECATED; -import static org.apiguardian.api.API.Status.STABLE; +import static org.apiguardian.api.API.Status.MAINTAINED; import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.CONCURRENT; import static org.junit.platform.engine.support.hierarchical.Node.ExecutionMode.SAME_THREAD; @@ -34,7 +34,7 @@ import org.junit.platform.commons.logging.LoggerFactory; import org.junit.platform.commons.util.ExceptionUtils; import org.junit.platform.engine.ConfigurationParameters; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; /** * A {@link ForkJoinPool}-based @@ -42,10 +42,12 @@ * {@linkplain TestTask test tasks} with the configured parallelism. * * @since 1.3 - * @see ForkJoinPool + * @see ParallelHierarchicalTestExecutorServiceFactory + * @see ParallelExecutorServiceType#FORK_JOIN_POOL * @see DefaultParallelExecutionConfigurationStrategy + * @see ForkJoinPool */ -@API(status = STABLE, since = "1.10") +@API(status = MAINTAINED, since = "1.10") public class ForkJoinPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { // package-private for testing @@ -61,7 +63,11 @@ public class ForkJoinPoolHierarchicalTestExecutorService implements Hierarchical * * @see DefaultParallelExecutionConfigurationStrategy * @deprecated Please use - * {@link ConcurrentHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters)} + * {@link ParallelHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters)} + * with configuration parameter + * {@value ParallelHierarchicalTestExecutorServiceFactory#EXECUTOR_SERVICE_PROPERTY_NAME} + * set to + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} * instead. */ @API(status = DEPRECATED, since = "6.1") @@ -76,7 +82,9 @@ public ForkJoinPoolHierarchicalTestExecutorService(ConfigurationParameters confi * * @since 1.7 * @deprecated Please use - * {@link ConcurrentHierarchicalTestExecutorServiceFactory#create(ConcurrentExecutorServiceType, ParallelExecutionConfiguration)} + * {@link ParallelHierarchicalTestExecutorServiceFactory#create(ParallelExecutorServiceType, ParallelExecutionConfiguration)} + * with + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL ParallelExecutorServiceType.FORK_JOIN_POOL} * instead. */ @API(status = DEPRECATED, since = "6.1") diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java similarity index 78% rename from junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java rename to junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java index 03b77b561f00..f96c7bd5f8f5 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ConcurrentHierarchicalTestExecutorServiceFactory.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/ParallelHierarchicalTestExecutorServiceFactory.java @@ -22,23 +22,23 @@ /** * Factory for {@link HierarchicalTestExecutorService} instances that support - * concurrent execution. + * parallel execution. * * @since 6.1 - * @see ConcurrentExecutorServiceType + * @see ParallelExecutorServiceType * @see ForkJoinPoolHierarchicalTestExecutorService * @see WorkerThreadPoolHierarchicalTestExecutorService */ @API(status = MAINTAINED, since = "6.1") -public class ConcurrentHierarchicalTestExecutorServiceFactory { +public final class ParallelHierarchicalTestExecutorServiceFactory { /** * Property name used to determine the desired - * {@link ConcurrentExecutorServiceType ConcurrentExecutorServiceType}. + * {@link ParallelExecutorServiceType ParallelExecutorServiceType}. * *

Value must be - * {@link ConcurrentExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or - * {@link ConcurrentExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, + * {@link ParallelExecutorServiceType#FORK_JOIN_POOL FORK_JOIN_POOL} or + * {@link ParallelExecutorServiceType#WORKER_THREAD_POOL WORKER_THREAD_POOL}, * ignoring case. */ public static final String EXECUTOR_SERVICE_PROPERTY_NAME = "executor-service"; @@ -59,13 +59,13 @@ public class ConcurrentHierarchicalTestExecutorServiceFactory { * key. * * @see #EXECUTOR_SERVICE_PROPERTY_NAME - * @see ConcurrentExecutorServiceType + * @see ParallelExecutorServiceType * @see ParallelExecutionConfigurationStrategy * @see PrefixedConfigurationParameters */ public static HierarchicalTestExecutorService create(ConfigurationParameters configurationParameters) { - var type = configurationParameters.get(EXECUTOR_SERVICE_PROPERTY_NAME, ConcurrentExecutorServiceType::parse) // - .orElse(ConcurrentExecutorServiceType.FORK_JOIN_POOL); + var type = configurationParameters.get(EXECUTOR_SERVICE_PROPERTY_NAME, ParallelExecutorServiceType::parse) // + .orElse(ParallelExecutorServiceType.FORK_JOIN_POOL); var configuration = DefaultParallelExecutionConfigurationStrategy.toConfiguration(configurationParameters); return create(type, configuration); } @@ -75,32 +75,32 @@ public static HierarchicalTestExecutorService create(ConfigurationParameters con * supplied {@link ConfigurationParameters}. * *

The {@value #EXECUTOR_SERVICE_PROPERTY_NAME} key is ignored in favor - * of the supplied {@link ConcurrentExecutorServiceType} parameter when + * of the supplied {@link ParallelExecutorServiceType} parameter when * invoking this method. * - * @see ConcurrentExecutorServiceType + * @see ParallelExecutorServiceType * @see ParallelExecutionConfigurationStrategy */ - public static HierarchicalTestExecutorService create(ConcurrentExecutorServiceType type, + public static HierarchicalTestExecutorService create(ParallelExecutorServiceType executorServiceType, ParallelExecutionConfiguration configuration) { - return switch (type) { + return switch (executorServiceType) { case FORK_JOIN_POOL -> new ForkJoinPoolHierarchicalTestExecutorService(configuration, TaskEventListener.NOOP); case WORKER_THREAD_POOL -> new WorkerThreadPoolHierarchicalTestExecutorService(configuration); }; } - private ConcurrentHierarchicalTestExecutorServiceFactory() { + private ParallelHierarchicalTestExecutorServiceFactory() { } /** - * Type of {@link HierarchicalTestExecutorService} that supports concurrent + * Type of {@link HierarchicalTestExecutorService} that supports parallel * execution. * * @since 6.1 */ @API(status = MAINTAINED, since = "6.1") - public enum ConcurrentExecutorServiceType { + public enum ParallelExecutorServiceType { /** * Indicates that {@link ForkJoinPoolHierarchicalTestExecutorService} @@ -115,7 +115,7 @@ public enum ConcurrentExecutorServiceType { @API(status = EXPERIMENTAL, since = "6.1") WORKER_THREAD_POOL; - private static ConcurrentExecutorServiceType parse(String value) { + private static ParallelExecutorServiceType parse(String value) { return valueOf(value.toUpperCase(Locale.ROOT)); } } diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 3d3fb97f4274..8b8f5ff167d2 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -54,7 +54,7 @@ import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; import org.junit.platform.engine.ConfigurationParameters; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; /** * An {@linkplain HierarchicalTestExecutorService executor service} based on a @@ -62,8 +62,8 @@ * configured parallelism. * * @since 6.1 - * @see ConcurrentHierarchicalTestExecutorServiceFactory - * @see ConcurrentExecutorServiceType#WORKER_THREAD_POOL + * @see ParallelHierarchicalTestExecutorServiceFactory + * @see ParallelExecutorServiceType#WORKER_THREAD_POOL * @see DefaultParallelExecutionConfigurationStrategy */ @API(status = EXPERIMENTAL, since = "6.1") @@ -111,12 +111,13 @@ public final class WorkerThreadPoolHierarchicalTestExecutorService implements Hi * {@link ParallelExecutionConfiguration#getSaturatePredicate()}, are * ignored. * - * @see ConcurrentHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters) + * @see ParallelHierarchicalTestExecutorServiceFactory#create(ConfigurationParameters) */ WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration) { this(configuration, ClassLoaderUtils.getDefaultClassLoader()); } + // package-private for testing WorkerThreadPoolHierarchicalTestExecutorService(ParallelExecutionConfiguration configuration, ClassLoader classLoader) { ThreadFactory threadFactory = new WorkerThreadFactory(classLoader); diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java index af22d1d29ab9..6efac2e2d996 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java @@ -47,7 +47,7 @@ import org.junit.platform.engine.DiscoveryIssue; import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.support.discovery.DiscoveryIssueReporter; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.launcher.core.ConfigurationParametersFactoryForTests; class DefaultJupiterConfigurationTests { @@ -163,9 +163,9 @@ void doesNotReportAnyIssuesIfConfigurationParametersAreEmpty() { } @ParameterizedTest - @EnumSource(ConcurrentExecutorServiceType.class) + @EnumSource(ParallelExecutorServiceType.class) void doesNotReportAnyIssuesIfParallelExecutionIsEnabledAndConfigurationParameterIsSet( - ConcurrentExecutorServiceType executorServiceType) { + ParallelExecutorServiceType executorServiceType) { var parameters = Map.of(JupiterConfiguration.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, true, // JupiterConfiguration.PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType); List issues = new ArrayList<>(); diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java index f821f69209b3..64691d61a7cd 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/ExtensionRegistrationViaParametersAndFieldsTests.java @@ -79,7 +79,8 @@ import org.junit.platform.commons.logging.LogRecordListener; import org.junit.platform.commons.support.ModifierSupport; import org.junit.platform.commons.util.ExceptionUtils; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineExecutionResults; /** @@ -208,8 +209,9 @@ void registersProgrammaticTestInstancePostProcessors() { } @ParameterizedTest - @EnumSource(ConcurrentExecutorServiceType.class) - void createsExtensionPerInstance(ConcurrentExecutorServiceType executorServiceType) { + @EnumSource(ParallelExecutorServiceType.class) + void createsExtensionPerInstance( + ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType executorServiceType) { var results = executeTests(request() // .selectors(selectClass(InitializationPerInstanceTestCase.class)) // .configurationParameter(Constants.PARALLEL_EXECUTION_ENABLED_PROPERTY_NAME, "true") // diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java index 3c052cf330b6..a464c896aee0 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/OrderedMethodTests.java @@ -69,7 +69,7 @@ import org.junit.platform.engine.DiscoveryIssue.Severity; import org.junit.platform.engine.support.descriptor.ClassSource; import org.junit.platform.engine.support.descriptor.MethodSource; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineDiscoveryResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Events; @@ -82,8 +82,8 @@ * @since 5.4 */ @ParameterizedClass -@EnumSource(ConcurrentExecutorServiceType.class) -record OrderedMethodTests(ConcurrentExecutorServiceType executorServiceType) { +@EnumSource(ParallelExecutorServiceType.class) +record OrderedMethodTests(ParallelExecutorServiceType executorServiceType) { private static final Set callSequence = Collections.synchronizedSet(new LinkedHashSet<>()); private static final Set threadNames = Collections.synchronizedSet(new LinkedHashSet<>()); diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java index c78bede00d74..bfb48e235ba0 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/extension/RepeatedTestTests.java @@ -51,7 +51,7 @@ import org.junit.jupiter.params.provider.EnumSource; import org.junit.platform.commons.support.ReflectionSupport; import org.junit.platform.engine.DiscoveryIssue.Severity; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.launcher.LauncherDiscoveryRequest; import org.junit.platform.testkit.engine.Events; @@ -296,8 +296,8 @@ void failureThreshold3() { } @ParameterizedTest - @EnumSource(ConcurrentExecutorServiceType.class) - void failureThresholdWithConcurrentExecution(ConcurrentExecutorServiceType executorServiceType) { + @EnumSource(ParallelExecutorServiceType.class) + void failureThresholdWithConcurrentExecution(ParallelExecutorServiceType executorServiceType) { Class testClass = TestCase.class; String methodName = "failureThresholdWithConcurrentExecution"; Method method = ReflectionSupport.findMethod(testClass, methodName).orElseThrow(); diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java index a7668934489d..c13e8e94f1aa 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ForkJoinDeadLockTests.java @@ -34,14 +34,14 @@ import org.junit.jupiter.engine.Constants; import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.provider.EnumSource; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineTestKit; // https://github.com/junit-team/junit-framework/issues/3945 @Timeout(10) @ParameterizedClass -@EnumSource(ConcurrentExecutorServiceType.class) -record ForkJoinDeadLockTests(ConcurrentExecutorServiceType executorServiceType) { +@EnumSource(ParallelExecutorServiceType.class) +record ForkJoinDeadLockTests(ParallelExecutorServiceType executorServiceType) { @Test void forkJoinExecutionDoesNotLeadToDeadLock() { diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java index 6879bc715973..2bddf3bfb991 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/HierarchicalTestExecutorTests.java @@ -49,9 +49,9 @@ import org.junit.platform.engine.TestExecutionResult; import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.descriptor.AbstractTestDescriptor; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; import org.junit.platform.engine.support.hierarchical.ExclusiveResource.LockMode; import org.junit.platform.engine.support.hierarchical.Node.DynamicTestExecutor; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.launcher.core.ConfigurationParametersFactoryForTests; import org.mockito.ArgumentCaptor; import org.mockito.Mock; @@ -561,9 +561,9 @@ void executesDynamicTestDescriptorsWithCustomListener() { } @ParameterizedTest - @EnumSource(ConcurrentExecutorServiceType.class) + @EnumSource(ParallelExecutorServiceType.class) @MockitoSettings(strictness = LENIENT) - void canAbortExecutionOfDynamicChild(ConcurrentExecutorServiceType executorServiceType) throws Exception { + void canAbortExecutionOfDynamicChild(ParallelExecutorServiceType executorServiceType) throws Exception { var leafUniqueId = UniqueId.root("leaf", "child leaf"); var child = spy(new MyLeaf(leafUniqueId)); @@ -593,11 +593,11 @@ void canAbortExecutionOfDynamicChild(ConcurrentExecutorServiceType executorServi }); var parameters = ConfigurationParametersFactoryForTests.create(Map.of(// - ConcurrentHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType, // + ParallelHierarchicalTestExecutorServiceFactory.EXECUTOR_SERVICE_PROPERTY_NAME, executorServiceType, // DefaultParallelExecutionConfigurationStrategy.CONFIG_STRATEGY_PROPERTY_NAME, "fixed", // DefaultParallelExecutionConfigurationStrategy.CONFIG_FIXED_PARALLELISM_PROPERTY_NAME, 2)); - try (var executorService = ConcurrentHierarchicalTestExecutorServiceFactory.create(parameters)) { + try (var executorService = ParallelHierarchicalTestExecutorServiceFactory.create(parameters)) { createExecutor(executorService).execute().get(); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java index 2f2be2aece27..448432f25dbb 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/ParallelExecutionIntegrationTests.java @@ -83,7 +83,7 @@ import org.junit.platform.engine.TestDescriptor; import org.junit.platform.engine.reporting.ReportEntry; import org.junit.platform.engine.support.descriptor.MethodSource; -import org.junit.platform.engine.support.hierarchical.ConcurrentHierarchicalTestExecutorServiceFactory.ConcurrentExecutorServiceType; +import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; import org.junit.platform.testkit.engine.EngineExecutionResults; import org.junit.platform.testkit.engine.EngineTestKit; import org.junit.platform.testkit.engine.Event; @@ -94,8 +94,8 @@ */ @SuppressWarnings({ "JUnitMalformedDeclaration", "NewClassNamingConvention" }) @ParameterizedClass -@EnumSource(ConcurrentExecutorServiceType.class) -record ParallelExecutionIntegrationTests(ConcurrentExecutorServiceType executorServiceType) { +@EnumSource(ParallelExecutorServiceType.class) +record ParallelExecutionIntegrationTests(ParallelExecutorServiceType executorServiceType) { @Test void successfulParallelTest(TestReporter reporter) { From 1f9ffde7811e814c98437a1742c5b4c70cc9ee60 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 3 Nov 2025 18:41:53 +0100 Subject: [PATCH 098/120] Compare queue entries by unique id This avoids an exhaustable index and potentially famous last words. --- ...adPoolHierarchicalTestExecutorService.java | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 8b8f5ff167d2..a71adb958168 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -41,7 +41,6 @@ import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiFunction; import java.util.function.BooleanSupplier; import java.util.function.Consumer; @@ -54,6 +53,7 @@ import org.junit.platform.commons.util.Preconditions; import org.junit.platform.commons.util.ToStringBuilder; import org.junit.platform.engine.ConfigurationParameters; +import org.junit.platform.engine.UniqueId; import org.junit.platform.engine.support.hierarchical.ParallelHierarchicalTestExecutorServiceFactory.ParallelExecutorServiceType; /** @@ -642,7 +642,6 @@ private enum BlockingMode { } private static class WorkQueue implements Iterable { - private final AtomicLong index = new AtomicLong(); private final Set queue = new ConcurrentSkipListSet<>(); Entry add(TestTask task) { @@ -652,8 +651,8 @@ Entry add(TestTask task) { } Entry createEntry(TestTask task) { - int level = task.getTestDescriptor().getUniqueId().getSegments().size(); - return new Entry(task, new CompletableFuture<>(), level, index.getAndIncrement()); + var uniqueId = task.getTestDescriptor().getUniqueId(); + return new Entry(uniqueId, task, new CompletableFuture<>()); } void addAll(Collection entries) { @@ -686,7 +685,7 @@ public Iterator iterator() { return queue.iterator(); } - private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, int level, long index) + private record Entry(UniqueId id, TestTask task, CompletableFuture<@Nullable Void> future) implements Comparable { @SuppressWarnings("FutureReturnValueIgnored") @@ -703,7 +702,7 @@ private record Entry(TestTask task, CompletableFuture<@Nullable Void> future, in @Override public int compareTo(Entry that) { - var result = Integer.compare(that.level, this.level); + var result = Integer.compare(that.getLevel(), getLevel()); if (result != 0) { return result; } @@ -711,7 +710,35 @@ public int compareTo(Entry that) { if (result != 0) { return result; } - return Long.compare(that.index, this.index); + return compareBy(that.id(), this.id()); + } + + private int compareBy(UniqueId a, UniqueId b) { + var aIterator = a.getSegments().iterator(); + var bIterator = b.getSegments().iterator(); + + // ids have the same length + while (aIterator.hasNext()) { + var aCurrent = aIterator.next(); + var bCurrent = bIterator.next(); + int result = compareBy(aCurrent, bCurrent); + if (result != 0) { + return result; + } + } + return 0; + } + + private int compareBy(UniqueId.Segment a, UniqueId.Segment b) { + int result = a.getType().compareTo(b.getType()); + if (result != 0) { + return result; + } + return a.getValue().compareTo(b.getValue()); + } + + private int getLevel() { + return this.id.getSegments().size(); } private boolean isContainer() { From 592a05e8bb51a0d8b57b10b0e7c767691c43c5ae Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 3 Nov 2025 19:07:13 +0100 Subject: [PATCH 099/120] Retain invoke all order --- ...adPoolHierarchicalTestExecutorService.java | 15 ++++++--- ...lHierarchicalTestExecutorServiceTests.java | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index a71adb958168..c8934dfa7e03 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -345,6 +345,7 @@ private List forkConcurrentChildren(List ch Consumer isolatedTaskCollector, List sameThreadTasks) { List queueEntries = new ArrayList<>(children.size()); + int index = 0; for (TestTask child : children) { if (requiresGlobalReadWriteLock(child)) { isolatedTaskCollector.accept(child); @@ -353,7 +354,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(child); } else { - queueEntries.add(workQueue.createEntry(child)); + queueEntries.add(workQueue.createEntry(child, index++)); } } @@ -645,14 +646,14 @@ private static class WorkQueue implements Iterable { private final Set queue = new ConcurrentSkipListSet<>(); Entry add(TestTask task) { - Entry entry = createEntry(task); + Entry entry = createEntry(task, 0); LOGGER.trace(() -> "forking: " + entry.task); return doAdd(entry); } - Entry createEntry(TestTask task) { + Entry createEntry(TestTask task, int index) { var uniqueId = task.getTestDescriptor().getUniqueId(); - return new Entry(uniqueId, task, new CompletableFuture<>()); + return new Entry(uniqueId, task, new CompletableFuture<>(), index); } void addAll(Collection entries) { @@ -685,7 +686,7 @@ public Iterator iterator() { return queue.iterator(); } - private record Entry(UniqueId id, TestTask task, CompletableFuture<@Nullable Void> future) + private record Entry(UniqueId id, TestTask task, CompletableFuture<@Nullable Void> future, int index) implements Comparable { @SuppressWarnings("FutureReturnValueIgnored") @@ -710,6 +711,10 @@ public int compareTo(Entry that) { if (result != 0) { return result; } + result = Integer.compare(that.index(), this.index()); + if (result != 0) { + return result; + } return compareBy(that.id(), this.id()); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 4439ff89b495..fd1730524826 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -29,6 +29,8 @@ import java.net.URL; import java.net.URLClassLoader; import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; @@ -473,6 +475,36 @@ void executesChildrenInOrder() throws Exception { .isSorted(); } + @Test + void executesChildrenInInvokeAllOrder() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); + + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1a").withLevel(2); + var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1b").withLevel(2); + var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1c").withLevel(2); + var leaf1d = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("leaf1d").withLevel(2); + + List children = Arrays.asList(leaf1d, leaf1a, leaf1b, leaf1c); + Collections.shuffle(children); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + () -> requiredService().invokeAll(children)) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf1d)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(children) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + @Test void workIsStolenInReverseOrder() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); From 86af266ac9f355601edb88a88328f233aaa56212 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 3 Nov 2025 19:09:58 +0100 Subject: [PATCH 100/120] Polishing --- .../WorkerThreadPoolHierarchicalTestExecutorServiceTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index fd1730524826..905fb4787c38 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -490,8 +490,8 @@ void executesChildrenInInvokeAllOrder() throws Exception { List children = Arrays.asList(leaf1d, leaf1a, leaf1b, leaf1c); Collections.shuffle(children); - - var root = new TestTaskStub(ExecutionMode.SAME_THREAD, + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, // () -> requiredService().invokeAll(children)) // .withName("root").withLevel(1); From 8d1ec79e6b9ad7c7ffa064b6bee743aa853b8c84 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 3 Nov 2025 20:12:17 +0100 Subject: [PATCH 101/120] Track dynamic children by index --- ...adPoolHierarchicalTestExecutorService.java | 22 ++++++++---- ...lHierarchicalTestExecutorServiceTests.java | 35 +++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index c8934dfa7e03..bca7957b1d68 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -142,7 +142,7 @@ public void close() { var workerThread = WorkerThread.get(); if (workerThread == null) { - return enqueue(testTask).future(); + return enqueue(testTask, 0).future(); } if (testTask.getExecutionMode() == SAME_THREAD) { @@ -150,7 +150,7 @@ public void close() { return completedFuture(null); } - var entry = enqueue(testTask); + var entry = enqueue(testTask, workerThread.nextChildIndex()); workerThread.trackSubmittedChild(entry); return new WorkStealingFuture(entry); } @@ -172,8 +172,8 @@ public void invokeAll(List testTasks) { workerThread.invokeAll(testTasks); } - private WorkQueue.Entry enqueue(TestTask testTask) { - var entry = workQueue.add(testTask); + private WorkQueue.Entry enqueue(TestTask testTask, int index) { + var entry = workQueue.add(testTask, index); maybeStartWorker(); return entry; } @@ -548,6 +548,10 @@ private static CompletableFuture toCombinedFuture(List entri return CompletableFuture.allOf(futures); } + private int nextChildIndex() { + return stateStack.element().nextChildIndex(); + } + private void trackSubmittedChild(WorkQueue.Entry entry) { stateStack.element().trackSubmittedChild(entry); } @@ -571,6 +575,8 @@ private void tryToStealWorkFromSubmittedChildren() { private static class State { + private int nextChildIndex = 0; + @Nullable private List submittedChildren; @@ -586,6 +592,10 @@ private void clearIfEmpty() { submittedChildren = null; } } + + private int nextChildIndex() { + return nextChildIndex++; + } } private enum WorkStealResult { @@ -645,8 +655,8 @@ private enum BlockingMode { private static class WorkQueue implements Iterable { private final Set queue = new ConcurrentSkipListSet<>(); - Entry add(TestTask task) { - Entry entry = createEntry(task, 0); + Entry add(TestTask task, int index) { + Entry entry = createEntry(task, index); LOGGER.trace(() -> "forking: " + entry.task); return doAdd(entry); } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 905fb4787c38..566614be2fb1 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -586,6 +586,41 @@ void stealsDynamicChildren() throws Exception { assertThat(child2.executionThread).isEqualTo(root.executionThread).isNotEqualTo(child1.executionThread); } + @Test + void executesDynamicChildrenInSubmitOrder() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child2").withLevel(2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child3").withLevel(2); + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withName("child3").withLevel(2); + + List children = List.of(child1, child2, child3, child4); + Collections.shuffle(children); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var executor = requiredService(); + var features = children.stream().map(executor::submit).toList(); + for (var future : features) { + future.get(); + } + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + + assertThat(children) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + @Test void stealsNestedDynamicChildren() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); From 342e97f8145060c6d0399d3c2779e7cd69582113 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 4 Nov 2025 12:19:08 +0100 Subject: [PATCH 102/120] Fix test --- .../WorkerThreadPoolHierarchicalTestExecutorServiceTests.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 566614be2fb1..d8106368f6ae 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -29,6 +29,7 @@ import java.net.URL; import java.net.URLClassLoader; import java.time.Instant; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -599,7 +600,7 @@ void executesDynamicChildrenInSubmitOrder() throws Exception { var child4 = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("child3").withLevel(2); - List children = List.of(child1, child2, child3, child4); + List children = new ArrayList<>(List.of(child1, child2, child3, child4)); Collections.shuffle(children); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { From 4819a7244cf4b124345979f9a9a737c661ec6dff Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 4 Nov 2025 13:07:23 +0100 Subject: [PATCH 103/120] Convert `Entry` to regular class --- ...adPoolHierarchicalTestExecutorService.java | 67 ++++++++++++++----- 1 file changed, 51 insertions(+), 16 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index bca7957b1d68..0ddae570ee12 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -28,6 +28,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; @@ -354,7 +355,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(child); } else { - queueEntries.add(workQueue.createEntry(child, index++)); + queueEntries.add(new WorkQueue.Entry(child, index++)); } } @@ -656,16 +657,11 @@ private static class WorkQueue implements Iterable { private final Set queue = new ConcurrentSkipListSet<>(); Entry add(TestTask task, int index) { - Entry entry = createEntry(task, index); + Entry entry = new Entry(task, index); LOGGER.trace(() -> "forking: " + entry.task); return doAdd(entry); } - Entry createEntry(TestTask task, int index) { - var uniqueId = task.getTestDescriptor().getUniqueId(); - return new Entry(uniqueId, task, new CompletableFuture<>(), index); - } - void addAll(Collection entries) { entries.forEach(this::doAdd); } @@ -696,19 +692,25 @@ public Iterator iterator() { return queue.iterator(); } - private record Entry(UniqueId id, TestTask task, CompletableFuture<@Nullable Void> future, int index) - implements Comparable { + private static final class Entry implements Comparable { + + private final TestTask task; + private final CompletableFuture<@Nullable Void> future; + private final int index; @SuppressWarnings("FutureReturnValueIgnored") - Entry { - future.whenComplete((__, t) -> { + Entry(TestTask task, int index) { + this.future = new CompletableFuture<>(); + this.future.whenComplete((__, t) -> { if (t == null) { - LOGGER.trace(() -> "completed normally: " + this.task()); + LOGGER.trace(() -> "completed normally: " + task); } else { - LOGGER.trace(t, () -> "completed exceptionally: " + this.task()); + LOGGER.trace(t, () -> "completed exceptionally: " + task); } }); + this.task = task; + this.index = index; } @Override @@ -721,11 +723,11 @@ public int compareTo(Entry that) { if (result != 0) { return result; } - result = Integer.compare(that.index(), this.index()); + result = Integer.compare(that.index, index); if (result != 0) { return result; } - return compareBy(that.id(), this.id()); + return compareBy(that.uniqueId(), this.uniqueId()); } private int compareBy(UniqueId a, UniqueId b) { @@ -753,13 +755,46 @@ private int compareBy(UniqueId.Segment a, UniqueId.Segment b) { } private int getLevel() { - return this.id.getSegments().size(); + return uniqueId().getSegments().size(); } private boolean isContainer() { return task.getTestDescriptor().isContainer(); } + private UniqueId uniqueId() { + return task.getTestDescriptor().getUniqueId(); + } + + CompletableFuture<@Nullable Void> future() { + return future; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } + if (obj == null || obj.getClass() != this.getClass()) { + return false; + } + var that = (Entry) obj; + return Objects.equals(this.uniqueId(), that.uniqueId()) && this.index == that.index; + } + + @Override + public int hashCode() { + return Objects.hash(uniqueId(), index); + } + + @Override + public String toString() { + return new ToStringBuilder(this) // + .append("task", task) // + .append("index", index) // + .toString(); + } + } } From 33c1a392f0768d706cf59d92d2f22cda9249ebfd Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 4 Nov 2025 13:09:09 +0100 Subject: [PATCH 104/120] Always use `nextChildIndex()` --- .../WorkerThreadPoolHierarchicalTestExecutorService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 0ddae570ee12..efd049774095 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -346,7 +346,6 @@ private List forkConcurrentChildren(List ch Consumer isolatedTaskCollector, List sameThreadTasks) { List queueEntries = new ArrayList<>(children.size()); - int index = 0; for (TestTask child : children) { if (requiresGlobalReadWriteLock(child)) { isolatedTaskCollector.accept(child); @@ -355,7 +354,7 @@ else if (child.getExecutionMode() == SAME_THREAD) { sameThreadTasks.add(child); } else { - queueEntries.add(new WorkQueue.Entry(child, index++)); + queueEntries.add(new WorkQueue.Entry(child, nextChildIndex())); } } From 20eb69353281cbae22a05d3ae3854a401922ab0e Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 4 Nov 2025 14:48:30 +0100 Subject: [PATCH 105/120] Use `Comparator` --- ...adPoolHierarchicalTestExecutorService.java | 48 +++++++++---------- 1 file changed, 22 insertions(+), 26 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index efd049774095..63da55a2d0c6 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -10,8 +10,7 @@ package org.junit.platform.engine.support.hierarchical; -import static java.util.Comparator.naturalOrder; -import static java.util.Comparator.reverseOrder; +import static java.util.Comparator.comparing; import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.TimeUnit.SECONDS; @@ -23,6 +22,7 @@ import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collection; +import java.util.Comparator; import java.util.Deque; import java.util.EnumMap; import java.util.Iterator; @@ -361,13 +361,13 @@ else if (child.getExecutionMode() == SAME_THREAD) { if (!queueEntries.isEmpty()) { if (sameThreadTasks.isEmpty()) { // hold back one task for this thread - var lastEntry = queueEntries.stream().max(naturalOrder()).orElseThrow(); + var lastEntry = queueEntries.stream().max(WorkQueue.Entry.COMPARATOR).orElseThrow(); queueEntries.remove(lastEntry); sameThreadTasks.add(lastEntry.task); } forkAll(queueEntries); } - queueEntries.sort(reverseOrder()); + queueEntries.sort(WorkQueue.Entry.COMPARATOR.reversed()); return queueEntries; } @@ -653,7 +653,8 @@ private enum BlockingMode { } private static class WorkQueue implements Iterable { - private final Set queue = new ConcurrentSkipListSet<>(); + + private final Set queue = new ConcurrentSkipListSet<>(Entry.COMPARATOR); Entry add(TestTask task, int index) { Entry entry = new Entry(task, index); @@ -691,7 +692,15 @@ public Iterator iterator() { return queue.iterator(); } - private static final class Entry implements Comparable { + private static final class Entry { + + private static final Comparator SAME_LENGTH_UNIQUE_ID_COMPARATOR // + = (e1, e2) -> compareBy(e1.uniqueId(), e2.uniqueId()); + + private static final Comparator COMPARATOR = comparing(Entry::level).reversed() // + .thenComparing(Entry::isContainer) // tests before containers + .thenComparing(comparing(Entry::index).reversed()) // + .thenComparing(SAME_LENGTH_UNIQUE_ID_COMPARATOR.reversed()); private final TestTask task; private final CompletableFuture<@Nullable Void> future; @@ -712,24 +721,7 @@ private static final class Entry implements Comparable { this.index = index; } - @Override - public int compareTo(Entry that) { - var result = Integer.compare(that.getLevel(), getLevel()); - if (result != 0) { - return result; - } - result = Boolean.compare(this.isContainer(), that.isContainer()); - if (result != 0) { - return result; - } - result = Integer.compare(that.index, index); - if (result != 0) { - return result; - } - return compareBy(that.uniqueId(), this.uniqueId()); - } - - private int compareBy(UniqueId a, UniqueId b) { + private static int compareBy(UniqueId a, UniqueId b) { var aIterator = a.getSegments().iterator(); var bIterator = b.getSegments().iterator(); @@ -745,7 +737,7 @@ private int compareBy(UniqueId a, UniqueId b) { return 0; } - private int compareBy(UniqueId.Segment a, UniqueId.Segment b) { + private static int compareBy(UniqueId.Segment a, UniqueId.Segment b) { int result = a.getType().compareTo(b.getType()); if (result != 0) { return result; @@ -753,7 +745,11 @@ private int compareBy(UniqueId.Segment a, UniqueId.Segment b) { return a.getValue().compareTo(b.getValue()); } - private int getLevel() { + private int index() { + return this.index; + } + + private int level() { return uniqueId().getSegments().size(); } From ee46f96aac7b84d2792c78513bb8630dc7b367b4 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Tue, 4 Nov 2025 15:50:40 +0100 Subject: [PATCH 106/120] Use insertion order --- ...adPoolHierarchicalTestExecutorService.java | 33 ++-- ...lHierarchicalTestExecutorServiceTests.java | 171 ++++++++++-------- 2 files changed, 111 insertions(+), 93 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 63da55a2d0c6..e5e9f880b3e6 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -334,11 +334,11 @@ void invokeAll(List testTasks) { List isolatedTasks = new ArrayList<>(testTasks.size()); List sameThreadTasks = new ArrayList<>(testTasks.size()); - var reverseQueueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); + var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var reverseQueueEntriesByResult = tryToStealWorkWithoutBlocking(reverseQueueEntries); - tryToStealWorkWithBlocking(reverseQueueEntriesByResult); - waitFor(reverseQueueEntriesByResult); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); + tryToStealWorkWithBlocking(queueEntriesByResult); + waitFor(queueEntriesByResult); executeAll(isolatedTasks); } @@ -359,15 +359,14 @@ else if (child.getExecutionMode() == SAME_THREAD) { } if (!queueEntries.isEmpty()) { + queueEntries.sort(WorkQueue.Entry.CHILD_COMPARATOR); if (sameThreadTasks.isEmpty()) { // hold back one task for this thread - var lastEntry = queueEntries.stream().max(WorkQueue.Entry.COMPARATOR).orElseThrow(); - queueEntries.remove(lastEntry); - sameThreadTasks.add(lastEntry.task); + var firstEntry = queueEntries.remove(0); + sameThreadTasks.add(firstEntry.task); } forkAll(queueEntries); } - queueEntries.sort(WorkQueue.Entry.COMPARATOR.reversed()); return queueEntries; } @@ -562,9 +561,10 @@ private void tryToStealWorkFromSubmittedChildren() { if (currentSubmittedChildren == null || currentSubmittedChildren.isEmpty()) { return; } - var iterator = currentSubmittedChildren.listIterator(currentSubmittedChildren.size()); - while (iterator.hasPrevious()) { - WorkQueue.Entry entry = iterator.previous(); + currentSubmittedChildren.sort(WorkQueue.Entry.CHILD_COMPARATOR); + var iterator = currentSubmittedChildren.iterator(); + while (iterator.hasNext()) { + WorkQueue.Entry entry = iterator.next(); var result = tryToStealWork(entry, BlockingMode.NON_BLOCKING); if (result.isExecuted()) { iterator.remove(); @@ -654,7 +654,7 @@ private enum BlockingMode { private static class WorkQueue implements Iterable { - private final Set queue = new ConcurrentSkipListSet<>(Entry.COMPARATOR); + private final Set queue = new ConcurrentSkipListSet<>(Entry.QUEUE_COMPARATOR); Entry add(TestTask task, int index) { Entry entry = new Entry(task, index); @@ -697,10 +697,13 @@ private static final class Entry { private static final Comparator SAME_LENGTH_UNIQUE_ID_COMPARATOR // = (e1, e2) -> compareBy(e1.uniqueId(), e2.uniqueId()); - private static final Comparator COMPARATOR = comparing(Entry::level).reversed() // + private static final Comparator QUEUE_COMPARATOR = comparing(Entry::level).reversed() // .thenComparing(Entry::isContainer) // tests before containers - .thenComparing(comparing(Entry::index).reversed()) // - .thenComparing(SAME_LENGTH_UNIQUE_ID_COMPARATOR.reversed()); + .thenComparing(Entry::index) // + .thenComparing(SAME_LENGTH_UNIQUE_ID_COMPARATOR); + + private static final Comparator CHILD_COMPARATOR = comparing(Entry::isContainer).reversed() // containers before tests + .thenComparing(Entry::index); private final TestTask task; private final CompletableFuture<@Nullable Void> future; diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index d8106368f6ae..e257ae28f5fe 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -272,23 +272,26 @@ void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { var resourceLock = new SingleLock(exclusiveResource(LockMode.READ_WRITE), new ReentrantLock()); var lockFreeChildrenStarted = new CountDownLatch(2); - var child4Started = new CountDownLatch(1); + var child2Started = new CountDownLatch(1); Executable child1Behaviour = () -> { lockFreeChildrenStarted.countDown(); - child4Started.await(); + child2Started.await(); }; - Executable child4Behaviour = () -> { - child4Started.countDown(); + Executable child2Behaviour = () -> { + child2Started.countDown(); lockFreeChildrenStarted.await(); }; var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, child1Behaviour) // .withName("child1"); - var child2 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock) // - .withName("child2"); // - var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown).withName("child3"); - var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, child4Behaviour).withResourceLock(resourceLock) // + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Behaviour) // + .withResourceLock(resourceLock) // + .withName("child2"); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withResourceLock(resourceLock) // + .withName("child3"); // + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown) // .withName("child4"); var children = List.of(child1, child2, child3, child4); var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)) // @@ -298,8 +301,8 @@ void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { root.assertExecutedSuccessfully(); assertThat(children).allSatisfy(TestTaskStub::assertExecutedSuccessfully); - assertThat(child1.executionThread).isEqualTo(child3.executionThread); - assertThat(child2.startTime).isAfterOrEqualTo(child3.startTime); + assertThat(child1.executionThread).isEqualTo(child4.executionThread); + assertThat(child3.startTime).isAfterOrEqualTo(child4.startTime); } @Test @@ -315,7 +318,7 @@ void prioritizesChildrenOfStartedContainers() throws Exception { var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // .withName("leaf").withLevel(3); var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // - .withName("child3").withLevel(2); + .withType(CONTAINER).withName("child3").withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> requiredService().invokeAll(List.of(child1, child2, child3))) // @@ -453,33 +456,6 @@ public void release() { void executesChildrenInOrder() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); - var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // - .withName("leaf1a").withLevel(2); - var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // - .withName("leaf1b").withLevel(2); - var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT) // - .withName("leaf1c").withLevel(2); - var leaf1d = new TestTaskStub(ExecutionMode.CONCURRENT) // - .withName("leaf1d").withLevel(2); - - var root = new TestTaskStub(ExecutionMode.SAME_THREAD, - () -> requiredService().invokeAll(List.of(leaf1a, leaf1b, leaf1c, leaf1d))) // - .withName("root").withLevel(1); - - service.submit(root).get(); - - assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf1d)) // - .allSatisfy(TestTaskStub::assertExecutedSuccessfully); - - assertThat(Stream.of(leaf1a, leaf1b, leaf1c, leaf1d)) // - .extracting(TestTaskStub::startTime) // - .isSorted(); - } - - @Test - void executesChildrenInInvokeAllOrder() throws Exception { - service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); - var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("leaf1a").withLevel(2); var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT) // @@ -507,52 +483,52 @@ void executesChildrenInInvokeAllOrder() throws Exception { } @Test - void workIsStolenInReverseOrder() throws Exception { + void testsAreStolenRatherThanContainers() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); // Execute tasks pairwise CyclicBarrier cyclicBarrier = new CyclicBarrier(2); Executable behavior = cyclicBarrier::await; - // With half of the leaves to be executed normally - var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // - .withName("leaf1a").withLevel(2); - var leaf1b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // - .withName("leaf1b").withLevel(2); - var leaf1c = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // - .withName("leaf1c").withLevel(2); - - // And half of the leaves to be stolen - var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // - .withName("leaf2a").withLevel(2); - var leaf2b = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // - .withName("leaf2b").withLevel(2); - var leaf2c = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // - .withName("leaf2c").withLevel(2); + // With half of the leaves being containers + var container1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("container1").withType(CONTAINER).withLevel(2); + var container2 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("container2").withType(CONTAINER).withLevel(2); + var container3 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("container3").withType(CONTAINER).withLevel(2); + + // And half of the leaves being tests, to be stolen + var test1 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("test1").withType(TEST).withLevel(2); + var test2 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("test2").withType(TEST).withLevel(2); + var test3 = new TestTaskStub(ExecutionMode.CONCURRENT, behavior) // + .withName("test3").withType(TEST).withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, - () -> requiredService().invokeAll(List.of(leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c))) // + () -> requiredService().invokeAll(List.of(container1, container2, container3, test1, test2, test3))) // .withName("root").withLevel(1); service.submit(root).get(); - assertThat(List.of(root, leaf1a, leaf1b, leaf1c, leaf2a, leaf2b, leaf2c)) // + assertThat(List.of(root, container1, container2, container3, test1, test2, test3)) // .allSatisfy(TestTaskStub::assertExecutedSuccessfully); - // If the last node was stolen. - assertThat(leaf1a.executionThread).isNotEqualTo(leaf2c.executionThread); - // Then it must follow that the last half of the nodes were stolen - assertThat(Stream.of(leaf1a, leaf1b, leaf1c)) // + // If the last test node was stolen + assertThat(container1.executionThread).isNotEqualTo(test3.executionThread); + // Then it must follow that the test nodes were stolen + assertThat(Stream.of(container1, container2, container3)) // .extracting(TestTaskStub::executionThread) // - .containsOnly(leaf1a.executionThread); - assertThat(Stream.of(leaf2a, leaf2b, leaf2c)) // + .containsOnly(container1.executionThread); + assertThat(Stream.of(test1, test2, test3)) // .extracting(TestTaskStub::executionThread) // - .containsOnly(leaf2c.executionThread); + .containsOnly(test3.executionThread); - assertThat(Stream.of(leaf1a, leaf1b, leaf1c)) // + assertThat(Stream.of(container1, container2, container3)) // .extracting(TestTaskStub::startTime) // .isSorted(); - assertThat(Stream.of(leaf2c, leaf2b, leaf2a)) // + assertThat(Stream.of(test1, test2, test3)) // .extracting(TestTaskStub::startTime) // .isSorted(); } @@ -587,6 +563,45 @@ void stealsDynamicChildren() throws Exception { assertThat(child2.executionThread).isEqualTo(root.executionThread).isNotEqualTo(child1.executionThread); } + @Test + void stealsDynamicChildrenInOrder() throws Exception { + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); + + var child1Started = new CountDownLatch(1); + var childrenSubmitted = new CountDownLatch(1); + var childrenFinished = new CountDownLatch(2); + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + child1Started.countDown(); + childrenSubmitted.await(); + }) // + .withName("child1").withLevel(2); + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, childrenFinished::countDown) // + .withName("child2").withLevel(2); + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, childrenFinished::countDown) // + .withName("child3").withLevel(2); + + var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { + var future1 = requiredService().submit(child1); + child1Started.await(); + var future2 = requiredService().submit(child2); + var future3 = requiredService().submit(child3); + childrenSubmitted.countDown(); + childrenFinished.await(); + future1.get(); + future2.get(); + future3.get(); + }) // + .withName("root").withLevel(1); + + service.submit(root).get(); + + assertThat(Stream.of(root, child1, child2, child3)) // + .allSatisfy(TestTaskStub::assertExecutedSuccessfully); + assertThat(List.of(child1, child2, child3)) // + .extracting(TestTaskStub::startTime) // + .isSorted(); + } + @Test void executesDynamicChildrenInSubmitOrder() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(1, 1)); @@ -686,27 +701,27 @@ void stealsSiblingDynamicChildrenOnly() throws Exception { var leaf1ASubmitted = new CountDownLatch(1); var leaf1AStarted = new CountDownLatch(1); - var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { - leaf1AStarted.countDown(); - child2Started.await(); - }) // - .withName("leaf1a").withLevel(3); - var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { child1Started.countDown(); leaf1ASubmitted.await(); }) // .withName("child1").withLevel(2); - var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Started::countDown) // - .withName("child2").withLevel(2); + var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + leaf1AStarted.countDown(); + child2Started.await(); + }) // + .withName("leaf1a").withLevel(3); - var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { var futureA = requiredService().submit(leaf1a); leaf1ASubmitted.countDown(); leaf1AStarted.await(); futureA.get(); }) // + .withName("child2").withType(CONTAINER).withLevel(2); + + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Started::countDown) // .withName("child3").withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { @@ -715,18 +730,18 @@ void stealsSiblingDynamicChildrenOnly() throws Exception { var future2 = requiredService().submit(child2); var future3 = requiredService().submit(child3); future1.get(); - future2.get(); future3.get(); + future2.get(); }) // .withName("root").withLevel(1); service.submit(root).get(); - assertThat(Stream.of(root, child1, child2, child3, leaf1a)) // + assertThat(Stream.of(root, child1, child3, child2, leaf1a)) // .allSatisfy(TestTaskStub::assertExecutedSuccessfully); - assertThat(child3.executionThread).isNotEqualTo(child1.executionThread).isNotEqualTo(child2.executionThread); - assertThat(child1.executionThread).isNotEqualTo(child2.executionThread); + assertThat(child2.executionThread).isNotEqualTo(child1.executionThread).isNotEqualTo(child3.executionThread); + assertThat(child1.executionThread).isNotEqualTo(child3.executionThread); assertThat(child1.executionThread).isEqualTo(leaf1a.executionThread); } From fdd596b431f7be22eb3aad8228241287979d386a Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 5 Nov 2025 00:20:25 +0100 Subject: [PATCH 107/120] Use thenComparing with key extractor and comparator --- ...adPoolHierarchicalTestExecutorService.java | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index e5e9f880b3e6..c9615bd96ee8 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -694,13 +694,10 @@ public Iterator iterator() { private static final class Entry { - private static final Comparator SAME_LENGTH_UNIQUE_ID_COMPARATOR // - = (e1, e2) -> compareBy(e1.uniqueId(), e2.uniqueId()); - private static final Comparator QUEUE_COMPARATOR = comparing(Entry::level).reversed() // .thenComparing(Entry::isContainer) // tests before containers .thenComparing(Entry::index) // - .thenComparing(SAME_LENGTH_UNIQUE_ID_COMPARATOR); + .thenComparing(Entry::uniqueId, new SameLengthUniqueIdComparator()); private static final Comparator CHILD_COMPARATOR = comparing(Entry::isContainer).reversed() // containers before tests .thenComparing(Entry::index); @@ -724,30 +721,6 @@ private static final class Entry { this.index = index; } - private static int compareBy(UniqueId a, UniqueId b) { - var aIterator = a.getSegments().iterator(); - var bIterator = b.getSegments().iterator(); - - // ids have the same length - while (aIterator.hasNext()) { - var aCurrent = aIterator.next(); - var bCurrent = bIterator.next(); - int result = compareBy(aCurrent, bCurrent); - if (result != 0) { - return result; - } - } - return 0; - } - - private static int compareBy(UniqueId.Segment a, UniqueId.Segment b) { - int result = a.getType().compareTo(b.getType()); - if (result != 0) { - return result; - } - return a.getValue().compareTo(b.getValue()); - } - private int index() { return this.index; } @@ -793,6 +766,34 @@ public String toString() { .toString(); } + private static class SameLengthUniqueIdComparator implements Comparator { + + @Override + public int compare(UniqueId a, UniqueId b) { + var aIterator = a.getSegments().iterator(); + var bIterator = b.getSegments().iterator(); + + // ids have the same length + while (aIterator.hasNext()) { + var aCurrent = aIterator.next(); + var bCurrent = bIterator.next(); + int result = compareBy(aCurrent, bCurrent); + if (result != 0) { + return result; + } + } + return 0; + } + + private static int compareBy(UniqueId.Segment a, UniqueId.Segment b) { + int result = a.getType().compareTo(b.getType()); + if (result != 0) { + return result; + } + return a.getValue().compareTo(b.getValue()); + } + } + } } From 965c42ad16d3bb97f19a42aa6abc701ea4867811 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 5 Nov 2025 00:22:48 +0100 Subject: [PATCH 108/120] Use Arrays.asList to create an ArrayList --- .../WorkerThreadPoolHierarchicalTestExecutorServiceTests.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index e257ae28f5fe..881831026b25 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -29,7 +29,6 @@ import java.net.URL; import java.net.URLClassLoader; import java.time.Instant; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -615,7 +614,7 @@ void executesDynamicChildrenInSubmitOrder() throws Exception { var child4 = new TestTaskStub(ExecutionMode.CONCURRENT) // .withName("child3").withLevel(2); - List children = new ArrayList<>(List.of(child1, child2, child3, child4)); + List children = Arrays.asList(child1, child2, child3, child4); Collections.shuffle(children); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { From 1ea7dc7bd60121948b83d76e4c5a6b701d6b2a03 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 5 Nov 2025 00:34:16 +0100 Subject: [PATCH 109/120] Name leaves after their parents --- ...lHierarchicalTestExecutorServiceTests.java | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 881831026b25..395afaa495bc 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -696,31 +696,31 @@ void stealsSiblingDynamicChildrenOnly() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 3)); var child1Started = new CountDownLatch(1); - var child2Started = new CountDownLatch(1); - var leaf1ASubmitted = new CountDownLatch(1); - var leaf1AStarted = new CountDownLatch(1); + var child3Started = new CountDownLatch(1); + var leaf2ASubmitted = new CountDownLatch(1); + var leaf2AStarted = new CountDownLatch(1); var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { child1Started.countDown(); - leaf1ASubmitted.await(); + leaf2ASubmitted.await(); }) // .withName("child1").withLevel(2); - var leaf1a = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { - leaf1AStarted.countDown(); - child2Started.await(); + var leaf2a = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { + leaf2AStarted.countDown(); + child3Started.await(); }) // .withName("leaf1a").withLevel(3); var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> { - var futureA = requiredService().submit(leaf1a); - leaf1ASubmitted.countDown(); - leaf1AStarted.await(); + var futureA = requiredService().submit(leaf2a); + leaf2ASubmitted.countDown(); + leaf2AStarted.await(); futureA.get(); }) // .withName("child2").withType(CONTAINER).withLevel(2); - var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, child2Started::countDown) // + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, child3Started::countDown) // .withName("child3").withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, () -> { @@ -736,12 +736,12 @@ void stealsSiblingDynamicChildrenOnly() throws Exception { service.submit(root).get(); - assertThat(Stream.of(root, child1, child3, child2, leaf1a)) // + assertThat(Stream.of(root, child1, child3, child2, leaf2a)) // .allSatisfy(TestTaskStub::assertExecutedSuccessfully); assertThat(child2.executionThread).isNotEqualTo(child1.executionThread).isNotEqualTo(child3.executionThread); assertThat(child1.executionThread).isNotEqualTo(child3.executionThread); - assertThat(child1.executionThread).isEqualTo(leaf1a.executionThread); + assertThat(child1.executionThread).isEqualTo(leaf2a.executionThread); } private static ExclusiveResource exclusiveResource(LockMode lockMode) { From 02623f2d9962c7fe9c52d55029b8ebef927e6dc8 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 5 Nov 2025 00:36:34 +0100 Subject: [PATCH 110/120] Order elements --- .../WorkerThreadPoolHierarchicalTestExecutorServiceTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 395afaa495bc..c910b3dd0b84 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -736,7 +736,7 @@ void stealsSiblingDynamicChildrenOnly() throws Exception { service.submit(root).get(); - assertThat(Stream.of(root, child1, child3, child2, leaf2a)) // + assertThat(Stream.of(root, child1, child2, leaf2a, child3)) // .allSatisfy(TestTaskStub::assertExecutedSuccessfully); assertThat(child2.executionThread).isNotEqualTo(child1.executionThread).isNotEqualTo(child3.executionThread); From a52149e3211f022d352752d0a71b0fca8767be8c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 5 Nov 2025 09:01:13 +0100 Subject: [PATCH 111/120] Wait for children in order --- .../WorkerThreadPoolHierarchicalTestExecutorServiceTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index c910b3dd0b84..f73e5a2e00c5 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -729,8 +729,8 @@ void stealsSiblingDynamicChildrenOnly() throws Exception { var future2 = requiredService().submit(child2); var future3 = requiredService().submit(child3); future1.get(); - future3.get(); future2.get(); + future3.get(); }) // .withName("root").withLevel(1); From d013d31bad28deafc808fcbbd093d8bd270aadfa Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 5 Nov 2025 09:02:19 +0100 Subject: [PATCH 112/120] Repeat flaky test (to be fixed) --- .../WorkerThreadPoolHierarchicalTestExecutorServiceTests.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index f73e5a2e00c5..793917b081fe 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -42,6 +42,7 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; @@ -304,7 +305,7 @@ void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { assertThat(child3.startTime).isAfterOrEqualTo(child4.startTime); } - @Test + @RepeatedTest(value = 100, failureThreshold = 1) void prioritizesChildrenOfStartedContainers() throws Exception { service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); From 10f1dbf27021e7636a6188426da84e0980deb782 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Wed, 5 Nov 2025 18:18:05 +0100 Subject: [PATCH 113/120] Make worker start at head of queue again after executing a node --- ...adPoolHierarchicalTestExecutorService.java | 23 +++++++++----- ...lHierarchicalTestExecutorServiceTests.java | 31 ++++++++++++------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index c9615bd96ee8..5bc69f2d8051 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -14,6 +14,7 @@ import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.TimeUnit.SECONDS; +import static java.util.function.Predicate.isEqual; import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; @@ -45,6 +46,7 @@ import java.util.function.BiFunction; import java.util.function.BooleanSupplier; import java.util.function.Consumer; +import java.util.function.Predicate; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -292,7 +294,8 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) } private void processQueueEntries() { - var queueEntriesByResult = tryToStealWorkWithoutBlocking(workQueue); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(workQueue, + isEqual(WorkStealResult.EXECUTED_BY_THIS_WORKER)); var queueModified = queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) // || queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); if (queueModified) { @@ -336,7 +339,7 @@ void invokeAll(List testTasks) { List sameThreadTasks = new ArrayList<>(testTasks.size()); var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries, __ -> false); tryToStealWorkWithBlocking(queueEntriesByResult); waitFor(queueEntriesByResult); executeAll(isolatedTasks); @@ -371,10 +374,10 @@ else if (child.getExecutionMode() == SAME_THREAD) { } private Map> tryToStealWorkWithoutBlocking( - Iterable queueEntries) { + Iterable queueEntries, Predicate stopCondition) { Map> queueEntriesByResult = new EnumMap<>(WorkStealResult.class); - tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult); + tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult, stopCondition); return queueEntriesByResult; } @@ -383,14 +386,18 @@ private void tryToStealWorkWithBlocking(Map false); } private void tryToStealWork(Iterable entries, BlockingMode blocking, - Map> queueEntriesByResult) { + Map> queueEntriesByResult, + Predicate stopCondition) { for (var entry : entries) { - var state = tryToStealWork(entry, blocking); - queueEntriesByResult.computeIfAbsent(state, __ -> new ArrayList<>()).add(entry); + var result = tryToStealWork(entry, blocking); + queueEntriesByResult.computeIfAbsent(result, __ -> new ArrayList<>()).add(entry); + if (stopCondition.test(result)) { + break; + } } } diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 793917b081fe..2aa907489101 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -42,7 +42,6 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AutoClose; -import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.function.Executable; @@ -305,19 +304,28 @@ void invokeAllQueueEntriesSkipsOverUnavailableResources() throws Exception { assertThat(child3.startTime).isAfterOrEqualTo(child4.startTime); } - @RepeatedTest(value = 100, failureThreshold = 1) + @Test void prioritizesChildrenOfStartedContainers() throws Exception { - service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2)); + service = new WorkerThreadPoolHierarchicalTestExecutorService(configuration(2, 2)); - var leavesStarted = new CountDownLatch(2); + var leafSubmitted = new CountDownLatch(1); + var child2AndLeafStarted = new CountDownLatch(2); + + var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, child2AndLeafStarted::countDown) // + .withName("leaf").withLevel(3); - var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::await) // + Executable child3Behavior = () -> { + var future = requiredService().submit(leaf); + leafSubmitted.countDown(); + child2AndLeafStarted.await(); + future.get(); + }; + + var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, leafSubmitted::await) // .withName("child1").withLevel(2); - var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, child2AndLeafStarted::countDown) // .withName("child2").withLevel(2); - var leaf = new TestTaskStub(ExecutionMode.CONCURRENT, leavesStarted::countDown) // - .withName("leaf").withLevel(3); - var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().submit(leaf).get()) // + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT, child3Behavior) // .withType(CONTAINER).withName("child3").withLevel(2); var root = new TestTaskStub(ExecutionMode.SAME_THREAD, @@ -327,11 +335,10 @@ void prioritizesChildrenOfStartedContainers() throws Exception { service.submit(root).get(); root.assertExecutedSuccessfully(); - assertThat(List.of(child1, child2, leaf, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); - leaf.assertExecutedSuccessfully(); + assertThat(List.of(root, child1, child2, leaf, child3)).allSatisfy(TestTaskStub::assertExecutedSuccessfully); assertThat(leaf.startTime).isBeforeOrEqualTo(child2.startTime); - assertThat(leaf.executionThread).isSameAs(child3.executionThread); + assertThat(leaf.executionThread).isSameAs(child2.executionThread).isNotSameAs(child3.executionThread); } @Test From b647f0bfed1d999d675de463907ee768d5f38af3 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Wed, 5 Nov 2025 19:18:18 +0100 Subject: [PATCH 114/120] Reduce complexity by duplication --- ...adPoolHierarchicalTestExecutorService.java | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 5bc69f2d8051..3d9b7f71c425 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -14,7 +14,6 @@ import static java.util.Objects.requireNonNull; import static java.util.concurrent.CompletableFuture.completedFuture; import static java.util.concurrent.TimeUnit.SECONDS; -import static java.util.function.Predicate.isEqual; import static org.apiguardian.api.API.Status.EXPERIMENTAL; import static org.junit.platform.commons.util.ExceptionUtils.throwAsUncheckedException; import static org.junit.platform.engine.support.hierarchical.ExclusiveResource.GLOBAL_READ_WRITE; @@ -46,7 +45,6 @@ import java.util.function.BiFunction; import java.util.function.BooleanSupplier; import java.util.function.Consumer; -import java.util.function.Predicate; import org.apiguardian.api.API; import org.jspecify.annotations.Nullable; @@ -294,15 +292,29 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) } private void processQueueEntries() { - var queueEntriesByResult = tryToStealWorkWithoutBlocking(workQueue, - isEqual(WorkStealResult.EXECUTED_BY_THIS_WORKER)); - var queueModified = queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_THIS_WORKER) // - || queueEntriesByResult.containsKey(WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER); + var entriesRequiringResourceLocks = new ArrayList(); + var queueModified = false; + + for (var entry : workQueue) { + var result = tryToStealWork(entry, BlockingMode.NON_BLOCKING); + // After executing a test a significant amount of time has passed. + // Process the queue from the beginning + if (result == WorkStealResult.EXECUTED_BY_THIS_WORKER) { + return; + } + if (result == WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER) { + queueModified = true; + } + if (result == WorkStealResult.RESOURCE_LOCK_UNAVAILABLE) { + entriesRequiringResourceLocks.add(entry); + } + } + // The queue changed while we looked at it. + // Check from the start before processing any blocked items. if (queueModified) { return; } - var entriesRequiringResourceLocks = queueEntriesByResult.get(WorkStealResult.RESOURCE_LOCK_UNAVAILABLE); - if (entriesRequiringResourceLocks != null) { + if (!entriesRequiringResourceLocks.isEmpty()) { // One entry at a time to avoid blocking too much tryToStealWork(entriesRequiringResourceLocks.get(0), BlockingMode.BLOCKING); } @@ -339,7 +351,7 @@ void invokeAll(List testTasks) { List sameThreadTasks = new ArrayList<>(testTasks.size()); var queueEntries = forkConcurrentChildren(testTasks, isolatedTasks::add, sameThreadTasks); executeAll(sameThreadTasks); - var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries, __ -> false); + var queueEntriesByResult = tryToStealWorkWithoutBlocking(queueEntries); tryToStealWorkWithBlocking(queueEntriesByResult); waitFor(queueEntriesByResult); executeAll(isolatedTasks); @@ -374,10 +386,10 @@ else if (child.getExecutionMode() == SAME_THREAD) { } private Map> tryToStealWorkWithoutBlocking( - Iterable queueEntries, Predicate stopCondition) { + Iterable queueEntries) { Map> queueEntriesByResult = new EnumMap<>(WorkStealResult.class); - tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult, stopCondition); + tryToStealWork(queueEntries, BlockingMode.NON_BLOCKING, queueEntriesByResult); return queueEntriesByResult; } @@ -386,18 +398,14 @@ private void tryToStealWorkWithBlocking(Map false); + tryToStealWork(entriesRequiringResourceLocks, BlockingMode.BLOCKING, queueEntriesByResult); } private void tryToStealWork(Iterable entries, BlockingMode blocking, - Map> queueEntriesByResult, - Predicate stopCondition) { + Map> queueEntriesByResult) { for (var entry : entries) { var result = tryToStealWork(entry, blocking); queueEntriesByResult.computeIfAbsent(result, __ -> new ArrayList<>()).add(entry); - if (stopCondition.test(result)) { - break; - } } } From bd24b6f516fd80e5d4f99dca657a33255128f1eb Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 6 Nov 2025 08:40:31 +0100 Subject: [PATCH 115/120] Simplify --- ...erThreadPoolHierarchicalTestExecutorService.java | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 3d9b7f71c425..2dda88e233c4 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -293,27 +293,18 @@ void processQueueEntries(WorkerLease workerLease, BooleanSupplier doneCondition) private void processQueueEntries() { var entriesRequiringResourceLocks = new ArrayList(); - var queueModified = false; for (var entry : workQueue) { var result = tryToStealWork(entry, BlockingMode.NON_BLOCKING); - // After executing a test a significant amount of time has passed. - // Process the queue from the beginning if (result == WorkStealResult.EXECUTED_BY_THIS_WORKER) { + // After executing a test a significant amount of time has passed. + // Process the queue from the beginning return; } - if (result == WorkStealResult.EXECUTED_BY_DIFFERENT_WORKER) { - queueModified = true; - } if (result == WorkStealResult.RESOURCE_LOCK_UNAVAILABLE) { entriesRequiringResourceLocks.add(entry); } } - // The queue changed while we looked at it. - // Check from the start before processing any blocked items. - if (queueModified) { - return; - } if (!entriesRequiringResourceLocks.isEmpty()) { // One entry at a time to avoid blocking too much tryToStealWork(entriesRequiringResourceLocks.get(0), BlockingMode.BLOCKING); From ea28eee5476df446c66e29928ef6a718523a1deb Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 6 Nov 2025 08:57:35 +0100 Subject: [PATCH 116/120] Improve discovery issue message --- .../jupiter/engine/config/DefaultJupiterConfiguration.java | 2 +- .../engine/config/DefaultJupiterConfigurationTests.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java index 48cd87b6501e..9a2e2dd813f0 100644 --- a/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java +++ b/junit-jupiter-engine/src/main/java/org/junit/jupiter/engine/config/DefaultJupiterConfiguration.java @@ -110,7 +110,7 @@ private void validateConfigurationParameters(DiscoveryIssueReporter issueReporte + PARALLEL_CONFIG_EXECUTOR_SERVICE_PROPERTY_NAME + "' configuration parameter to '" + WORKER_THREAD_POOL + "' and report any issues to the JUnit team. " + "Alternatively, set the configuration parameter to '" + FORK_JOIN_POOL - + "' to hide this message."); + + "' to hide this message and keep using the original implementation."); issueReporter.reportIssue(info); } } diff --git a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java index 6efac2e2d996..df79d780076f 100644 --- a/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java +++ b/jupiter-tests/src/test/java/org/junit/jupiter/engine/config/DefaultJupiterConfigurationTests.java @@ -188,8 +188,8 @@ void asksUsersToTryWorkerThreadPoolHierarchicalExecutorServiceIfParallelExecutio Parallel test execution is enabled but the default ForkJoinPool-based executor service will be used. \ Please give the new implementation based on a regular thread pool a try by setting the \ 'junit.jupiter.execution.parallel.config.executor-service' configuration parameter to \ - 'WORKER_THREAD_POOL' and report any issues to the JUnit team. \ - Alternatively, set the configuration parameter to 'FORK_JOIN_POOL' to hide this message.""")); + 'WORKER_THREAD_POOL' and report any issues to the JUnit team. Alternatively, set the configuration \ + parameter to 'FORK_JOIN_POOL' to hide this message and keep using the original implementation.""")); } private void assertDefaultConfigParam(@Nullable String configValue, Lifecycle expected) { From 98d7ce88c91e2afd18d203b2eebec799ea7ce01c Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 6 Nov 2025 08:59:27 +0100 Subject: [PATCH 117/120] Polish release note entry --- .../asciidoc/release-notes/release-notes-6.1.0-M1.adoc | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc index 204bcd5d7afb..a52b75de61b0 100644 --- a/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc +++ b/documentation/src/docs/asciidoc/release-notes/release-notes-6.1.0-M1.adoc @@ -30,11 +30,11 @@ repository on GitHub. * Support for creating a `ModuleSelector` from a `java.lang.Module` and using its classloader for test discovery. -* New `WorkerThreadPoolHierarchicalTestExecutorService` implementation of parallel test - execution that is backed by a regular thread pool rather than a `ForkJoinPool`. Engine - authors should switch to use `ParallelHierarchicalTestExecutorServiceFactory` rather - than instantiating a concrete `HierarchicalTestExecutorService` implementation for - parallel execution directly. +* New `WorkerThreadPoolHierarchicalTestExecutorService` implementation used for parallel + test execution that is backed by a regular thread pool rather than a `ForkJoinPool`. + Engine authors should switch to use `ParallelHierarchicalTestExecutorServiceFactory` + rather than instantiating a concrete `HierarchicalTestExecutorService` implementation + for parallel execution directly. * `OpenTestReportGeneratingListener` now supports redirecting XML events to a socket via the new `junit.platform.reporting.open.xml.socket` configuration parameter. When set to a port number, events are sent to `127.0.0.1:` instead of being written to a file. From 8495e8b350bb3edcabc7585a69060d9be92bb826 Mon Sep 17 00:00:00 2001 From: Marc Philipp Date: Thu, 6 Nov 2025 09:03:40 +0100 Subject: [PATCH 118/120] Improve internal documentation --- ...adPoolHierarchicalTestExecutorService.java | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index 2dda88e233c4..f43706952978 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -71,20 +71,30 @@ public final class WorkerThreadPoolHierarchicalTestExecutorService implements HierarchicalTestExecutorService { /* - This implementation is based on a regular thread pool and a work queue shared among all worker threads. Whenever - a task is submitted to the work queue to be executed concurrently, an attempt is made to acquire a worker lease. - The number of total worker leases is initialized with the desired parallelism. This ensures that at most - `parallelism` tests are running concurrently, regardless whether the user code performs any blocking operations. - If a worker lease was acquired, a worker thread is started. Each worker thread polls the shared work queue for - tasks to run. Since the tasks represent hierarchically structured tests, container tasks will call - `submit(TestTask)` or `invokeAll(List)` for their children, recursively. Child tasks with execution - mode `CONCURRENT` are submitted to the shared queue be prior to executing those with execution mode - `SAME_THREAD` directly. Each worker thread attempts to "steal" queue entries for its children and execute them - itself prior to waiting for its children to finish. In case it does need to block, it temporarily gives up its - worker lease and starts another worker thread to compensate for the reduced `parallelism`. If the max pool size - does not permit starting another thread, that is ignored in case there are still other active worker threads. + This implementation is based on a regular thread pool and a work queue shared among all worker threads. + + Each worker thread scans the shared work queue for tasks to run. Since the tasks represent hierarchically + structured tests, container tasks will call `submit(TestTask)` or `invokeAll(List)` for their + children, recursively. + + To maintain the desired parallelism -- regardless whether the user code performs any blocking operations -- + a fixed number of worker leases is configured. Whenever a task is submitted to the work queue to be executed + concurrently, an attempt is made to acquire a worker lease. If a worker lease was acquired, a worker thread is + started. Each worker thread attempts to "steal" queue entries for its children and execute them itself prior to + waiting for its children to finish. + + To optimize CPU utilization, whenever a worker thread does need to block, it temporarily gives up its worker + lease and attempts to start another worker thread to compensate for the reduced `parallelism`. If the max pool + size does not permit starting another thread, the attempt is ignored in case there are still other active worker + threads. + The same happens in case a resource lock needs to be acquired. - */ + + To minimize the number of idle workers, worker threads will prefer to steal top level tasks, while working + through their own task hierarchy in a depth first fashion. Furthermore, child tasks with execution mode + `CONCURRENT` are submitted to the shared queue prior to executing those with execution mode `SAME_THREAD` + directly. + */ private static final Logger LOGGER = LoggerFactory.getLogger(WorkerThreadPoolHierarchicalTestExecutorService.class); From e1f0d9d5ee72f20092248455d6bc8ce068ee0144 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 6 Nov 2025 14:20:31 +0100 Subject: [PATCH 119/120] Polish test --- ...erThreadPoolHierarchicalTestExecutorServiceTests.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java index 2aa907489101..786cc120a6f4 100644 --- a/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java +++ b/platform-tests/src/test/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorServiceTests.java @@ -248,10 +248,13 @@ void processingQueueEntriesSkipsOverUnavailableResources() throws Exception { var child1 = new TestTaskStub(ExecutionMode.CONCURRENT, child1Behaviour) // .withResourceLock(resourceLock) // .withName("child1"); - var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown).withName("child2"); // - var child3 = new TestTaskStub(ExecutionMode.CONCURRENT).withResourceLock(resourceLock) // + var child2 = new TestTaskStub(ExecutionMode.CONCURRENT, lockFreeChildrenStarted::countDown) // + .withName("child2"); // + var child3 = new TestTaskStub(ExecutionMode.CONCURRENT) // + .withResourceLock(resourceLock) // .withName("child3"); - var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, child4Behaviour).withName("child4"); + var child4 = new TestTaskStub(ExecutionMode.CONCURRENT, child4Behaviour) // + .withName("child4"); var children = List.of(child1, child2, child3, child4); var root = new TestTaskStub(ExecutionMode.CONCURRENT, () -> requiredService().invokeAll(children)) // .withName("root"); From e7791ae5f9f17eb5d333e81a282edfd5cc4fb41a Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 6 Nov 2025 14:28:25 +0100 Subject: [PATCH 120/120] Skip over locked entries that were stolen --- .../WorkerThreadPoolHierarchicalTestExecutorService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java index f43706952978..a59f3ec5740b 100644 --- a/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java +++ b/junit-platform-engine/src/main/java/org/junit/platform/engine/support/hierarchical/WorkerThreadPoolHierarchicalTestExecutorService.java @@ -315,9 +315,12 @@ private void processQueueEntries() { entriesRequiringResourceLocks.add(entry); } } - if (!entriesRequiringResourceLocks.isEmpty()) { - // One entry at a time to avoid blocking too much - tryToStealWork(entriesRequiringResourceLocks.get(0), BlockingMode.BLOCKING); + + for (var entry : entriesRequiringResourceLocks) { + var result = tryToStealWork(entry, BlockingMode.BLOCKING); + if (result == WorkStealResult.EXECUTED_BY_THIS_WORKER) { + return; + } } }