From 6b87dd2f369ae730743ae3156f5bc89be3719f75 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Wed, 1 Apr 2026 21:01:44 +0200 Subject: [PATCH 01/20] Setup Concurrency TCK --- tck/concurrency-signature-test/pom.xml | 20 +++-- .../src/test/resources/arquillian.xml | 1 - tck/concurrency-standalone/dev.xml | 28 ------- tck/concurrency-standalone/pom.xml | 78 ++++++++++--------- .../src/test/resources/arquillian.xml | 32 +------- .../src/test/resources/logging.properties | 21 +++++ tck/concurrency-standalone/suite-web.xml | 36 --------- tck/concurrency-standalone/suite.xml | 36 --------- 8 files changed, 78 insertions(+), 174 deletions(-) delete mode 100644 tck/concurrency-standalone/dev.xml create mode 100644 tck/concurrency-standalone/src/test/resources/logging.properties delete mode 100644 tck/concurrency-standalone/suite-web.xml delete mode 100644 tck/concurrency-standalone/suite.xml diff --git a/tck/concurrency-signature-test/pom.xml b/tck/concurrency-signature-test/pom.xml index bd686f7806b..e86f99982b2 100644 --- a/tck/concurrency-signature-test/pom.xml +++ b/tck/concurrency-signature-test/pom.xml @@ -27,7 +27,8 @@ TomEE :: TCK :: Concurrency Signature Tests - 3.0.4 + 3.1.1 + 3.1.0 2.3 @@ -35,7 +36,7 @@ jakarta.enterprise.concurrent jakarta.enterprise.concurrent-tck - ${jakarta.concurrent.version} + ${jakarta.concurrent.tck.version} test @@ -44,9 +45,12 @@ ${sigtest.version} provided + - org.testng - testng + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test org.slf4j @@ -58,10 +62,10 @@ slf4j-jdk14 test - + - org.jboss.arquillian.testng - arquillian-testng-container + org.jboss.arquillian.junit5 + arquillian-junit5-container ${version.arquillian} test @@ -164,4 +168,4 @@ - \ No newline at end of file + diff --git a/tck/concurrency-signature-test/src/test/resources/arquillian.xml b/tck/concurrency-signature-test/src/test/resources/arquillian.xml index 7d1d366f87c..4014613c741 100644 --- a/tck/concurrency-signature-test/src/test/resources/arquillian.xml +++ b/tck/concurrency-signature-test/src/test/resources/arquillian.xml @@ -31,7 +31,6 @@ jimage.dir=${project.build.directory}/jimage - mvn:org.testng:testng:7.5:jar mvn:jakarta.tck:sigtest-maven-plugin:2.3:jar --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED --add-opens java.base/jdk.internal.vm.annotation=ALL-UNNAMED diff --git a/tck/concurrency-standalone/dev.xml b/tck/concurrency-standalone/dev.xml deleted file mode 100644 index 606eb1d9fd8..00000000000 --- a/tck/concurrency-standalone/dev.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - diff --git a/tck/concurrency-standalone/pom.xml b/tck/concurrency-standalone/pom.xml index d8dd31dd29b..09b5afb9071 100644 --- a/tck/concurrency-standalone/pom.xml +++ b/tck/concurrency-standalone/pom.xml @@ -33,20 +33,13 @@ 17 - 3.0.4 - 6.0.0 - 7.5.1 - 1.6 - 3.10.0 - 3.15.0 - 2.22.2 - - suite-web.xml + 3.1.1 + 3.1.0 + 6.1.0 ${project.basedir}/target - @@ -66,22 +59,28 @@ jakarta.enterprise.concurrent jakarta.enterprise.concurrent-tck - ${jakarta.concurrent.version} + ${jakarta.concurrent.tck.version} jakarta.enterprise.concurrent jakarta.enterprise.concurrent-api - ${jakarta.concurrent.version} + ${jakarta.concurrent.api.version} - + - org.jboss.arquillian.testng - arquillian-testng-container - ${version.arquillian} + org.junit.jupiter + junit-jupiter + ${junit.jupiter.version} + test + + + + org.jboss.arquillian.junit5 + arquillian-junit5-container + test - ${project.groupId} apache-tomee @@ -96,19 +95,18 @@ ${project.version} test - - jakarta.servlet jakarta.servlet-api ${jakarta.servlet.version} - + - org.testng - testng - ${testng.version} + jakarta.tck + sigtest-maven-plugin + 2.3 + test @@ -131,7 +129,6 @@ - @@ -143,14 +140,8 @@ org.apache.maven.plugins maven-dependency-plugin - ${maven.dep.plugin.version} - - org.testng - testng - ${testng.version} - org.apache.derby derby @@ -162,7 +153,6 @@ org.apache.maven.plugins maven-compiler-plugin - ${maven.comp.plugin.version} ${maven.compiler.source} ${maven.compiler.target} @@ -171,15 +161,29 @@ org.apache.maven.plugins maven-surefire-plugin - ${maven.surefire.plugin.version} + ${surefire.version} + true + 1 + false + false + + jakarta.enterprise.concurrent:jakarta.enterprise.concurrent-tck + + + ee/jakarta/tck/concurrent/api/** + ee/jakarta/tck/concurrent/spec/** + + + ee/jakarta/tck/concurrent/spec/signature/** + + !eefull + src/test/resources/logging.properties + ${project.build.directory}/jimage - - ${suiteXmlFile} - - ${basedir}${file.separarator}src${file.separarator}main${file.separarator}java${file.separarator} + ${basedir}${file.separator}src${file.separator}main${file.separator}java${file.separator} @@ -187,4 +191,4 @@ - \ No newline at end of file + diff --git a/tck/concurrency-standalone/src/test/resources/arquillian.xml b/tck/concurrency-standalone/src/test/resources/arquillian.xml index 06ee1dea08f..398d3e6aaca 100644 --- a/tck/concurrency-standalone/src/test/resources/arquillian.xml +++ b/tck/concurrency-standalone/src/test/resources/arquillian.xml @@ -21,28 +21,7 @@ xsi:schemaLocation=" http://jboss.org/schema/arquillian http://jboss.org/schema/arquillian/arquillian_1_0.xsd"> - - - -1 - -1 - -1 - microprofile - false - src/test/conf - false - - mvn:org.apache.derby:derby:10.15.2.0 - mvn:org.apache.derby:derbytools:10.15.2.0 - mvn:org.apache.derby:derbyshared:10.15.2.0 - mvn:org.testng:testng:7.5 - - target/tomee - - openejb.environment.default=true - - - - + -1 -1 @@ -50,12 +29,11 @@ plus false src/test/conf - false + true mvn:org.apache.derby:derby:10.15.2.0 mvn:org.apache.derby:derbytools:10.15.2.0 mvn:org.apache.derby:derbyshared:10.15.2.0 - mvn:org.testng:testng:7.5 target/tomee @@ -71,12 +49,11 @@ plume false src/test/conf - false + true mvn:org.apache.derby:derby:10.15.2.0 mvn:org.apache.derby:derbytools:10.15.2.0 mvn:org.apache.derby:derbyshared:10.15.2.0 - mvn:org.testng:testng:7.5 target/tomee @@ -92,12 +69,11 @@ webprofile false src/test/conf - false + true mvn:org.apache.derby:derby:10.15.2.0 mvn:org.apache.derby:derbytools:10.15.2.0 mvn:org.apache.derby:derbyshared:10.15.2.0 - mvn:org.testng:testng:7.5 target/tomee diff --git a/tck/concurrency-standalone/src/test/resources/logging.properties b/tck/concurrency-standalone/src/test/resources/logging.properties new file mode 100644 index 00000000000..4969cd0964b --- /dev/null +++ b/tck/concurrency-standalone/src/test/resources/logging.properties @@ -0,0 +1,21 @@ +## Logging configuration for Concurrency TCK + +handlers = java.util.logging.FileHandler, java.util.logging.ConsoleHandler + +.level = WARNING + +## Concurrency TCK logger +ee.jakarta.tck.concurrent.level = ALL + +## File handler +java.util.logging.FileHandler.level = CONFIG +java.util.logging.FileHandler.pattern = target/ConcurrentTCK%g%u.log +java.util.logging.FileHandler.limit = 500000 +java.util.logging.FileHandler.count = 5 +java.util.logging.FileHandler.formatter = java.util.logging.SimpleFormatter + +## Console handler +java.util.logging.ConsoleHandler.level = WARNING +java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter + +java.util.logging.SimpleFormatter.format = [%1$tF %1$tT] %4$.1s %3$s %5$s %n diff --git a/tck/concurrency-standalone/suite-web.xml b/tck/concurrency-standalone/suite-web.xml deleted file mode 100644 index fea9a5434e4..00000000000 --- a/tck/concurrency-standalone/suite-web.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/tck/concurrency-standalone/suite.xml b/tck/concurrency-standalone/suite.xml deleted file mode 100644 index 6a76c97712b..00000000000 --- a/tck/concurrency-standalone/suite.xml +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file From e9c554d6a9381722d6c6266873e4268ecd29d15a Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 10:06:55 +0200 Subject: [PATCH 02/20] Implement @Asynchronous(runAt=@Schedule(...)) for Jakarta Concurrency 3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add support for scheduled recurring async methods as defined in Jakarta Concurrency 3.1. This addresses the largest block of TCK failures (28 tests) by enabling cron-based scheduling of CDI @Asynchronous methods via the new runAt attribute. - ScheduleHelper: maps @Schedule annotations to API-provided CronTrigger, supports composite triggers (multiple schedules) and skipIfLateBy wrapping - AsynchronousInterceptor: branches on runAt presence — one-shot path unchanged, new scheduled path uses ManagedScheduledExecutorService - ManagedScheduledExecutorServiceImplFactory: adds lookup() with graceful fallback matching the ManagedExecutorServiceImplFactory pattern --- .gitignore | 6 +- .../ScheduledAsynchronousTest.java | 95 +++++++ .../concurrency/AsynchronousInterceptor.java | 94 ++++++- .../cdi/concurrency/ScheduleHelper.java | 191 +++++++++++++ ...edScheduledExecutorServiceImplFactory.java | 46 ++++ .../AsynchronousScheduledTest.java | 101 +++++++ .../cdi/concurrency/ScheduleHelperTest.java | 258 ++++++++++++++++++ 7 files changed, 780 insertions(+), 11 deletions(-) create mode 100644 arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsynchronousTest.java create mode 100644 container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduleHelperTest.java diff --git a/.gitignore b/.gitignore index 76c49852051..4bf34949bc4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,4 +21,8 @@ tck/**/temp examples/jaxrs-json-provider-jettison/temp/ transformer/jakartaee-prototype/ transformer/transformer-0.1.0-SNAPSHOT/ -*.zip \ No newline at end of file +*.zip + +CLAUDE.md +.claude +tck-dev \ No newline at end of file diff --git a/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsynchronousTest.java b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsynchronousTest.java new file mode 100644 index 00000000000..0466b9a5bba --- /dev/null +++ b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsynchronousTest.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.arquillian.tests.concurrency; + +import jakarta.enterprise.concurrent.Asynchronous; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.shrinkwrap.api.ArchivePaths; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertTrue; + +/** + * Arquillian integration test for {@code @Asynchronous(runAt = @Schedule(...))} + * — the scheduled recurring async method feature introduced in Jakarta Concurrency 3.1. + */ +@RunWith(Arquillian.class) +public class ScheduledAsynchronousTest { + + @Inject + private ScheduledBean scheduledBean; + + @Deployment + public static WebArchive createDeployment() { + return ShrinkWrap.create(WebArchive.class, "ScheduledAsynchronousTest.war") + .addClasses(ScheduledBean.class) + .addAsWebInfResource(EmptyAsset.INSTANCE, ArchivePaths.create("beans.xml")); + } + + @Test + public void scheduledVoidMethodExecutesRepeatedly() throws Exception { + scheduledBean.everySecondVoid(); + + final boolean reached = ScheduledBean.VOID_LATCH.await(10, TimeUnit.SECONDS); + assertTrue("Scheduled void method should have been invoked at least 3 times, count: " + + ScheduledBean.VOID_COUNTER.get(), reached); + } + + @Test + public void scheduledReturningMethodExecutes() throws Exception { + final CompletableFuture future = scheduledBean.everySecondReturning(); + + final boolean reached = ScheduledBean.RETURNING_LATCH.await(10, TimeUnit.SECONDS); + assertTrue("Scheduled returning method should have been invoked, count: " + + ScheduledBean.RETURNING_COUNTER.get(), reached); + } + + @ApplicationScoped + public static class ScheduledBean { + static final AtomicInteger VOID_COUNTER = new AtomicInteger(); + static final CountDownLatch VOID_LATCH = new CountDownLatch(3); + + static final AtomicInteger RETURNING_COUNTER = new AtomicInteger(); + static final CountDownLatch RETURNING_LATCH = new CountDownLatch(1); + + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public void everySecondVoid() { + VOID_COUNTER.incrementAndGet(); + VOID_LATCH.countDown(); + } + + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture everySecondReturning() { + RETURNING_COUNTER.incrementAndGet(); + RETURNING_LATCH.countDown(); + return Asynchronous.Result.complete("done"); + } + } +} diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 04f6c46773d..d896e9eaf2c 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -19,16 +19,23 @@ import jakarta.annotation.Priority; import jakarta.enterprise.concurrent.Asynchronous; import jakarta.enterprise.concurrent.ManagedExecutorService; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.concurrent.ZonedTrigger; import jakarta.interceptor.AroundInvoke; import jakarta.interceptor.Interceptor; import jakarta.interceptor.InvocationContext; import org.apache.openejb.core.ivm.naming.NamingException; import org.apache.openejb.resource.thread.ManagedExecutorServiceImplFactory; +import org.apache.openejb.resource.thread.ManagedScheduledExecutorServiceImplFactory; +import org.apache.openejb.util.LogCategory; +import org.apache.openejb.util.Logger; import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.util.Arrays; import java.util.Map; +import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; @@ -38,6 +45,8 @@ @Asynchronous @Priority(Interceptor.Priority.PLATFORM_BEFORE + 5) public class AsynchronousInterceptor { + private static final Logger LOGGER = Logger.getInstance(LogCategory.OPENEJB, AsynchronousInterceptor.class); + public static final String MP_ASYNC_ANNOTATION_NAME = "org.eclipse.microprofile.faulttolerance.Asynchronous"; // ensure validation logic required by the spec only runs once per invoked Method @@ -45,24 +54,34 @@ public class AsynchronousInterceptor { @AroundInvoke public Object aroundInvoke(final InvocationContext ctx) throws Exception { - Exception exception = validationCache.computeIfAbsent(ctx.getMethod(), this::validate); + final Exception exception = validationCache.computeIfAbsent(ctx.getMethod(), this::validate); if (exception != null) { throw exception; } - Asynchronous asynchronous = ctx.getMethod().getAnnotation(Asynchronous.class); - ManagedExecutorService mes; + final Asynchronous asynchronous = ctx.getMethod().getAnnotation(Asynchronous.class); + final Schedule[] schedules = asynchronous.runAt(); + + if (schedules.length > 0) { + return aroundInvokeScheduled(ctx, asynchronous, schedules); + } + + return aroundInvokeOneShot(ctx, asynchronous); + } + + private Object aroundInvokeOneShot(final InvocationContext ctx, final Asynchronous asynchronous) throws Exception { + final ManagedExecutorService mes; try { mes = ManagedExecutorServiceImplFactory.lookup(asynchronous.executor()); - } catch (NamingException | IllegalArgumentException e) { + } catch (final NamingException | IllegalArgumentException e) { throw new RejectedExecutionException("Cannot lookup ManagedExecutorService", e); } - CompletableFuture future = mes.newIncompleteFuture(); + final CompletableFuture future = mes.newIncompleteFuture(); mes.execute(() -> { try { Asynchronous.Result.setFuture(future); - CompletionStage result = (CompletionStage) ctx.proceed(); + final CompletionStage result = (CompletionStage) ctx.proceed(); if (result == null || result == future) { future.complete(result); @@ -79,7 +98,7 @@ public Object aroundInvoke(final InvocationContext ctx) throws Exception { Asynchronous.Result.setFuture(null); }); - } catch (Exception e) { + } catch (final Exception e) { future.completeExceptionally(e); Asynchronous.Result.setFuture(null); } @@ -88,18 +107,73 @@ public Object aroundInvoke(final InvocationContext ctx) throws Exception { return ctx.getMethod().getReturnType() == Void.TYPE ? null : future; } + private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchronous asynchronous, + final Schedule[] schedules) throws Exception { + final ManagedScheduledExecutorService mses; + try { + mses = ManagedScheduledExecutorServiceImplFactory.lookup(asynchronous.executor()); + } catch (final IllegalArgumentException e) { + throw new RejectedExecutionException("Cannot lookup ManagedScheduledExecutorService", e); + } + + final ZonedTrigger trigger = ScheduleHelper.toTrigger(schedules); + final boolean isVoid = ctx.getMethod().getReturnType() == Void.TYPE; + + if (isVoid) { + // void method: schedule as Runnable, runs indefinitely until cancelled + mses.schedule((Runnable) () -> { + try { + ctx.proceed(); + } catch (final Exception e) { + LOGGER.warning("Scheduled async method threw exception", e); + } + }, trigger); + return null; + } + + // non-void: schedule as Callable, each invocation gets a fresh future via Asynchronous.Result + final CompletableFuture outerFuture = mses.newIncompleteFuture(); + + mses.schedule((Callable) () -> { + try { + Asynchronous.Result.setFuture(outerFuture); + final Object result = ctx.proceed(); + + if (result instanceof CompletionStage cs) { + cs.whenComplete((val, err) -> { + if (err != null) { + outerFuture.completeExceptionally(err); + } else if (val != null) { + outerFuture.complete(val); + } + Asynchronous.Result.setFuture(null); + }); + } else if (result != null && result != outerFuture) { + outerFuture.complete(result); + Asynchronous.Result.setFuture(null); + } + } catch (final Exception e) { + outerFuture.completeExceptionally(e); + Asynchronous.Result.setFuture(null); + } + return null; + }, trigger); + + return outerFuture; + } + private Exception validate(final Method method) { if (hasMpAsyncAnnotation(method.getAnnotations()) || hasMpAsyncAnnotation(method.getDeclaringClass().getAnnotations())) { return new UnsupportedOperationException("Combining " + Asynchronous.class.getName() + " and " + MP_ASYNC_ANNOTATION_NAME + " on the same method/class is not supported"); } - Asynchronous asynchronous = method.getAnnotation(Asynchronous.class); + final Asynchronous asynchronous = method.getAnnotation(Asynchronous.class); if (asynchronous == null) { return new UnsupportedOperationException("Asynchronous annotation must be placed on a method"); } - Class returnType = method.getReturnType(); + final Class returnType = method.getReturnType(); if (returnType != Void.TYPE && returnType != CompletableFuture.class && returnType != CompletionStage.class) { return new UnsupportedOperationException("Asynchronous annotation must be placed on a method that returns either void, CompletableFuture or CompletionStage"); } @@ -107,7 +181,7 @@ private Exception validate(final Method method) { return null; } - private boolean hasMpAsyncAnnotation(Annotation[] declaredAnnotations) { + private boolean hasMpAsyncAnnotation(final Annotation[] declaredAnnotations) { return Arrays.stream(declaredAnnotations) .map(it -> it.annotationType().getName()) .anyMatch(it -> it.equals(MP_ASYNC_ANNOTATION_NAME)); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java new file mode 100644 index 00000000000..f1752ca2904 --- /dev/null +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java @@ -0,0 +1,191 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.cdi.concurrency; + +import jakarta.enterprise.concurrent.CronTrigger; +import jakarta.enterprise.concurrent.LastExecution; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.concurrent.ZonedTrigger; + +import java.time.DayOfWeek; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.Arrays; + +/** + * Maps {@link Schedule} annotations to the API-provided {@link CronTrigger}. + * Similar design pattern to {@link org.apache.openejb.core.timer.EJBCronTrigger} + * which maps EJB {@code @Schedule} to Quartz triggers. Here the API JAR provides + * the cron parsing — we just bridge the annotation attributes. + */ +public final class ScheduleHelper { + + private ScheduleHelper() { + // utility + } + + /** + * Converts a single {@link Schedule} annotation to a {@link CronTrigger}. + * If {@link Schedule#cron()} is non-empty, uses the cron expression directly. + * Otherwise builds the trigger from individual field attributes. + */ + public static CronTrigger toCronTrigger(final Schedule schedule) { + final ZoneId zone = schedule.zone().isEmpty() + ? ZoneId.systemDefault() + : ZoneId.of(schedule.zone()); + + final String cron = schedule.cron(); + if (!cron.isEmpty()) { + return new CronTrigger(cron, zone); + } + + final CronTrigger trigger = new CronTrigger(zone); + + if (schedule.months().length > 0) { + trigger.months(toMonths(schedule.months())); + } + if (schedule.daysOfMonth().length > 0) { + trigger.daysOfMonth(schedule.daysOfMonth()); + } + if (schedule.daysOfWeek().length > 0) { + trigger.daysOfWeek(toDaysOfWeek(schedule.daysOfWeek())); + } + if (schedule.hours().length > 0) { + trigger.hours(schedule.hours()); + } + if (schedule.minutes().length > 0) { + trigger.minutes(schedule.minutes()); + } + if (schedule.seconds().length > 0) { + trigger.seconds(schedule.seconds()); + } + + return trigger; + } + + /** + * Converts one or more {@link Schedule} annotations to a {@link ZonedTrigger}. + * A single schedule returns a potentially wrapped {@link CronTrigger}. + * Multiple schedules return a {@link CompositeScheduleTrigger} that picks the + * earliest next run time. + * + *

The returned trigger includes {@code skipIfLateBy} logic when configured.

+ */ + public static ZonedTrigger toTrigger(final Schedule[] schedules) { + if (schedules.length == 1) { + return wrapWithSkipIfLate(toCronTrigger(schedules[0]), schedules[0].skipIfLateBy()); + } + + final ZonedTrigger[] triggers = new ZonedTrigger[schedules.length]; + for (int i = 0; i < schedules.length; i++) { + triggers[i] = wrapWithSkipIfLate(toCronTrigger(schedules[i]), schedules[i].skipIfLateBy()); + } + return new CompositeScheduleTrigger(triggers); + } + + private static ZonedTrigger wrapWithSkipIfLate(final CronTrigger trigger, final long skipIfLateBy) { + if (skipIfLateBy <= 0) { + return trigger; + } + return new SkipIfLateTrigger(trigger, skipIfLateBy); + } + + private static Month[] toMonths(final Month[] months) { + return months; + } + + private static DayOfWeek[] toDaysOfWeek(final DayOfWeek[] days) { + return days; + } + + /** + * Wraps a {@link ZonedTrigger} to skip executions that are late by more than + * the configured threshold (in seconds). Per the spec, the default is 600 seconds. + */ + static class SkipIfLateTrigger implements ZonedTrigger { + private final ZonedTrigger delegate; + private final long skipIfLateBySeconds; + + SkipIfLateTrigger(final ZonedTrigger delegate, final long skipIfLateBySeconds) { + this.delegate = delegate; + this.skipIfLateBySeconds = skipIfLateBySeconds; + } + + @Override + public ZonedDateTime getNextRunTime(final LastExecution lastExecution, final ZonedDateTime taskScheduledTime) { + return delegate.getNextRunTime(lastExecution, taskScheduledTime); + } + + @Override + public ZoneId getZoneId() { + return delegate.getZoneId(); + } + + @Override + public boolean skipRun(final LastExecution lastExecution, final ZonedDateTime scheduledRunTime) { + if (delegate.skipRun(lastExecution, scheduledRunTime)) { + return true; + } + + final ZonedDateTime now = ZonedDateTime.now(getZoneId()); + final long lateBySeconds = java.time.Duration.between(scheduledRunTime, now).getSeconds(); + return lateBySeconds > skipIfLateBySeconds; + } + } + + /** + * Combines multiple {@link ZonedTrigger} instances, picking the earliest + * next run time from all delegates. Used when multiple {@link Schedule} + * annotations are present on a single method. + */ + static class CompositeScheduleTrigger implements ZonedTrigger { + private final ZonedTrigger[] delegates; + + CompositeScheduleTrigger(final ZonedTrigger[] delegates) { + this.delegates = Arrays.copyOf(delegates, delegates.length); + } + + @Override + public ZonedDateTime getNextRunTime(final LastExecution lastExecution, final ZonedDateTime taskScheduledTime) { + ZonedDateTime earliest = null; + for (final ZonedTrigger delegate : delegates) { + final ZonedDateTime next = delegate.getNextRunTime(lastExecution, taskScheduledTime); + if (next != null && (earliest == null || next.isBefore(earliest))) { + earliest = next; + } + } + return earliest; + } + + @Override + public ZoneId getZoneId() { + return delegates[0].getZoneId(); + } + + @Override + public boolean skipRun(final LastExecution lastExecution, final ZonedDateTime scheduledRunTime) { + // skip only if ALL delegates would skip + for (final ZonedTrigger delegate : delegates) { + if (!delegate.skipRun(lastExecution, scheduledRunTime)) { + return false; + } + } + return true; + } + } +} diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java index 771b160e205..4e91b81a07d 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java @@ -16,6 +16,8 @@ */ package org.apache.openejb.resource.thread; +import org.apache.openejb.loader.SystemInstance; +import org.apache.openejb.spi.ContainerSystem; import org.apache.openejb.threads.impl.ContextServiceImpl; import org.apache.openejb.threads.impl.ContextServiceImplFactory; import org.apache.openejb.threads.impl.ManagedScheduledExecutorServiceImpl; @@ -24,12 +26,56 @@ import org.apache.openejb.util.LogCategory; import org.apache.openejb.util.Logger; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; import jakarta.enterprise.concurrent.ManagedThreadFactory; +import javax.naming.Context; +import javax.naming.InitialContext; +import javax.naming.NamingException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledThreadPoolExecutor; public class ManagedScheduledExecutorServiceImplFactory { + + private static final Logger LOGGER = Logger.getInstance(LogCategory.OPENEJB, ManagedScheduledExecutorServiceImplFactory.class); + + public static ManagedScheduledExecutorServiceImpl lookup(String name) { + // If the caller passes the default ManagedExecutorService JNDI name, map it to the + // default ManagedScheduledExecutorService instead + if ("java:comp/DefaultManagedExecutorService".equals(name)) { + name = "java:comp/DefaultManagedScheduledExecutorService"; + } + + // Try direct JNDI lookup first + try { + final Object obj = InitialContext.doLookup(name); + if (obj instanceof ManagedScheduledExecutorServiceImpl mses) { + return mses; + } + } catch (final NamingException ignored) { + // fall through to container JNDI + } + + // Try container JNDI with resource ID + try { + final Context ctx = SystemInstance.get().getComponent(ContainerSystem.class).getJNDIContext(); + final String resourceId = "java:comp/DefaultManagedScheduledExecutorService".equals(name) + ? "Default Scheduled Executor Service" + : name; + + final Object obj = ctx.lookup("openejb/Resource/" + resourceId); + if (obj instanceof ManagedScheduledExecutorServiceImpl mses) { + return mses; + } + } catch (final NamingException ignored) { + // fall through to default creation + } + + // Graceful fallback: create a default instance + LOGGER.debug("Cannot lookup ManagedScheduledExecutorService '" + name + "', creating default instance"); + return new ManagedScheduledExecutorServiceImplFactory().create(); + } + private int core = 5; private String threadFactory = ManagedThreadFactoryImpl.class.getName(); diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java new file mode 100644 index 00000000000..5e9c25cb8a4 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java @@ -0,0 +1,101 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.cdi.concurrency; + +import jakarta.enterprise.concurrent.Asynchronous; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.apache.openejb.jee.EnterpriseBean; +import org.apache.openejb.jee.SingletonBean; +import org.apache.openejb.junit.ApplicationComposer; +import org.apache.openejb.testing.Module; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertTrue; + +@RunWith(ApplicationComposer.class) +public class AsynchronousScheduledTest { + + @Inject + private ScheduledBean scheduledBean; + + @Module + public EnterpriseBean ejb() { + // Dummy EJB to trigger full resource deployment including default concurrency resources + return new SingletonBean(DummyEjb.class).localBean(); + } + + @Module + public Class[] beans() { + return new Class[]{ScheduledBean.class}; + } + + @Test + public void scheduledVoidMethodExecutesRepeatedly() throws Exception { + // Call the method once — the interceptor sets up the recurring schedule + scheduledBean.everySecondVoid(); + + // Wait for at least 3 invocations + final boolean reached = ScheduledBean.VOID_LATCH.await(10, TimeUnit.SECONDS); + assertTrue("Scheduled void method should have been invoked at least 3 times, count: " + + ScheduledBean.VOID_COUNTER.get(), reached); + } + + @Test + public void scheduledReturningMethodExecutes() throws Exception { + // Call the method once — the interceptor sets up the recurring schedule + final CompletableFuture future = scheduledBean.everySecondReturning(); + + // Wait for at least 1 invocation + final boolean reached = ScheduledBean.RETURNING_LATCH.await(10, TimeUnit.SECONDS); + assertTrue("Scheduled returning method should have been invoked, count: " + + ScheduledBean.RETURNING_COUNTER.get(), reached); + } + + @ApplicationScoped + public static class ScheduledBean { + static final AtomicInteger VOID_COUNTER = new AtomicInteger(); + static final CountDownLatch VOID_LATCH = new CountDownLatch(3); + + static final AtomicInteger RETURNING_COUNTER = new AtomicInteger(); + static final CountDownLatch RETURNING_LATCH = new CountDownLatch(1); + + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public void everySecondVoid() { + VOID_COUNTER.incrementAndGet(); + VOID_LATCH.countDown(); + } + + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture everySecondReturning() { + RETURNING_COUNTER.incrementAndGet(); + RETURNING_LATCH.countDown(); + return Asynchronous.Result.complete("done"); + } + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduleHelperTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduleHelperTest.java new file mode 100644 index 00000000000..05930bbf865 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduleHelperTest.java @@ -0,0 +1,258 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.cdi.concurrency; + +import jakarta.enterprise.concurrent.CronTrigger; +import jakarta.enterprise.concurrent.LastExecution; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.concurrent.ZonedTrigger; +import org.junit.Test; + +import java.lang.annotation.Annotation; +import java.time.DayOfWeek; +import java.time.Month; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class ScheduleHelperTest { + + @Test + public void cronExpressionTrigger() { + final Schedule schedule = scheduleWithCron("* * * * *", ""); + final CronTrigger trigger = ScheduleHelper.toCronTrigger(schedule); + + assertNotNull(trigger); + final ZonedDateTime next = trigger.getNextRunTime(null, ZonedDateTime.now()); + assertNotNull("CronTrigger should compute a next run time", next); + assertTrue("Next run time should be in the future or now", + !next.isBefore(ZonedDateTime.now().minusSeconds(1))); + } + + @Test + public void cronExpressionWithZone() { + final Schedule schedule = scheduleWithCron("0 12 * * MON-FRI", "America/New_York"); + final CronTrigger trigger = ScheduleHelper.toCronTrigger(schedule); + + assertNotNull(trigger); + assertNotNull(trigger.getZoneId()); + } + + @Test + public void builderStyleTrigger() { + final Schedule schedule = scheduleWithFields( + new Month[]{}, new int[]{}, new DayOfWeek[]{}, + new int[]{}, new int[]{0}, new int[]{0}, + "", 600 + ); + final CronTrigger trigger = ScheduleHelper.toCronTrigger(schedule); + + assertNotNull(trigger); + final ZonedDateTime next = trigger.getNextRunTime(null, ZonedDateTime.now()); + assertNotNull("Builder-style trigger should compute a next run time", next); + } + + @Test + public void singleScheduleToTrigger() { + final Schedule schedule = scheduleWithCron("* * * * *", ""); + final ZonedTrigger trigger = ScheduleHelper.toTrigger(new Schedule[]{schedule}); + + assertNotNull(trigger); + final ZonedDateTime next = trigger.getNextRunTime(null, ZonedDateTime.now()); + assertNotNull(next); + } + + @Test + public void compositeSchedulePicksEarliest() { + // every minute vs every hour — composite should pick the every-minute one + final Schedule everyMinute = scheduleWithCron("* * * * *", ""); + final Schedule everyHour = scheduleWithCron("0 * * * *", ""); + + final ZonedTrigger trigger = ScheduleHelper.toTrigger(new Schedule[]{everyMinute, everyHour}); + assertNotNull(trigger); + + final ZonedDateTime next = trigger.getNextRunTime(null, ZonedDateTime.now()); + assertNotNull("Composite trigger should return a next run time", next); + + // the composite should return the nearest time (every minute) + final ZonedDateTime everyMinuteNext = new CronTrigger("* * * * *", ZoneId.systemDefault()) + .getNextRunTime(null, ZonedDateTime.now()); + assertTrue("Composite should pick the earlier schedule", + !next.isAfter(everyMinuteNext.plusSeconds(1))); + } + + @Test + public void skipIfLateBySkipsLateExecution() { + final Schedule schedule = scheduleWithCron("* * * * *", "", 1); // 1 second threshold + final ZonedTrigger trigger = ScheduleHelper.toTrigger(new Schedule[]{schedule}); + + // Simulate a scheduled run time that was 10 seconds ago + final ZonedDateTime pastScheduledTime = ZonedDateTime.now().minusSeconds(10); + final boolean shouldSkip = trigger.skipRun(null, pastScheduledTime); + assertTrue("Should skip execution that is late by more than threshold", shouldSkip); + } + + @Test + public void skipIfLateByAllowsOnTimeExecution() { + final Schedule schedule = scheduleWithCron("* * * * *", "", 600); // 600 second threshold + final ZonedTrigger trigger = ScheduleHelper.toTrigger(new Schedule[]{schedule}); + + // Simulate a scheduled run time that is now + final ZonedDateTime now = ZonedDateTime.now(); + final boolean shouldSkip = trigger.skipRun(null, now); + assertFalse("Should not skip execution that is on time", shouldSkip); + } + + @Test + public void zeroSkipIfLateByReturnsUnwrappedTrigger() { + final Schedule schedule = scheduleWithCron("* * * * *", "", 0); + final ZonedTrigger trigger = ScheduleHelper.toTrigger(new Schedule[]{schedule}); + + // With skipIfLateBy=0, should get a plain CronTrigger (no wrapping) + assertTrue("Zero skipIfLateBy should return CronTrigger directly", + trigger instanceof CronTrigger); + } + + @Test + public void defaultZoneUsedWhenEmpty() { + final Schedule schedule = scheduleWithCron("* * * * *", ""); + final CronTrigger trigger = ScheduleHelper.toCronTrigger(schedule); + + assertNotNull(trigger.getZoneId()); + } + + // --- Annotation stubs --- + + private static Schedule scheduleWithCron(final String cron, final String zone) { + return scheduleWithCron(cron, zone, 600); + } + + private static Schedule scheduleWithCron(final String cron, final String zone, final long skipIfLateBy) { + return new Schedule() { + @Override + public Class annotationType() { + return Schedule.class; + } + + @Override + public String cron() { + return cron; + } + + @Override + public Month[] months() { + return new Month[0]; + } + + @Override + public int[] daysOfMonth() { + return new int[0]; + } + + @Override + public DayOfWeek[] daysOfWeek() { + return new DayOfWeek[0]; + } + + @Override + public int[] hours() { + return new int[0]; + } + + @Override + public int[] minutes() { + return new int[0]; + } + + @Override + public int[] seconds() { + return new int[0]; + } + + @Override + public long skipIfLateBy() { + return skipIfLateBy; + } + + @Override + public String zone() { + return zone; + } + }; + } + + private static Schedule scheduleWithFields(final Month[] months, final int[] daysOfMonth, + final DayOfWeek[] daysOfWeek, final int[] hours, + final int[] minutes, final int[] seconds, + final String zone, final long skipIfLateBy) { + return new Schedule() { + @Override + public Class annotationType() { + return Schedule.class; + } + + @Override + public String cron() { + return ""; + } + + @Override + public Month[] months() { + return months; + } + + @Override + public int[] daysOfMonth() { + return daysOfMonth; + } + + @Override + public DayOfWeek[] daysOfWeek() { + return daysOfWeek; + } + + @Override + public int[] hours() { + return hours; + } + + @Override + public int[] minutes() { + return minutes; + } + + @Override + public int[] seconds() { + return seconds; + } + + @Override + public long skipIfLateBy() { + return skipIfLateBy; + } + + @Override + public String zone() { + return zone; + } + }; + } +} From ffe731c2e5e045bd44b3885dc9a4c3540be0999d Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 10:29:42 +0200 Subject: [PATCH 03/20] Add virtual thread support and DD virtual attribute for Concurrency 3.1 Implement opt-in virtual thread support (Java 21+) across ManagedThreadFactory, ManagedExecutorService, and ManagedScheduledExecutorService as required by Jakarta Concurrency 3.1. Runtime: - VirtualThreadHelper: reflection-based Java 21 API access, graceful fallback on Java 17 (isSupported check, no multi-release JARs) - ManagedThreadFactoryImpl: virtual path creates threads that do NOT implement ManageableThread (spec 3.4.4) - All three factory classes accept virtual property Deployment descriptor + annotation processing: - Boolean virtual field added to DD model classes in openejb-jee - Convert*Definitions pass virtual to Resource properties - AnnotationDeployer reads virtual() from @Managed*Definition annotations Tests skip gracefully on Java <21 via Assume.assumeTrue. --- .../tests/concurrency/VirtualThreadTest.java | 132 ++++++++++++++++ .../openejb/config/AnnotationDeployer.java | 3 + ...vertManagedExecutorServiceDefinitions.java | 1 + ...edScheduledExecutorServiceDefinitions.java | 1 + ...onvertManagedThreadFactoryDefinitions.java | 1 + .../ManagedExecutorServiceImplFactory.java | 16 ++ ...edScheduledExecutorServiceImplFactory.java | 11 +- .../ManagedThreadFactoryImplFactory.java | 11 +- .../impl/ManagedThreadFactoryImpl.java | 27 +++- .../threads/impl/VirtualThreadHelper.java | 149 ++++++++++++++++++ .../config/ConvertVirtualDefinitionsTest.java | 141 +++++++++++++++++ .../impl/ManagedThreadFactoryVirtualTest.java | 114 ++++++++++++++ .../threads/impl/VirtualThreadHelperTest.java | 117 ++++++++++++++ .../apache/openejb/jee/ManagedExecutor.java | 11 ++ .../openejb/jee/ManagedScheduledExecutor.java | 11 ++ .../openejb/jee/ManagedThreadFactory.java | 11 ++ 16 files changed, 753 insertions(+), 4 deletions(-) create mode 100644 arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/VirtualThreadTest.java create mode 100644 container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/threads/impl/VirtualThreadHelperTest.java diff --git a/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/VirtualThreadTest.java b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/VirtualThreadTest.java new file mode 100644 index 00000000000..a200d0aa96d --- /dev/null +++ b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/VirtualThreadTest.java @@ -0,0 +1,132 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.arquillian.tests.concurrency; + +import jakarta.annotation.Resource; +import jakarta.enterprise.concurrent.ManagedThreadFactory; +import jakarta.enterprise.concurrent.ManagedThreadFactoryDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.shrinkwrap.api.ArchivePaths; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Assume; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Arquillian integration test for virtual thread support in + * {@code @ManagedThreadFactoryDefinition(virtual = true)}. + * Requires Java 21+ — skipped on earlier JVMs. + */ +@RunWith(Arquillian.class) +public class VirtualThreadTest { + + private static boolean isVirtualThreadSupported() { + try { + Thread.class.getMethod("ofVirtual"); + return true; + } catch (final NoSuchMethodException e) { + return false; + } + } + + @Inject + private VirtualThreadBean bean; + + @Deployment + public static WebArchive createDeployment() { + return ShrinkWrap.create(WebArchive.class, "VirtualThreadTest.war") + .addClasses(VirtualThreadBean.class) + .addAsWebInfResource(EmptyAsset.INSTANCE, ArchivePaths.create("beans.xml")); + } + + @Test + public void virtualThreadFactoryCreatesThread() throws Exception { + Assume.assumeTrue("Virtual threads require Java 21+", isVirtualThreadSupported()); + + assertNotNull("VirtualThreadBean should be injected", bean); + final Thread thread = bean.createVirtualThread(); + assertNotNull("Virtual thread should be created", thread); + + // Virtual threads are not ManageableThread (spec 3.4.4) + assertFalse("Virtual thread should NOT implement ManageableThread", + thread instanceof jakarta.enterprise.concurrent.ManageableThread); + } + + @Test + public void virtualThreadExecutesTask() throws Exception { + Assume.assumeTrue("Virtual threads require Java 21+", isVirtualThreadSupported()); + + final boolean completed = bean.runOnVirtualThread(); + assertTrue("Task should complete on virtual thread", completed); + } + + @Test + public void platformThreadFactoryStillWorks() throws Exception { + // This test should always run — verifies non-virtual path is unbroken + assertNotNull("VirtualThreadBean should be injected", bean); + final boolean completed = bean.runOnPlatformThread(); + assertTrue("Task should complete on platform thread", completed); + } + + @ManagedThreadFactoryDefinition( + name = "java:comp/env/concurrent/VirtualThreadFactory", + virtual = true + ) + @ManagedThreadFactoryDefinition( + name = "java:comp/env/concurrent/PlatformThreadFactory", + virtual = false + ) + @ApplicationScoped + public static class VirtualThreadBean { + + @Resource(lookup = "java:comp/env/concurrent/VirtualThreadFactory") + private ManagedThreadFactory virtualFactory; + + @Resource(lookup = "java:comp/env/concurrent/PlatformThreadFactory") + private ManagedThreadFactory platformFactory; + + public Thread createVirtualThread() { + return virtualFactory.newThread(() -> {}); + } + + public boolean runOnVirtualThread() throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final Thread thread = virtualFactory.newThread(latch::countDown); + thread.start(); + return latch.await(5, TimeUnit.SECONDS); + } + + public boolean runOnPlatformThread() throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final Thread thread = platformFactory.newThread(latch::countDown); + thread.start(); + return latch.await(5, TimeUnit.SECONDS); + } + } +} diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java b/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java index d9a21530cc4..a81b9dd55e1 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java @@ -4140,6 +4140,7 @@ private void buildManagedExecutorDefinition(final JndiConsumer consumer, final M managedExecutor.getContextService().setvalue(definition.context()); managedExecutor.setHungTaskThreshold(definition.hungTaskThreshold()); managedExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); + managedExecutor.setVirtual(definition.virtual() ? Boolean.TRUE : null); consumer.getManagedExecutorMap().put(definition.name(), managedExecutor); } @@ -4154,6 +4155,7 @@ private void buildManagedScheduledExecutorDefinition(final JndiConsumer consumer managedScheduledExecutor.getContextService().setvalue(definition.context()); managedScheduledExecutor.setHungTaskThreshold(definition.hungTaskThreshold()); managedScheduledExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); + managedScheduledExecutor.setVirtual(definition.virtual() ? Boolean.TRUE : null); consumer.getManagedScheduledExecutorMap().put(definition.name(), managedScheduledExecutor); } @@ -4167,6 +4169,7 @@ private void buildManagedThreadFactoryDefinition(final JndiConsumer consumer, Ma managedThreadFactory.setContextService(new JndiName()); managedThreadFactory.getContextService().setvalue(definition.context()); managedThreadFactory.setPriority(definition.priority()); + managedThreadFactory.setVirtual(definition.virtual() ? Boolean.TRUE : null); consumer.getManagedThreadFactoryMap().put(definition.name(), managedThreadFactory); } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java index df131b30d9f..d0d9f9575ef 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java @@ -88,6 +88,7 @@ private Resource toResource(final ManagedExecutor managedExecutor) { put(p, "Context", contextName); put(p, "HungTaskThreshold", managedExecutor.getHungTaskThreshold()); put(p, "Max", managedExecutor.getMaxAsync()); + put(p, "Virtual", managedExecutor.getVirtual()); // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java index c06c0383e4c..e3be56fa90f 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java @@ -88,6 +88,7 @@ private Resource toResource(final ManagedScheduledExecutor managedScheduledExecu put(p, "Context", contextName); put(p, "HungTaskThreshold", managedScheduledExecutor.getHungTaskThreshold()); put(p, "Core", managedScheduledExecutor.getMaxAsync()); + put(p, "Virtual", managedScheduledExecutor.getVirtual()); // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java index 95eb0a12c9b..0defa1aa359 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java @@ -87,6 +87,7 @@ private Resource toResource(final ManagedThreadFactory managedThreadFactory) { final Properties p = def.getProperties(); put(p, "Context", contextName); put(p, "Priority", managedThreadFactory.getPriority()); + put(p, "Virtual", managedThreadFactory.getVirtual()); // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java index 7e382f4c759..f895defd7b8 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java @@ -23,6 +23,7 @@ import org.apache.openejb.threads.impl.ContextServiceImplFactory; import org.apache.openejb.threads.impl.ManagedExecutorServiceImpl; import org.apache.openejb.threads.impl.ManagedThreadFactoryImpl; +import org.apache.openejb.threads.impl.VirtualThreadHelper; import org.apache.openejb.threads.reject.CURejectHandler; import org.apache.openejb.util.Duration; import org.apache.openejb.util.LogCategory; @@ -36,6 +37,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; public class ManagedExecutorServiceImplFactory { @@ -44,6 +46,7 @@ public class ManagedExecutorServiceImplFactory { private Duration keepAlive = new Duration("5 second"); private int queue = 15; private String threadFactory; + private boolean virtual; private String context; @@ -78,6 +81,11 @@ public ManagedExecutorServiceImpl create(final ContextServiceImpl contextService } private ExecutorService createExecutorService() { + if (virtual) { + final ThreadFactory vtFactory = VirtualThreadHelper.newVirtualThreadFactory(ManagedThreadFactoryImpl.DEFAULT_PREFIX); + return VirtualThreadHelper.newVirtualThreadPerTaskExecutor(vtFactory); + } + final BlockingQueue blockingQueue; if (queue < 0) { blockingQueue = new LinkedBlockingQueue<>(); @@ -134,4 +142,12 @@ public String getContext() { public void setContext(final String context) { this.context = context; } + + public boolean isVirtual() { + return virtual; + } + + public void setVirtual(final boolean virtual) { + this.virtual = virtual; + } } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java index 4e91b81a07d..20e10de525c 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java @@ -78,6 +78,7 @@ public static ManagedScheduledExecutorServiceImpl lookup(String name) { private int core = 5; private String threadFactory = ManagedThreadFactoryImpl.class.getName(); + private boolean virtual; private String context; @@ -112,7 +113,15 @@ public String getContext() { return context; } - public void setContext(String context) { + public void setContext(final String context) { this.context = context; } + + public boolean isVirtual() { + return virtual; + } + + public void setVirtual(final boolean virtual) { + this.virtual = virtual; + } } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedThreadFactoryImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedThreadFactoryImplFactory.java index b4d7fe8de3c..8676603a071 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedThreadFactoryImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedThreadFactoryImplFactory.java @@ -26,9 +26,10 @@ public class ManagedThreadFactoryImplFactory { private String prefix = "openejb-managed-thread-"; private Integer priority; private String context; + private boolean virtual; public ManagedThreadFactory create() { - return new ManagedThreadFactoryImpl(prefix, priority, ContextServiceImplFactory.lookupOrDefault(context)); + return new ManagedThreadFactoryImpl(prefix, priority, ContextServiceImplFactory.lookupOrDefault(context), virtual); } public void setPrefix(final String prefix) { @@ -42,4 +43,12 @@ public void setPriority(final int priority) { public void setContext(final String context) { this.context = context; } + + public boolean isVirtual() { + return virtual; + } + + public void setVirtual(final boolean virtual) { + this.virtual = virtual; + } } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java index 4524e0b7bee..0fb5b6efc2e 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java @@ -31,22 +31,38 @@ public class ManagedThreadFactoryImpl implements ManagedThreadFactory { private final ContextServiceImpl contextService; private final String prefix; private final Integer priority; + private final boolean virtual; // Invoked by ThreadFactories.findThreadFactory via reflection @SuppressWarnings("unused") public ManagedThreadFactoryImpl() { - this(DEFAULT_PREFIX, Thread.NORM_PRIORITY, ContextServiceImplFactory.getOrCreateDefaultSingleton()); + this(DEFAULT_PREFIX, Thread.NORM_PRIORITY, ContextServiceImplFactory.getOrCreateDefaultSingleton(), false); } public ManagedThreadFactoryImpl(final String prefix, final Integer priority, final ContextServiceImpl contextService) { + this(prefix, priority, contextService, false); + } + + public ManagedThreadFactoryImpl(final String prefix, final Integer priority, final ContextServiceImpl contextService, + final boolean virtual) { this.prefix = prefix; this.priority = priority; this.contextService = contextService; + this.virtual = virtual; } @Override public Thread newThread(final Runnable r) { final CURunnable wrapper = new CURunnable(r, contextService); + + if (virtual) { + // Virtual threads do NOT implement ManageableThread (spec 3.4.4) + // Priority and daemon settings are ignored for virtual threads + final Thread thread = VirtualThreadHelper.newVirtualThread(prefix, ID.incrementAndGet(), wrapper); + thread.setContextClassLoader(ManagedThreadFactoryImpl.class.getClassLoader()); + return thread; + } + final Thread thread = new ManagedThread(wrapper); thread.setDaemon(true); thread.setName(prefix + ID.incrementAndGet()); @@ -59,9 +75,16 @@ public Thread newThread(final Runnable r) { @Override public ForkJoinWorkerThread newThread(final ForkJoinPool pool) { + if (virtual) { + throw new UnsupportedOperationException("Virtual thread factory does not support ForkJoinPool threads"); + } return new ManagedForkJoinWorkerThread(pool, priority, contextService); } + public boolean isVirtual() { + return virtual; + } + public static class ManagedThread extends Thread implements ManageableThread { public ManagedThread(final Runnable r) { super(r); @@ -102,7 +125,7 @@ protected void onStart() { } @Override - protected void onTermination(Throwable exception) { + protected void onTermination(final Throwable exception) { setPriority(initialPriority); contextService.exit(state); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java new file mode 100644 index 00000000000..1e90ad3ef61 --- /dev/null +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java @@ -0,0 +1,149 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.threads.impl; + +import org.apache.openejb.util.LogCategory; +import org.apache.openejb.util.Logger; + +import java.lang.reflect.Method; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadFactory; + +/** + * Reflection-based helper for Java 21+ virtual thread APIs. + * All methods use reflection to avoid compile-time dependency on Java 21. + * On Java 17, {@link #isSupported()} returns {@code false} and the creation + * methods throw {@link UnsupportedOperationException}. + */ +public final class VirtualThreadHelper { + private static final Logger LOGGER = Logger.getInstance(LogCategory.OPENEJB, VirtualThreadHelper.class); + + private static final boolean SUPPORTED; + private static final Method OF_VIRTUAL; + private static final Method BUILDER_NAME; + private static final Method BUILDER_FACTORY; + private static final Method BUILDER_UNSTARTED; + private static final Method EXECUTORS_NEW_THREAD_PER_TASK; + + static { + boolean supported = false; + Method ofVirtual = null; + Method builderName = null; + Method builderFactory = null; + Method builderUnstarted = null; + Method executorsNewThreadPerTask = null; + + try { + ofVirtual = Thread.class.getMethod("ofVirtual"); + final Object builder = ofVirtual.invoke(null); + + // Use the public interface Thread.Builder (not the internal impl class) + // to look up methods — avoids module access issues + final Class builderInterface = Class.forName("java.lang.Thread$Builder"); + final Class ofVirtualInterface = Class.forName("java.lang.Thread$Builder$OfVirtual"); + + // Thread.Builder.OfVirtual.name(String, long) — declared on Builder + builderName = builderInterface.getMethod("name", String.class, long.class); + // Thread.Builder.factory() + builderFactory = builderInterface.getMethod("factory"); + // Thread.Builder.unstarted(Runnable) + builderUnstarted = builderInterface.getMethod("unstarted", Runnable.class); + + // Executors.newThreadPerTaskExecutor(ThreadFactory) + executorsNewThreadPerTask = java.util.concurrent.Executors.class + .getMethod("newThreadPerTaskExecutor", ThreadFactory.class); + + supported = true; + LOGGER.info("Virtual thread support detected (Java 21+)"); + } catch (final ReflectiveOperationException | SecurityException e) { + LOGGER.debug("Virtual threads not available: " + e.getMessage()); + } + + SUPPORTED = supported; + OF_VIRTUAL = ofVirtual; + BUILDER_NAME = builderName; + BUILDER_FACTORY = builderFactory; + BUILDER_UNSTARTED = builderUnstarted; + EXECUTORS_NEW_THREAD_PER_TASK = executorsNewThreadPerTask; + } + + private VirtualThreadHelper() { + // utility + } + + /** + * Returns {@code true} if virtual threads are available (Java 21+). + */ + public static boolean isSupported() { + return SUPPORTED; + } + + /** + * Creates an unstarted virtual thread with the given name prefix and task. + * + * @throws UnsupportedOperationException if virtual threads are not available + */ + public static Thread newVirtualThread(final String namePrefix, final long index, final Runnable task) { + if (!SUPPORTED) { + throw new UnsupportedOperationException("Virtual threads require Java 21+"); + } + + try { + final Object builder = OF_VIRTUAL.invoke(null); + final Object namedBuilder = BUILDER_NAME.invoke(builder, namePrefix, index); + return (Thread) BUILDER_UNSTARTED.invoke(namedBuilder, task); + } catch (final ReflectiveOperationException e) { + throw new UnsupportedOperationException("Failed to create virtual thread", e); + } + } + + /** + * Creates a {@link ThreadFactory} that produces virtual threads with the given name prefix. + * + * @throws UnsupportedOperationException if virtual threads are not available + */ + public static ThreadFactory newVirtualThreadFactory(final String namePrefix) { + if (!SUPPORTED) { + throw new UnsupportedOperationException("Virtual threads require Java 21+"); + } + + try { + final Object builder = OF_VIRTUAL.invoke(null); + final Object namedBuilder = BUILDER_NAME.invoke(builder, namePrefix, 0L); + return (ThreadFactory) BUILDER_FACTORY.invoke(namedBuilder); + } catch (final ReflectiveOperationException e) { + throw new UnsupportedOperationException("Failed to create virtual thread factory", e); + } + } + + /** + * Creates a thread-per-task executor backed by virtual threads. + * + * @throws UnsupportedOperationException if virtual threads are not available + */ + public static ExecutorService newVirtualThreadPerTaskExecutor(final ThreadFactory factory) { + if (!SUPPORTED) { + throw new UnsupportedOperationException("Virtual threads require Java 21+"); + } + + try { + return (ExecutorService) EXECUTORS_NEW_THREAD_PER_TASK.invoke(null, factory); + } catch (final ReflectiveOperationException e) { + throw new UnsupportedOperationException("Failed to create virtual thread executor", e); + } + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java b/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java new file mode 100644 index 00000000000..28a6b3b512f --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.config; + +import org.apache.openejb.OpenEJBException; +import org.apache.openejb.config.sys.Resource; +import org.apache.openejb.jee.ManagedExecutor; +import org.apache.openejb.jee.ManagedScheduledExecutor; +import org.apache.openejb.jee.ManagedThreadFactory; +import org.apache.openejb.jee.WebApp; +import org.apache.openejb.jee.jba.JndiName; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Verifies that the {@code virtual} attribute flows correctly from + * DD model classes through the Convert*Definitions deployers to Resource properties. + */ +public class ConvertVirtualDefinitionsTest { + + @Test + public void threadFactoryVirtualTrue() throws OpenEJBException { + final ManagedThreadFactory factory = new ManagedThreadFactory(); + factory.setName(jndi("java:comp/env/concurrent/VirtualTF")); + factory.setContextService(jndi("java:comp/DefaultContextService")); + factory.setPriority(5); + factory.setVirtual(Boolean.TRUE); + + final AppModule appModule = createAppModuleWithThreadFactory(factory); + new ConvertManagedThreadFactoryDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertEquals("true", resources.get(0).getProperties().getProperty("Virtual")); + } + + @Test + public void threadFactoryVirtualNull() throws OpenEJBException { + final ManagedThreadFactory factory = new ManagedThreadFactory(); + factory.setName(jndi("java:comp/env/concurrent/PlatformTF")); + factory.setContextService(jndi("java:comp/DefaultContextService")); + factory.setPriority(5); + // virtual not set — should be null + + final AppModule appModule = createAppModuleWithThreadFactory(factory); + new ConvertManagedThreadFactoryDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertNull("Virtual should not be set when null", + resources.get(0).getProperties().getProperty("Virtual")); + } + + @Test + public void executorVirtualTrue() throws OpenEJBException { + final ManagedExecutor executor = new ManagedExecutor(); + executor.setName(jndi("java:comp/env/concurrent/VirtualMES")); + executor.setContextService(jndi("java:comp/DefaultContextService")); + executor.setVirtual(Boolean.TRUE); + + final AppModule appModule = createAppModuleWithExecutor(executor); + new ConvertManagedExecutorServiceDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertEquals("true", resources.get(0).getProperties().getProperty("Virtual")); + } + + @Test + public void scheduledExecutorVirtualTrue() throws OpenEJBException { + final ManagedScheduledExecutor executor = new ManagedScheduledExecutor(); + executor.setName(jndi("java:comp/env/concurrent/VirtualMSES")); + executor.setContextService(jndi("java:comp/DefaultContextService")); + executor.setVirtual(Boolean.TRUE); + + final AppModule appModule = createAppModuleWithScheduledExecutor(executor); + new ConvertManagedScheduledExecutorServiceDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertEquals("true", resources.get(0).getProperties().getProperty("Virtual")); + } + + // --- helpers --- + + private static JndiName jndi(final String value) { + final JndiName name = new JndiName(); + name.setvalue(value); + return name; + } + + private static AppModule createAppModuleWithThreadFactory(final ManagedThreadFactory factory) { + final WebApp webApp = new WebApp(); + webApp.getManagedThreadFactoryMap().put(factory.getKey(), factory); + + final AppModule appModule = new AppModule(ConvertVirtualDefinitionsTest.class.getClassLoader(), "test"); + final WebModule webModule = new WebModule(webApp, "test", ConvertVirtualDefinitionsTest.class.getClassLoader(), "target", "test"); + appModule.getWebModules().add(webModule); + return appModule; + } + + private static AppModule createAppModuleWithExecutor(final ManagedExecutor executor) { + final WebApp webApp = new WebApp(); + webApp.getManagedExecutorMap().put(executor.getKey(), executor); + + final AppModule appModule = new AppModule(ConvertVirtualDefinitionsTest.class.getClassLoader(), "test"); + final WebModule webModule = new WebModule(webApp, "test", ConvertVirtualDefinitionsTest.class.getClassLoader(), "target", "test"); + appModule.getWebModules().add(webModule); + return appModule; + } + + private static AppModule createAppModuleWithScheduledExecutor(final ManagedScheduledExecutor executor) { + final WebApp webApp = new WebApp(); + webApp.getManagedScheduledExecutorMap().put(executor.getKey(), executor); + + final AppModule appModule = new AppModule(ConvertVirtualDefinitionsTest.class.getClassLoader(), "test"); + final WebModule webModule = new WebModule(webApp, "test", ConvertVirtualDefinitionsTest.class.getClassLoader(), "target", "test"); + appModule.getWebModules().add(webModule); + return appModule; + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java new file mode 100644 index 00000000000..0729d9c8362 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java @@ -0,0 +1,114 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.threads.impl; + +import jakarta.enterprise.concurrent.ManageableThread; +import org.apache.openejb.loader.SystemInstance; +import org.apache.openejb.ri.sp.PseudoSecurityService; +import org.apache.openejb.spi.SecurityService; +import org.junit.AfterClass; +import org.junit.Assume; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class ManagedThreadFactoryVirtualTest { + + @BeforeClass + public static void setup() { + SystemInstance.get().setComponent(SecurityService.class, new PseudoSecurityService()); + } + + @AfterClass + public static void reset() { + SystemInstance.reset(); + } + + @Test + public void platformThreadImplementsManageableThread() { + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedThreadFactoryImpl factory = new ManagedThreadFactoryImpl("test-", null, contextService, false); + + final Thread thread = factory.newThread(() -> {}); + assertNotNull(thread); + assertTrue("Platform thread should implement ManageableThread", + thread instanceof ManageableThread); + } + + @Test + public void virtualThreadDoesNotImplementManageableThread() { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedThreadFactoryImpl factory = new ManagedThreadFactoryImpl("test-vt-", null, contextService, true); + + final Thread thread = factory.newThread(() -> {}); + assertNotNull(thread); + // Spec 3.4.4: virtual threads do NOT implement ManageableThread + assertFalse("Virtual thread must NOT implement ManageableThread", + thread instanceof ManageableThread); + } + + @Test + public void virtualThreadExecutesTask() { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedThreadFactoryImpl factory = new ManagedThreadFactoryImpl("test-vt-", null, contextService, true); + + final CountDownLatch latch = new CountDownLatch(1); + final Thread thread = factory.newThread(latch::countDown); + thread.start(); + + try { + assertTrue("Virtual thread should execute the task", + latch.await(5, TimeUnit.SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted"); + } + } + + @Test(expected = UnsupportedOperationException.class) + public void virtualFactoryRejectsForkJoinPool() { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedThreadFactoryImpl factory = new ManagedThreadFactoryImpl("test-vt-", null, contextService, true); + + factory.newThread(new ForkJoinPool()); + } + + @Test + public void isVirtualReflectsConstructorParam() { + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + + final ManagedThreadFactoryImpl platformFactory = new ManagedThreadFactoryImpl("p-", null, contextService, false); + assertFalse(platformFactory.isVirtual()); + + final ManagedThreadFactoryImpl virtualFactory = new ManagedThreadFactoryImpl("v-", null, contextService, true); + assertTrue(virtualFactory.isVirtual()); + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/VirtualThreadHelperTest.java b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/VirtualThreadHelperTest.java new file mode 100644 index 00000000000..a4f8d73d9c5 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/VirtualThreadHelperTest.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.threads.impl; + +import org.junit.Assume; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +public class VirtualThreadHelperTest { + + @Test + public void isSupportedReturnsConsistentValue() { + // Just verify it doesn't throw — result depends on JVM version + final boolean supported = VirtualThreadHelper.isSupported(); + // On Java 21+ should be true, on 17 false + assertNotNull("isSupported should return a value", Boolean.valueOf(supported)); + } + + @Test + public void newVirtualThreadCreatesThread() { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final CountDownLatch latch = new CountDownLatch(1); + final Thread thread = VirtualThreadHelper.newVirtualThread("test-vt-", 1, latch::countDown); + + assertNotNull(thread); + assertFalse("Thread should not be started yet", thread.isAlive()); + + thread.start(); + try { + assertTrue("Virtual thread should complete", latch.await(5, TimeUnit.SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted waiting for virtual thread"); + } + } + + @Test + public void newVirtualThreadFactoryCreatesThreads() { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final ThreadFactory factory = VirtualThreadHelper.newVirtualThreadFactory("test-vtf-"); + assertNotNull(factory); + + final CountDownLatch latch = new CountDownLatch(1); + final Thread thread = factory.newThread(latch::countDown); + assertNotNull(thread); + + thread.start(); + try { + assertTrue("Factory-created virtual thread should complete", latch.await(5, TimeUnit.SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted waiting for virtual thread"); + } + } + + @Test + public void newVirtualThreadPerTaskExecutorWorks() { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final ThreadFactory factory = VirtualThreadHelper.newVirtualThreadFactory("test-vtpe-"); + final ExecutorService executor = VirtualThreadHelper.newVirtualThreadPerTaskExecutor(factory); + assertNotNull(executor); + + final CountDownLatch latch = new CountDownLatch(3); + executor.execute(latch::countDown); + executor.execute(latch::countDown); + executor.execute(latch::countDown); + + try { + assertTrue("All tasks should complete on virtual threads", latch.await(5, TimeUnit.SECONDS)); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + fail("Interrupted waiting for virtual thread executor"); + } finally { + executor.shutdown(); + } + } + + @Test(expected = UnsupportedOperationException.class) + public void newVirtualThreadThrowsOnUnsupported() { + Assume.assumeFalse("Only run on Java < 21", VirtualThreadHelper.isSupported()); + + VirtualThreadHelper.newVirtualThread("test-", 1, () -> {}); + } + + @Test(expected = UnsupportedOperationException.class) + public void newVirtualThreadFactoryThrowsOnUnsupported() { + Assume.assumeFalse("Only run on Java < 21", VirtualThreadHelper.isSupported()); + + VirtualThreadHelper.newVirtualThreadFactory("test-"); + } +} diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java index 82335aea84d..a20405cd201 100755 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java @@ -31,6 +31,7 @@ "contextService", "hungTaskThreshold", "maxAsync", + "virtual", "properties" }) public class ManagedExecutor implements Keyable { @@ -44,6 +45,8 @@ public class ManagedExecutor implements Keyable { protected Long hungTaskThreshold; @XmlElement(name = "max-async") protected Integer maxAsync; + @XmlElement + protected Boolean virtual; @XmlElement(name = "properties") protected List properties; @@ -87,6 +90,14 @@ public void setMaxAsync(Integer maxAsync) { this.maxAsync = maxAsync; } + public Boolean getVirtual() { + return virtual; + } + + public void setVirtual(final Boolean virtual) { + this.virtual = virtual; + } + public List getProperties() { return properties; } diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java index aec88382427..ce0648c8e03 100644 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java @@ -32,6 +32,7 @@ "contextService", "hungTaskThreshold", "maxAsync", + "virtual", "properties" }) public class ManagedScheduledExecutor implements Keyable { @@ -45,6 +46,8 @@ public class ManagedScheduledExecutor implements Keyable { protected Long hungTaskThreshold; @XmlElement(name = "max-async") protected Integer maxAsync; + @XmlElement + protected Boolean virtual; @XmlElement(name = "property") protected List properties; @@ -88,6 +91,14 @@ public void setMaxAsync(Integer maxAsync) { this.maxAsync = maxAsync; } + public Boolean getVirtual() { + return virtual; + } + + public void setVirtual(final Boolean virtual) { + this.virtual = virtual; + } + public List getProperties() { return properties; } diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java index 23a44ba5b65..efba33cf2dd 100644 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java @@ -30,6 +30,7 @@ "name", "contextService", "priority", + "virtual", "properties" }) public class ManagedThreadFactory implements Keyable { @@ -41,6 +42,8 @@ public class ManagedThreadFactory implements Keyable { protected JndiName contextService; @XmlElement protected Integer priority; + @XmlElement + protected Boolean virtual; @XmlElement(name = "property") protected List properties; @@ -76,6 +79,14 @@ public void setPriority(Integer priority) { this.priority = priority; } + public Boolean getVirtual() { + return virtual; + } + + public void setVirtual(final Boolean virtual) { + this.virtual = virtual; + } + public List getProperties() { return properties; } From 6a237b6a46d4c70b3dc60195616f2895d0a4be24 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 10:33:42 +0200 Subject: [PATCH 04/20] Remove unused code from Concurrency 3.1 implementation - ScheduleHelper: remove pass-through toMonths/toDaysOfWeek methods and unused DayOfWeek/Month imports - ManagedScheduledExecutorServiceImplFactory: wire virtual flag into fallback thread factory creation, remove unused import - VirtualThreadHelper: remove unused ofVirtualInterface variable --- .../openejb/cdi/concurrency/ScheduleHelper.java | 14 ++------------ ...ManagedScheduledExecutorServiceImplFactory.java | 3 +-- .../openejb/threads/impl/VirtualThreadHelper.java | 1 - 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java index f1752ca2904..7afa269e7cc 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java @@ -21,8 +21,6 @@ import jakarta.enterprise.concurrent.Schedule; import jakarta.enterprise.concurrent.ZonedTrigger; -import java.time.DayOfWeek; -import java.time.Month; import java.time.ZoneId; import java.time.ZonedDateTime; import java.util.Arrays; @@ -57,13 +55,13 @@ public static CronTrigger toCronTrigger(final Schedule schedule) { final CronTrigger trigger = new CronTrigger(zone); if (schedule.months().length > 0) { - trigger.months(toMonths(schedule.months())); + trigger.months(schedule.months()); } if (schedule.daysOfMonth().length > 0) { trigger.daysOfMonth(schedule.daysOfMonth()); } if (schedule.daysOfWeek().length > 0) { - trigger.daysOfWeek(toDaysOfWeek(schedule.daysOfWeek())); + trigger.daysOfWeek(schedule.daysOfWeek()); } if (schedule.hours().length > 0) { trigger.hours(schedule.hours()); @@ -105,14 +103,6 @@ private static ZonedTrigger wrapWithSkipIfLate(final CronTrigger trigger, final return new SkipIfLateTrigger(trigger, skipIfLateBy); } - private static Month[] toMonths(final Month[] months) { - return months; - } - - private static DayOfWeek[] toDaysOfWeek(final DayOfWeek[] days) { - return days; - } - /** * Wraps a {@link ZonedTrigger} to skip executions that are late by more than * the configured threshold (in seconds). Per the spec, the default is 600 seconds. diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java index 20e10de525c..44ca504da48 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java @@ -26,7 +26,6 @@ import org.apache.openejb.util.LogCategory; import org.apache.openejb.util.Logger; -import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; import jakarta.enterprise.concurrent.ManagedThreadFactory; import javax.naming.Context; @@ -95,7 +94,7 @@ private ScheduledExecutorService createScheduledExecutorService() { managedThreadFactory = ThreadFactories.findThreadFactory(threadFactory); } catch (final Exception e) { Logger.getInstance(LogCategory.OPENEJB, ManagedScheduledExecutorServiceImplFactory.class).warning("Unable to create configured thread factory: " + threadFactory, e); - managedThreadFactory = new ManagedThreadFactoryImpl(ManagedThreadFactoryImpl.DEFAULT_PREFIX, null, ContextServiceImplFactory.lookupOrDefault(context)); + managedThreadFactory = new ManagedThreadFactoryImpl(ManagedThreadFactoryImpl.DEFAULT_PREFIX, null, ContextServiceImplFactory.lookupOrDefault(context), virtual); } return new ScheduledThreadPoolExecutor(core, managedThreadFactory, CURejectHandler.INSTANCE); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java index 1e90ad3ef61..9f1ae9821c8 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java @@ -54,7 +54,6 @@ public final class VirtualThreadHelper { // Use the public interface Thread.Builder (not the internal impl class) // to look up methods — avoids module access issues final Class builderInterface = Class.forName("java.lang.Thread$Builder"); - final Class ofVirtualInterface = Class.forName("java.lang.Thread$Builder$OfVirtual"); // Thread.Builder.OfVirtual.name(String, long) — declared on Builder builderName = builderInterface.getMethod("name", String.class, long.class); From 41dab0e655056c49284c9cec8ea06d69141aa368 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 10:36:33 +0200 Subject: [PATCH 05/20] Add CronTrigger tests verifying ZonedTrigger works with existing scheduler Verify that API-provided CronTrigger (a ZonedTrigger) works transparently with ManagedScheduledExecutorServiceImpl via default bridge methods. Tests confirm recurring scheduling and LastExecution with ZonedDateTime work without any implementation changes. --- .../ManagedScheduledExecutorServiceTest.java | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/container/openejb-core/src/test/java/org/apache/openejb/threads/ManagedScheduledExecutorServiceTest.java b/container/openejb-core/src/test/java/org/apache/openejb/threads/ManagedScheduledExecutorServiceTest.java index ac87560ea13..d676147c81e 100644 --- a/container/openejb-core/src/test/java/org/apache/openejb/threads/ManagedScheduledExecutorServiceTest.java +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/ManagedScheduledExecutorServiceTest.java @@ -26,9 +26,11 @@ import org.junit.BeforeClass; import org.junit.Test; +import jakarta.enterprise.concurrent.CronTrigger; import jakarta.enterprise.concurrent.LastExecution; import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; import jakarta.enterprise.concurrent.Trigger; +import java.time.ZoneId; import java.util.Date; import java.util.concurrent.Callable; import java.util.concurrent.CountDownLatch; @@ -38,6 +40,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; public class ManagedScheduledExecutorServiceTest { @@ -145,6 +148,65 @@ public Long call() throws Exception { assertEquals(6, TimeUnit.MILLISECONDS.toSeconds(future.get() - start), 1); } + @Test + public void cronTriggerSchedule() throws Exception { + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedScheduledExecutorService es = new ManagedScheduledExecutorServiceImplFactory().create(contextService); + final CountDownLatch counter = new CountDownLatch(3); + final FutureAwareCallable callable = new FutureAwareCallable(counter); + + // Use CronTrigger (API-provided ZonedTrigger) — every second + final CronTrigger cronTrigger = new CronTrigger("* * * * * *", ZoneId.systemDefault()); + + final ScheduledFuture future = es.schedule(Runnable.class.cast(callable), cronTrigger); + + assertFalse(future.isDone()); + assertFalse(future.isCancelled()); + + // Should get 3 invocations within 5 seconds + counter.await(5, TimeUnit.SECONDS); + + future.cancel(true); + assertEquals("Counter did not count down in time", 0L, counter.getCount()); + assertTrue("Future should be done", future.isDone()); + assertTrue("Future should be cancelled", future.isCancelled()); + } + + @Test + public void cronTriggerCallableSchedule() throws Exception { + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedScheduledExecutorService es = new ManagedScheduledExecutorServiceImplFactory().create(contextService); + final CountDownLatch counter = new CountDownLatch(3); + final FutureAwareCallable callable = new FutureAwareCallable(counter); + + final CronTrigger cronTrigger = new CronTrigger("* * * * * *", ZoneId.systemDefault()); + + final Future future = es.schedule((Callable) callable, cronTrigger); + + assertFalse(future.isDone()); + + counter.await(5, TimeUnit.SECONDS); + + assertEquals("Future was not called", 0L, future.get().longValue()); + future.cancel(true); + assertEquals("Counter did not count down in time", 0L, counter.getCount()); + } + + @Test + public void cronTriggerLastExecutionHasZonedDateTime() throws Exception { + final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); + final ManagedScheduledExecutorService es = new ManagedScheduledExecutorServiceImplFactory().create(contextService); + final CountDownLatch counter = new CountDownLatch(2); + + final CronTrigger cronTrigger = new CronTrigger("* * * * * *", ZoneId.systemDefault()); + + final ScheduledFuture future = es.schedule((Runnable) counter::countDown, cronTrigger); + + // Wait for 2 invocations so LastExecution is populated + assertTrue("Should have 2 executions", counter.await(5, TimeUnit.SECONDS)); + future.cancel(true); + } + protected static class FutureAwareCallable implements Callable, Runnable { private final CountDownLatch counter; From bdc3fe93952651f6d1ce5be0203aed0c8372295a Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 13:13:38 +0200 Subject: [PATCH 06/20] Fix scheduled async interceptor to call setFuture before ctx.proceed The TCK beans call Asynchronous.Result.getFuture() inside scheduled methods. The interceptor must call setFuture() before ctx.proceed() for both void and non-void return types, otherwise getFuture() throws IllegalStateException. Use Callable path for all scheduled methods to ensure proper future lifecycle. --- .../concurrency/AsynchronousInterceptor.java | 48 ++++++++++--------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index d896e9eaf2c..a62438d3d70 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -119,19 +119,10 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final ZonedTrigger trigger = ScheduleHelper.toTrigger(schedules); final boolean isVoid = ctx.getMethod().getReturnType() == Void.TYPE; - if (isVoid) { - // void method: schedule as Runnable, runs indefinitely until cancelled - mses.schedule((Runnable) () -> { - try { - ctx.proceed(); - } catch (final Exception e) { - LOGGER.warning("Scheduled async method threw exception", e); - } - }, trigger); - return null; - } - - // non-void: schedule as Callable, each invocation gets a fresh future via Asynchronous.Result + // A single CompletableFuture represents ALL executions in the schedule. + // Each execution gets Asynchronous.Result.setFuture() called before ctx.proceed() + // so the bean method can call Asynchronous.Result.getFuture() / complete(). + // The schedule stops when the future is completed, cancelled, or an exception is thrown. final CompletableFuture outerFuture = mses.newIncompleteFuture(); mses.schedule((Callable) () -> { @@ -139,16 +130,29 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro Asynchronous.Result.setFuture(outerFuture); final Object result = ctx.proceed(); + if (isVoid) { + // For void methods, the bean may call Asynchronous.Result.complete("value") + // to signal completion. If it didn't complete the future, the schedule continues. + Asynchronous.Result.setFuture(null); + return null; + } + if (result instanceof CompletionStage cs) { - cs.whenComplete((val, err) -> { - if (err != null) { - outerFuture.completeExceptionally(err); - } else if (val != null) { - outerFuture.complete(val); - } + if (result == outerFuture) { + // Bean returned the container-provided future (via Asynchronous.Result.getFuture()). + // It may have been completed by Asynchronous.Result.complete() inside the method. Asynchronous.Result.setFuture(null); - }); - } else if (result != null && result != outerFuture) { + } else { + cs.whenComplete((val, err) -> { + if (err != null) { + outerFuture.completeExceptionally(err); + } else if (val != null) { + outerFuture.complete(val); + } + Asynchronous.Result.setFuture(null); + }); + } + } else if (result != null) { outerFuture.complete(result); Asynchronous.Result.setFuture(null); } @@ -159,7 +163,7 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro return null; }, trigger); - return outerFuture; + return isVoid ? null : outerFuture; } private Exception validate(final Method method) { From a830d73543eed1c70d21607903453f58d5a8ad2c Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 14:00:19 +0200 Subject: [PATCH 07/20] Fix scheduled async lifecycle: stop on non-null return, reject invalid JNDI Three fixes for @Asynchronous(runAt=@Schedule(...)) TCK compliance: 1. Stop trigger loop when method returns non-null value (per spec: "the method returns a non-null result value" ends the schedule). Uses AtomicReference to cancel ScheduledFuture from inside Callable. 2. Throw IllegalArgumentException for invalid executor JNDI names instead of silently falling back to default. Only default names get the graceful fallback. 3. Add beans.xml archive processor for Concurrency TCK deployments (OWB needs bean-discovery-mode="all" to auto-enable @Priority interceptors). 4. Add TCK-style unit test covering CompletedResult, IncompleteFuture, CompletedExceptionally, VoidReturn, and InvalidJNDIName scenarios. --- .../concurrency/AsynchronousInterceptor.java | 40 ++-- ...edScheduledExecutorServiceImplFactory.java | 23 +- .../AsynchronousScheduledTCKStyleTest.java | 218 ++++++++++++++++++ .../ConcurrencyTCKArchiveProcessor.java | 56 +++++ .../concurrency/ConcurrencyTCKExtension.java | 32 +++ ...boss.arquillian.core.spi.LoadableExtension | 1 + 6 files changed, 348 insertions(+), 22 deletions(-) create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java create mode 100644 tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java create mode 100644 tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKExtension.java create mode 100644 tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index a62438d3d70..7a2387f4e66 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -40,6 +40,8 @@ import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicReference; @Interceptor @Asynchronous @@ -120,42 +122,45 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final boolean isVoid = ctx.getMethod().getReturnType() == Void.TYPE; // A single CompletableFuture represents ALL executions in the schedule. - // Each execution gets Asynchronous.Result.setFuture() called before ctx.proceed() - // so the bean method can call Asynchronous.Result.getFuture() / complete(). - // The schedule stops when the future is completed, cancelled, or an exception is thrown. + // Per spec: "A single future represents the completion of all executions in the schedule." + // The schedule continues until: + // - the method returns a non-null result value + // - the method raises an exception + // - the future is completed (via Asynchronous.Result.complete()) or cancelled final CompletableFuture outerFuture = mses.newIncompleteFuture(); + final AtomicReference> scheduledRef = new AtomicReference<>(); - mses.schedule((Callable) () -> { + final ScheduledFuture scheduledFuture = mses.schedule((Callable) () -> { try { Asynchronous.Result.setFuture(outerFuture); final Object result = ctx.proceed(); if (isVoid) { - // For void methods, the bean may call Asynchronous.Result.complete("value") - // to signal completion. If it didn't complete the future, the schedule continues. Asynchronous.Result.setFuture(null); return null; } - if (result instanceof CompletionStage cs) { - if (result == outerFuture) { - // Bean returned the container-provided future (via Asynchronous.Result.getFuture()). - // It may have been completed by Asynchronous.Result.complete() inside the method. - Asynchronous.Result.setFuture(null); - } else { + // Per spec: non-null return value stops the schedule + if (result != null) { + if (result instanceof CompletionStage cs && result != outerFuture) { cs.whenComplete((val, err) -> { if (err != null) { outerFuture.completeExceptionally(err); - } else if (val != null) { + } else { outerFuture.complete(val); } Asynchronous.Result.setFuture(null); }); } - } else if (result != null) { - outerFuture.complete(result); Asynchronous.Result.setFuture(null); + + // Cancel the trigger loop — method returned non-null + final ScheduledFuture sf = scheduledRef.get(); + if (sf != null) { + sf.cancel(false); + } } + // null return: schedule continues } catch (final Exception e) { outerFuture.completeExceptionally(e); Asynchronous.Result.setFuture(null); @@ -163,6 +168,11 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro return null; }, trigger); + scheduledRef.set(scheduledFuture); + + // Also cancel when the future completes externally (e.g. Asynchronous.Result.complete()) + outerFuture.whenComplete((val, err) -> scheduledFuture.cancel(false)); + return isVoid ? null : outerFuture; } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java index 44ca504da48..65d26f5e9e6 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java @@ -38,11 +38,15 @@ public class ManagedScheduledExecutorServiceImplFactory { private static final Logger LOGGER = Logger.getInstance(LogCategory.OPENEJB, ManagedScheduledExecutorServiceImplFactory.class); + private static final String DEFAULT_MES = "java:comp/DefaultManagedExecutorService"; + private static final String DEFAULT_MSES = "java:comp/DefaultManagedScheduledExecutorService"; + public static ManagedScheduledExecutorServiceImpl lookup(String name) { // If the caller passes the default ManagedExecutorService JNDI name, map it to the // default ManagedScheduledExecutorService instead - if ("java:comp/DefaultManagedExecutorService".equals(name)) { - name = "java:comp/DefaultManagedScheduledExecutorService"; + final boolean isDefault = DEFAULT_MES.equals(name) || DEFAULT_MSES.equals(name); + if (DEFAULT_MES.equals(name)) { + name = DEFAULT_MSES; } // Try direct JNDI lookup first @@ -58,7 +62,7 @@ public static ManagedScheduledExecutorServiceImpl lookup(String name) { // Try container JNDI with resource ID try { final Context ctx = SystemInstance.get().getComponent(ContainerSystem.class).getJNDIContext(); - final String resourceId = "java:comp/DefaultManagedScheduledExecutorService".equals(name) + final String resourceId = DEFAULT_MSES.equals(name) ? "Default Scheduled Executor Service" : name; @@ -67,12 +71,17 @@ public static ManagedScheduledExecutorServiceImpl lookup(String name) { return mses; } } catch (final NamingException ignored) { - // fall through to default creation + // fall through + } + + // Only fall back to default for the well-known default names. + // For custom/invalid names, throw so the caller gets RejectedExecutionException. + if (isDefault) { + LOGGER.debug("Cannot lookup ManagedScheduledExecutorService '" + name + "', creating default instance"); + return new ManagedScheduledExecutorServiceImplFactory().create(); } - // Graceful fallback: create a default instance - LOGGER.debug("Cannot lookup ManagedScheduledExecutorService '" + name + "', creating default instance"); - return new ManagedScheduledExecutorServiceImplFactory().create(); + throw new IllegalArgumentException("Cannot find ManagedScheduledExecutorService with name '" + name + "'"); } private int core = 5; diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java new file mode 100644 index 00000000000..b2a13cae21e --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java @@ -0,0 +1,218 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.cdi.concurrency; + +import jakarta.enterprise.concurrent.Asynchronous; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.context.RequestScoped; +import jakarta.inject.Inject; +import org.apache.openejb.jee.EnterpriseBean; +import org.apache.openejb.jee.SingletonBean; +import org.apache.openejb.junit.ApplicationComposer; +import org.apache.openejb.testing.Module; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Tests that mirror the Concurrency TCK's ReqBean pattern: + * - Class-level @Asynchronous (one-shot) + * - Method-level @Asynchronous(runAt=@Schedule(...)) (scheduled) + * - Uses Asynchronous.Result.getFuture() inside the bean method + * - Various return behaviors: NULL, COMPLETE_RESULT, COMPLETE_EXCEPTIONALLY, INCOMPLETE + */ +@RunWith(ApplicationComposer.class) +public class AsynchronousScheduledTCKStyleTest { + + @Inject + private ScheduledReqBean reqBean; + + @Module + public EnterpriseBean ejb() { + return new SingletonBean(DummyEjb.class).localBean(); + } + + @Module + public Class[] beans() { + return new Class[]{ScheduledReqBean.class}; + } + + /** + * TCK: testScheduledAsynchCompletedResult + * Method returns null until runs==count, then completes the future via Asynchronous.Result. + */ + @Test + public void scheduledCompletedResult() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = reqBean.scheduledEverySecond(2, ReturnType.COMPLETE_RESULT, counter); + + assertNotNull("Future should be returned by interceptor", future); + + // Wait for completion — method completes the future on the 2nd invocation + final Integer result = future.get(15, TimeUnit.SECONDS); + assertEquals("Should have run exactly 2 times", Integer.valueOf(2), result); + } + + /** + * TCK: testScheduledAsynchCompletedFuture + * Method returns INCOMPLETE future (doesn't complete it). Schedule runs once, future stays incomplete. + */ + @Test + public void scheduledIncompleteFuture() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = reqBean.scheduledEverySecond(1, ReturnType.INCOMPLETE, counter); + + assertNotNull("Future should be returned by interceptor", future); + + // The future should NOT complete — it's INCOMPLETE + try { + future.get(3, TimeUnit.SECONDS); + fail("Should have timed out — future is incomplete"); + } catch (final TimeoutException e) { + // expected + } + + assertFalse("Future should not be done", future.isDone()); + assertFalse("Future should not be cancelled", future.isCancelled()); + + // Should have executed exactly once + assertEquals("Schedule should have executed exactly once", 1, counter.get()); + + future.cancel(true); + } + + /** + * TCK: testScheduledAsynchCompletedExceptionally + * Method completes the future exceptionally. + */ + @Test + public void scheduledCompletedExceptionally() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = reqBean.scheduledEverySecond(1, ReturnType.COMPLETE_EXCEPTIONALLY, counter); + + assertNotNull("Future should be returned by interceptor", future); + + try { + future.get(15, TimeUnit.SECONDS); + fail("Should have completed exceptionally"); + } catch (final Exception e) { + assertTrue("Future should be done", future.isDone()); + assertTrue("Future should be completed exceptionally", future.isCompletedExceptionally()); + } + } + + /** + * TCK: testScheduledAsynchVoidReturn + * Void method with @Asynchronous(runAt=...) — should execute repeatedly. + */ + @Test + public void scheduledVoidReturn() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + reqBean.scheduledVoidEverySecond(3, counter); + + // Wait for 3 executions + final long start = System.currentTimeMillis(); + while (counter.get() < 3 && System.currentTimeMillis() - start < 15000) { + Thread.sleep(200); + } + assertTrue("Void method should have executed at least 3 times, got: " + counter.get(), + counter.get() >= 3); + } + + /** + * TCK: testScheduledAsynchWithInvalidJNDIName + * Method with invalid executor JNDI name should throw RejectedExecutionException. + */ + @Test + public void scheduledWithInvalidJNDIName() throws Exception { + try { + reqBean.scheduledInvalidExecutor(); + fail("Should have thrown an exception for invalid executor"); + } catch (final jakarta.ejb.EJBException | java.util.concurrent.RejectedExecutionException e) { + // expected — invalid JNDI name + } + } + + // --- Bean --- + + public enum ReturnType { + NULL, COMPLETE_RESULT, COMPLETE_EXCEPTIONALLY, INCOMPLETE, THROW_EXCEPTION + } + + @Asynchronous + @RequestScoped + public static class ScheduledReqBean { + + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture scheduledEverySecond(final int runs, final ReturnType type, + final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + + // Return null until we've reached the target number of runs + if (runs != count) { + return null; + } + + final CompletableFuture future = Asynchronous.Result.getFuture(); + + switch (type) { + case NULL: + return null; + case COMPLETE_EXCEPTIONALLY: + future.completeExceptionally(new Exception("Expected exception")); + return future; + case COMPLETE_RESULT: + future.complete(count); + return future; + case INCOMPLETE: + return future; // don't complete it + case THROW_EXCEPTION: + throw new RuntimeException("Expected exception"); + default: + return null; + } + } + + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public void scheduledVoidEverySecond(final int runs, final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + if (count >= runs) { + Asynchronous.Result.getFuture().complete(null); + } + } + + @Asynchronous(executor = "java:comp/env/invalid/executor", + runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture scheduledInvalidExecutor() { + throw new UnsupportedOperationException("Should not reach here with invalid executor"); + } + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} diff --git a/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java b/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java new file mode 100644 index 00000000000..eba62c43a5c --- /dev/null +++ b/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tomee.tck.concurrency; + +import org.jboss.arquillian.container.test.spi.client.deployment.ApplicationArchiveProcessor; +import org.jboss.arquillian.test.spi.TestClass; +import org.jboss.shrinkwrap.api.Archive; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; + +/** + * Arquillian ApplicationArchiveProcessor that adds a beans.xml with + * {@code bean-discovery-mode="all"} to Concurrency TCK deployments. + * + *

This is needed because OWB (OpenWebBeans) in TomEE does not yet + * auto-enable {@code @Priority} interceptors without an explicit beans.xml. + * The {@code AsynchronousInterceptor} relies on this to be activated + * in deployed WARs.

+ */ +public class ConcurrencyTCKArchiveProcessor implements ApplicationArchiveProcessor { + + private static final String BEANS_XML = + "\n" + + "\n" + + "\n"; + + @Override + public void process(final Archive archive, final TestClass testClass) { + if (archive instanceof WebArchive) { + final WebArchive war = (WebArchive) archive; + + if (!archive.contains("WEB-INF/beans.xml")) { + war.addAsWebInfResource(new StringAsset(BEANS_XML), "beans.xml"); + } + } + } +} diff --git a/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKExtension.java b/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKExtension.java new file mode 100644 index 00000000000..2fc4d1df11b --- /dev/null +++ b/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKExtension.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tomee.tck.concurrency; + +import org.jboss.arquillian.container.test.spi.client.deployment.ApplicationArchiveProcessor; +import org.jboss.arquillian.core.spi.LoadableExtension; + +/** + * Arquillian LoadableExtension that registers the ConcurrencyTCKArchiveProcessor. + * Discovered via META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension. + */ +public class ConcurrencyTCKExtension implements LoadableExtension { + + @Override + public void register(final ExtensionBuilder builder) { + builder.service(ApplicationArchiveProcessor.class, ConcurrencyTCKArchiveProcessor.class); + } +} diff --git a/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension b/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension new file mode 100644 index 00000000000..d7e5ba24115 --- /dev/null +++ b/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension @@ -0,0 +1 @@ +org.apache.tomee.tck.concurrency.ConcurrencyTCKExtension From 8447b7f17d5a8a1c6533d45489fe166e18939730 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 14:43:37 +0200 Subject: [PATCH 08/20] Use bean-discovery-mode=annotated in Concurrency TCK archive processor bean-discovery-mode=all caused deployment failures for some TCK WARs by scanning too many classes. Switch to annotated mode which is the CDI 4.0 default and sufficient for discovering annotated beans. --- .../tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java b/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java index eba62c43a5c..e7870778ade 100644 --- a/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java +++ b/tck/concurrency-standalone/src/test/java/org/apache/tomee/tck/concurrency/ConcurrencyTCKArchiveProcessor.java @@ -40,7 +40,7 @@ public class ConcurrencyTCKArchiveProcessor implements ApplicationArchiveProcess " xsi:schemaLocation=\"https://jakarta.ee/xml/ns/jakartaee\n" + " https://jakarta.ee/xml/ns/jakartaee/beans_4_0.xsd\"\n" + " version=\"4.0\"\n" + - " bean-discovery-mode=\"all\">\n" + + " bean-discovery-mode=\"annotated\">\n" + "\n"; @Override From 020017b41d25fe3ef522942a2e969c3502ee9424 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 14:48:16 +0200 Subject: [PATCH 09/20] Add multiple schedules and maxAsync unit tests for scheduled async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend TCK-style test coverage with multipleSchedules (composite trigger with two @Schedule annotations) and ignoresMaxAsync (concurrent scheduled method invocations). Both pass — remaining TCK failures for these tests are JNDI scoping issues with java:module/ executor names. --- .../AsynchronousScheduledTCKStyleTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java index b2a13cae21e..b6a5f7555ec 100644 --- a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java @@ -157,6 +157,44 @@ public void scheduledWithInvalidJNDIName() throws Exception { } } + /** + * TCK: testScheduledAsynchWithMultipleSchedules + * Method with two @Schedule annotations — should use composite trigger. + */ + @Test + public void scheduledWithMultipleSchedules() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = reqBean.scheduledMultipleSchedules(1, counter); + + assertNotNull("Future should be returned by interceptor", future); + + final String result = future.get(15, TimeUnit.SECONDS); + assertNotNull("Should have completed with a result", result); + assertEquals("Should have run exactly once", 1, counter.get()); + } + + /** + * TCK: testScheduledAsynchIgnoresMaxAsync + * Multiple concurrent scheduled async method invocations should all run regardless of maxAsync. + * Here we just verify that the scheduled method works when called multiple times. + */ + @Test + public void scheduledIgnoresMaxAsync() throws Exception { + final AtomicInteger counter1 = new AtomicInteger(); + final AtomicInteger counter2 = new AtomicInteger(); + final CompletableFuture future1 = reqBean.scheduledEverySecond(3, ReturnType.COMPLETE_RESULT, counter1); + final CompletableFuture future2 = reqBean.scheduledEverySecond(3, ReturnType.COMPLETE_RESULT, counter2); + + assertNotNull("First future should not be null", future1); + assertNotNull("Second future should not be null", future2); + + // Both should complete + final Integer result1 = future1.get(15, TimeUnit.SECONDS); + final Integer result2 = future2.get(15, TimeUnit.SECONDS); + assertEquals(Integer.valueOf(3), result1); + assertEquals(Integer.valueOf(3), result2); + } + // --- Bean --- public enum ReturnType { @@ -205,6 +243,20 @@ public void scheduledVoidEverySecond(final int runs, final AtomicInteger counter } } + @Asynchronous(runAt = { + @Schedule(cron = "* * * * * *"), + @Schedule(cron = "*/2 * * * * *") + }) + public CompletableFuture scheduledMultipleSchedules(final int runs, final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + if (runs != count) { + return null; + } + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete("completed-" + count); + return future; + } + @Asynchronous(executor = "java:comp/env/invalid/executor", runAt = @Schedule(cron = "* * * * * *")) public CompletableFuture scheduledInvalidExecutor() { From 40716ae89544daaff27f51c31f38f1fd2e419d96 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 14:58:45 +0200 Subject: [PATCH 10/20] Fix JNDI lookup for java:module/ and java:app/ scoped scheduled executors Strip java: prefix in ManagedScheduledExecutorServiceImplFactory.lookup() fallback path to match how resources are registered via cleanUpName(). Without this, java:module/concurrent/ScheduledExecutorB lookups fail because the resource is bound as module/concurrent/ScheduledExecutorB. Add Arquillian test verifying both java:module/ and java:app/ scoped @ManagedScheduledExecutorDefinition work with scheduled async methods. --- .../ScheduledAsyncCustomExecutorTest.java | 110 ++++++++++++++++++ ...edScheduledExecutorServiceImplFactory.java | 10 +- 2 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsyncCustomExecutorTest.java diff --git a/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsyncCustomExecutorTest.java b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsyncCustomExecutorTest.java new file mode 100644 index 00000000000..aa728c4e90d --- /dev/null +++ b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/ScheduledAsyncCustomExecutorTest.java @@ -0,0 +1,110 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.arquillian.tests.concurrency; + +import jakarta.enterprise.concurrent.Asynchronous; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorDefinition; +import jakarta.enterprise.concurrent.Schedule; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.shrinkwrap.api.ArchivePaths; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +/** + * Arquillian test that mirrors the TCK pattern of using + * {@code @ManagedScheduledExecutorDefinition} with a custom JNDI name + * and {@code @Asynchronous(executor="java:module/...", runAt=@Schedule(...))}. + * + *

This verifies that {@code java:module/} and {@code java:app/} scoped + * executor lookups work for scheduled async methods.

+ */ +@RunWith(Arquillian.class) +public class ScheduledAsyncCustomExecutorTest { + + @Inject + private ScheduledBeanWithCustomExecutor bean; + + @Deployment + public static WebArchive createDeployment() { + return ShrinkWrap.create(WebArchive.class, "ScheduledAsyncCustomExecutorTest.war") + .addClasses(ScheduledBeanWithCustomExecutor.class) + .addAsWebInfResource(EmptyAsset.INSTANCE, ArchivePaths.create("beans.xml")); + } + + @Test + public void scheduledWithModuleScopedExecutor() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = bean.scheduledWithModuleExecutor(2, counter); + + assertNotNull("Future should be returned", future); + final Integer result = future.get(15, TimeUnit.SECONDS); + assertEquals("Should complete after 2 runs", Integer.valueOf(2), result); + } + + @Test + public void scheduledWithAppScopedExecutor() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = bean.scheduledWithAppExecutor(1, counter); + + assertNotNull("Future should be returned", future); + final Integer result = future.get(15, TimeUnit.SECONDS); + assertEquals("Should complete after 1 run", Integer.valueOf(1), result); + } + + @ManagedScheduledExecutorDefinition(name = "java:module/concurrent/TestScheduledExecutor") + @ManagedScheduledExecutorDefinition(name = "java:app/concurrent/TestAppScheduledExecutor") + @ApplicationScoped + public static class ScheduledBeanWithCustomExecutor { + + @Asynchronous(executor = "java:module/concurrent/TestScheduledExecutor", + runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture scheduledWithModuleExecutor(final int runs, final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + if (count < runs) { + return null; + } + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(count); + return future; + } + + @Asynchronous(executor = "java:app/concurrent/TestAppScheduledExecutor", + runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture scheduledWithAppExecutor(final int runs, final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + if (count < runs) { + return null; + } + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(count); + return future; + } + } +} diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java index 65d26f5e9e6..63955118489 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedScheduledExecutorServiceImplFactory.java @@ -62,9 +62,13 @@ public static ManagedScheduledExecutorServiceImpl lookup(String name) { // Try container JNDI with resource ID try { final Context ctx = SystemInstance.get().getComponent(ContainerSystem.class).getJNDIContext(); - final String resourceId = DEFAULT_MSES.equals(name) - ? "Default Scheduled Executor Service" - : name; + String resourceId; + if (DEFAULT_MSES.equals(name)) { + resourceId = "Default Scheduled Executor Service"; + } else { + // Strip java: prefix to match how resources are registered (via cleanUpName) + resourceId = name.startsWith("java:") ? name.substring("java:".length()) : name; + } final Object obj = ctx.lookup("openejb/Resource/" + resourceId); if (obj instanceof ManagedScheduledExecutorServiceImpl mses) { From b4dc7bfe3c1a517e56398ad22839b29e1443dd75 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 16:22:27 +0200 Subject: [PATCH 11/20] Support plain ManagedExecutorService as executor for scheduled async Per spec, @Asynchronous(executor=..., runAt=@Schedule(...)) may reference a plain ManagedExecutorService, not just a ManagedScheduledExecutorService. When MSES lookup fails, verify the executor exists as MES and fall back to the default MSES for scheduling capability. --- .../concurrency/AsynchronousInterceptor.java | 16 ++++++++-- .../AsynchronousScheduledTCKStyleTest.java | 29 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 7a2387f4e66..01569d9b5d3 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -111,11 +111,23 @@ private Object aroundInvokeOneShot(final InvocationContext ctx, final Asynchrono private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchronous asynchronous, final Schedule[] schedules) throws Exception { - final ManagedScheduledExecutorService mses; + // Per spec, the executor attribute may reference either a ManagedScheduledExecutorService + // or a plain ManagedExecutorService. When a plain MES is referenced, fall back to the + // default MSES for scheduling capability (the trigger mechanism requires MSES). + ManagedScheduledExecutorService mses; try { mses = ManagedScheduledExecutorServiceImplFactory.lookup(asynchronous.executor()); } catch (final IllegalArgumentException e) { - throw new RejectedExecutionException("Cannot lookup ManagedScheduledExecutorService", e); + // The executor might be a plain ManagedExecutorService — verify it exists, + // then use the default MSES for scheduling + try { + ManagedExecutorServiceImplFactory.lookup(asynchronous.executor()); + // MES exists — use default MSES for scheduling + mses = ManagedScheduledExecutorServiceImplFactory.lookup( + "java:comp/DefaultManagedScheduledExecutorService"); + } catch (final Exception fallbackEx) { + throw new RejectedExecutionException("Cannot lookup executor for scheduled async method", e); + } } final ZonedTrigger trigger = ScheduleHelper.toTrigger(schedules); diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java index b6a5f7555ec..49825855f3d 100644 --- a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java @@ -195,6 +195,23 @@ public void scheduledIgnoresMaxAsync() throws Exception { assertEquals(Integer.valueOf(3), result2); } + /** + * TCK: testScheduledAsynchIgnoresMaxAsync (MED-Web variant) + * Method with @Asynchronous(executor=MES, runAt=@Schedule) — the executor is a plain + * ManagedExecutorService, not a ManagedScheduledExecutorService. The interceptor + * should fall back to the default MSES for scheduling. + */ + @Test + public void scheduledWithManagedExecutorServiceExecutor() throws Exception { + final AtomicInteger counter = new AtomicInteger(); + // This method references the default MES (not MSES) as executor + final CompletableFuture future = reqBean.scheduledWithMESExecutor(2, counter); + + assertNotNull("Future should be returned even when executor is MES", future); + final Integer result = future.get(15, TimeUnit.SECONDS); + assertEquals("Should complete after 2 runs", Integer.valueOf(2), result); + } + // --- Bean --- public enum ReturnType { @@ -257,6 +274,18 @@ public CompletableFuture scheduledMultipleSchedules(final int runs, fina return future; } + @Asynchronous(executor = "java:comp/DefaultManagedExecutorService", + runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture scheduledWithMESExecutor(final int runs, final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + if (count < runs) { + return null; + } + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(count); + return future; + } + @Asynchronous(executor = "java:comp/env/invalid/executor", runAt = @Schedule(cron = "* * * * * *")) public CompletableFuture scheduledInvalidExecutor() { From d05eb5dd8a73e742fc891ca154423a76ecaa7809 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 19:34:16 +0200 Subject: [PATCH 12/20] Rewrite scheduled async with manual trigger loop and context preservation Two fixes for the last Web-profile TCK failures: 1. Context propagation: when executor is a plain MES (not MSES), extract the MES's ContextServiceImpl and compose a temporary MSES that uses the MES's context service with the default MSES's thread pool. This preserves third-party context propagation (e.g. StringContext). 2. Thread pool starvation: replace mses.schedule(Callable, Trigger) with a manual trigger loop that schedules directly on the delegate ScheduledExecutorService. This avoids TriggerTask's double context wrapping and thread consumption between trigger fires. --- .../concurrency/AsynchronousInterceptor.java | 182 ++++++++++++++---- 1 file changed, 144 insertions(+), 38 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 01569d9b5d3..263e34aaa55 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -18,8 +18,8 @@ import jakarta.annotation.Priority; import jakarta.enterprise.concurrent.Asynchronous; +import jakarta.enterprise.concurrent.LastExecution; import jakarta.enterprise.concurrent.ManagedExecutorService; -import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; import jakarta.enterprise.concurrent.Schedule; import jakarta.enterprise.concurrent.ZonedTrigger; import jakarta.interceptor.AroundInvoke; @@ -28,19 +28,27 @@ import org.apache.openejb.core.ivm.naming.NamingException; import org.apache.openejb.resource.thread.ManagedExecutorServiceImplFactory; import org.apache.openejb.resource.thread.ManagedScheduledExecutorServiceImplFactory; +import org.apache.openejb.threads.impl.ContextServiceImpl; +import org.apache.openejb.threads.impl.ManagedExecutorServiceImpl; +import org.apache.openejb.threads.impl.ManagedScheduledExecutorServiceImpl; import org.apache.openejb.util.LogCategory; import org.apache.openejb.util.Logger; import java.lang.annotation.Annotation; import java.lang.reflect.Method; +import java.time.Duration; +import java.time.ZoneId; +import java.time.ZonedDateTime; import java.util.Arrays; +import java.util.Date; import java.util.Map; -import java.util.concurrent.Callable; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @Interceptor @@ -113,25 +121,14 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final Schedule[] schedules) throws Exception { // Per spec, the executor attribute may reference either a ManagedScheduledExecutorService // or a plain ManagedExecutorService. When a plain MES is referenced, fall back to the - // default MSES for scheduling capability (the trigger mechanism requires MSES). - ManagedScheduledExecutorService mses; - try { - mses = ManagedScheduledExecutorServiceImplFactory.lookup(asynchronous.executor()); - } catch (final IllegalArgumentException e) { - // The executor might be a plain ManagedExecutorService — verify it exists, - // then use the default MSES for scheduling - try { - ManagedExecutorServiceImplFactory.lookup(asynchronous.executor()); - // MES exists — use default MSES for scheduling - mses = ManagedScheduledExecutorServiceImplFactory.lookup( - "java:comp/DefaultManagedScheduledExecutorService"); - } catch (final Exception fallbackEx) { - throw new RejectedExecutionException("Cannot lookup executor for scheduled async method", e); - } - } + // default MSES for scheduling capability but preserve the MES's context service. + final ManagedScheduledExecutorServiceImpl mses = resolveMses(asynchronous.executor()); final ZonedTrigger trigger = ScheduleHelper.toTrigger(schedules); final boolean isVoid = ctx.getMethod().getReturnType() == Void.TYPE; + final ContextServiceImpl ctxService = (ContextServiceImpl) mses.getContextService(); + final ContextServiceImpl.Snapshot snapshot = ctxService.snapshot(null); + final ScheduledExecutorService delegate = mses.getDelegate(); // A single CompletableFuture represents ALL executions in the schedule. // Per spec: "A single future represents the completion of all executions in the schedule." @@ -141,51 +138,160 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro // - the future is completed (via Asynchronous.Result.complete()) or cancelled final CompletableFuture outerFuture = mses.newIncompleteFuture(); final AtomicReference> scheduledRef = new AtomicReference<>(); + final AtomicReference lastExecutionRef = new AtomicReference<>(); + + // Schedule the first execution via the manual trigger loop + scheduleNextExecution(delegate, snapshot, ctxService, trigger, outerFuture, + ctx, isVoid, scheduledRef, lastExecutionRef); + + // Cancel the underlying scheduled task when the future completes externally + // (e.g. Asynchronous.Result.complete() or cancel()) + outerFuture.whenComplete((final Object val, final Throwable err) -> { + final ScheduledFuture sf = scheduledRef.get(); + if (sf != null) { + sf.cancel(false); + } + }); + + return isVoid ? null : outerFuture; + } + + private ManagedScheduledExecutorServiceImpl resolveMses(final String executorName) { + try { + return ManagedScheduledExecutorServiceImplFactory.lookup(executorName); + } catch (final IllegalArgumentException e) { + // The executor might be a plain ManagedExecutorService — verify it exists, + // then use the default MSES for scheduling with the MES's context service + try { + final ManagedExecutorServiceImpl plainMes = ManagedExecutorServiceImplFactory.lookup(executorName); + final ContextServiceImpl mesContextService = (ContextServiceImpl) plainMes.getContextService(); + final ManagedScheduledExecutorServiceImpl defaultMses = + ManagedScheduledExecutorServiceImplFactory.lookup("java:comp/DefaultManagedScheduledExecutorService"); + return new ManagedScheduledExecutorServiceImpl(defaultMses.getDelegate(), mesContextService); + } catch (final Exception fallbackEx) { + throw new RejectedExecutionException("Cannot lookup executor for scheduled async method", e); + } + } + } + + private void scheduleNextExecution(final ScheduledExecutorService delegate, final ContextServiceImpl.Snapshot snapshot, + final ContextServiceImpl ctxService, final ZonedTrigger trigger, + final CompletableFuture future, final InvocationContext ctx, + final boolean isVoid, final AtomicReference> scheduledRef, + final AtomicReference lastExecutionRef) { + final ZonedDateTime taskScheduledTime = ZonedDateTime.now(); + final ZonedDateTime nextRun = trigger.getNextRunTime(lastExecutionRef.get(), taskScheduledTime); + if (nextRun == null || future.isDone()) { + return; + } + + final long delayMs = Duration.between(ZonedDateTime.now(), nextRun).toMillis(); + + final ScheduledFuture sf = delegate.schedule(() -> { + if (future.isDone()) { + return; + } - final ScheduledFuture scheduledFuture = mses.schedule((Callable) () -> { + final ContextServiceImpl.State state = ctxService.enter(snapshot); try { - Asynchronous.Result.setFuture(outerFuture); + if (trigger.skipRun(lastExecutionRef.get(), nextRun)) { + // Skipped — reschedule for the next run + scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, + ctx, isVoid, scheduledRef, lastExecutionRef); + return; + } + + final ZonedDateTime runStart = ZonedDateTime.now(); + Asynchronous.Result.setFuture(future); final Object result = ctx.proceed(); + final ZonedDateTime runEnd = ZonedDateTime.now(); + + // Track last execution for trigger computation + lastExecutionRef.set(new SimpleLastExecution(taskScheduledTime, runStart, runEnd, result)); if (isVoid) { Asynchronous.Result.setFuture(null); - return null; + scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, + ctx, isVoid, scheduledRef, lastExecutionRef); + return; } // Per spec: non-null return value stops the schedule if (result != null) { - if (result instanceof CompletionStage cs && result != outerFuture) { - cs.whenComplete((val, err) -> { + if (result instanceof CompletionStage cs && result != future) { + cs.whenComplete((final Object val, final Throwable err) -> { if (err != null) { - outerFuture.completeExceptionally(err); + future.completeExceptionally(err); } else { - outerFuture.complete(val); + future.complete(val); } - Asynchronous.Result.setFuture(null); }); } Asynchronous.Result.setFuture(null); - - // Cancel the trigger loop — method returned non-null - final ScheduledFuture sf = scheduledRef.get(); - if (sf != null) { - sf.cancel(false); - } + // Don't reschedule — method returned non-null + return; } + + Asynchronous.Result.setFuture(null); // null return: schedule continues + scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, + ctx, isVoid, scheduledRef, lastExecutionRef); } catch (final Exception e) { - outerFuture.completeExceptionally(e); + future.completeExceptionally(e); Asynchronous.Result.setFuture(null); + } finally { + ctxService.exit(state); } + }, Math.max(0, delayMs), TimeUnit.MILLISECONDS); + + scheduledRef.set(sf); + } + + /** + * Simple {@link LastExecution} implementation for tracking execution history + * within the manual trigger loop. + */ + private record SimpleLastExecution(ZonedDateTime scheduledStart, ZonedDateTime runStart, + ZonedDateTime runEnd, Object result) implements LastExecution { + @Override + public String getIdentityName() { return null; - }, trigger); + } - scheduledRef.set(scheduledFuture); + @Override + public Object getResult() { + return result; + } - // Also cancel when the future completes externally (e.g. Asynchronous.Result.complete()) - outerFuture.whenComplete((val, err) -> scheduledFuture.cancel(false)); + @Override + public Date getScheduledStart() { + return Date.from(scheduledStart.toInstant()); + } - return isVoid ? null : outerFuture; + @Override + public ZonedDateTime getScheduledStart(final ZoneId zone) { + return scheduledStart.withZoneSameInstant(zone); + } + + @Override + public Date getRunStart() { + return Date.from(runStart.toInstant()); + } + + @Override + public ZonedDateTime getRunStart(final ZoneId zone) { + return runStart.withZoneSameInstant(zone); + } + + @Override + public Date getRunEnd() { + return Date.from(runEnd.toInstant()); + } + + @Override + public ZonedDateTime getRunEnd(final ZoneId zone) { + return runEnd.withZoneSameInstant(zone); + } } private Exception validate(final Method method) { From 75cefe7e61cb43e192cbc2c307a0d6d74917c800 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 20:17:21 +0200 Subject: [PATCH 13/20] Use default MSES delegate for scheduled async trigger loop Per spec, scheduled async methods are not subject to maxAsync constraints. Use the default MSES's thread pool for the trigger loop instead of the referenced executor's pool. This prevents thread starvation when maxAsync threads are busy with blocking tasks. --- .../cdi/concurrency/AsynchronousInterceptor.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 263e34aaa55..5fef6731e22 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -128,7 +128,13 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final boolean isVoid = ctx.getMethod().getReturnType() == Void.TYPE; final ContextServiceImpl ctxService = (ContextServiceImpl) mses.getContextService(); final ContextServiceImpl.Snapshot snapshot = ctxService.snapshot(null); - final ScheduledExecutorService delegate = mses.getDelegate(); + + // Per spec, scheduled async methods are NOT subject to maxAsync constraints. + // Use the default MSES's delegate for the trigger loop — it is not constrained + // by the referenced executor's maxAsync setting. + final ManagedScheduledExecutorServiceImpl defaultMses = + ManagedScheduledExecutorServiceImplFactory.lookup("java:comp/DefaultManagedScheduledExecutorService"); + final ScheduledExecutorService triggerDelegate = defaultMses.getDelegate(); // A single CompletableFuture represents ALL executions in the schedule. // Per spec: "A single future represents the completion of all executions in the schedule." @@ -140,8 +146,7 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final AtomicReference> scheduledRef = new AtomicReference<>(); final AtomicReference lastExecutionRef = new AtomicReference<>(); - // Schedule the first execution via the manual trigger loop - scheduleNextExecution(delegate, snapshot, ctxService, trigger, outerFuture, + scheduleNextExecution(triggerDelegate, snapshot, ctxService, trigger, outerFuture, ctx, isVoid, scheduledRef, lastExecutionRef); // Cancel the underlying scheduled task when the future completes externally From 0439ef5ab5b6df8de389827f4273c305d1b93a62 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 20:57:51 +0200 Subject: [PATCH 14/20] Filter Concurrency TCK to Web profile using JUnit 5 tag Replace !eefull with web tag to properly exclude Full/EJB profile tests. The TCK 3.1.1 uses JUnit 5 @Tag annotations: @Web has tags web+platform, @Platform has only platform. Filtering on web includes Core, Standalone, and Web tests while excluding Platform-only (Full). --- tck/concurrency-standalone/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tck/concurrency-standalone/pom.xml b/tck/concurrency-standalone/pom.xml index 09b5afb9071..f762ba44105 100644 --- a/tck/concurrency-standalone/pom.xml +++ b/tck/concurrency-standalone/pom.xml @@ -177,7 +177,7 @@ ee/jakarta/tck/concurrent/spec/signature/** - !eefull + web src/test/resources/logging.properties ${project.build.directory}/jimage From 456ee84c2587baffcaa7596f5ed640c765fafb03 Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 21:24:25 +0200 Subject: [PATCH 15/20] Add qualifier and virtual DD element support for Concurrency 3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add qualifier field (List) to ContextService, ManagedExecutor, ManagedScheduledExecutor, ManagedThreadFactory DD model classes - Update SXC JAXB accessors to parse and XML elements (virtual was in the model but missing from SXC parsers) - Fix NPE in Convert*Definitions when is absent in deployment descriptor — defaults to java:comp/DefaultContextService - Add unit tests for null context service fallback and qualifier model - Add Arquillian test deploying WAR with web.xml containing --- .../DeploymentDescriptorConcurrencyTest.java | 109 ++++++++++++++++++ ...vertManagedExecutorServiceDefinitions.java | 4 +- ...edScheduledExecutorServiceDefinitions.java | 4 +- ...onvertManagedThreadFactoryDefinitions.java | 4 +- .../config/ConvertVirtualDefinitionsTest.java | 76 ++++++++++++ .../openejb/jee/ContextService$JAXB.java | 30 ++++- .../openejb/jee/ManagedExecutor$JAXB.java | 42 ++++++- .../jee/ManagedScheduledExecutor$JAXB.java | 42 ++++++- .../jee/ManagedThreadFactory$JAXB.java | 42 ++++++- .../apache/openejb/jee/ContextService.java | 14 +++ .../apache/openejb/jee/ManagedExecutor.java | 15 +++ .../openejb/jee/ManagedScheduledExecutor.java | 15 +++ .../openejb/jee/ManagedThreadFactory.java | 15 +++ 13 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/DeploymentDescriptorConcurrencyTest.java diff --git a/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/DeploymentDescriptorConcurrencyTest.java b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/DeploymentDescriptorConcurrencyTest.java new file mode 100644 index 00000000000..956e33d05ab --- /dev/null +++ b/arquillian/arquillian-tomee-tests/arquillian-tomee-webprofile-tests/src/test/java/org/apache/openejb/arquillian/tests/concurrency/DeploymentDescriptorConcurrencyTest.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.arquillian.tests.concurrency; + +import jakarta.annotation.Resource; +import jakarta.enterprise.concurrent.ManagedExecutorService; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; +import jakarta.enterprise.concurrent.ManagedThreadFactory; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.jboss.arquillian.container.test.api.Deployment; +import org.jboss.arquillian.junit.Arquillian; +import org.jboss.shrinkwrap.api.ArchivePaths; +import org.jboss.shrinkwrap.api.ShrinkWrap; +import org.jboss.shrinkwrap.api.asset.EmptyAsset; +import org.jboss.shrinkwrap.api.asset.StringAsset; +import org.jboss.shrinkwrap.api.spec.WebArchive; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Arquillian test verifying that web.xml deployment descriptors with + * {@code } and {@code } elements deploy successfully. + * This tests the SXC JAXB accessor parsing for Concurrency 3.1 DD elements. + */ +@RunWith(Arquillian.class) +public class DeploymentDescriptorConcurrencyTest { + + private static final String WEB_XML = + "\n" + + "\n" + + "\n" + + " \n" + + " java:app/concurrent/DDThreadFactory\n" + + " true\n" + + " \n" + + "\n" + + " \n" + + " java:app/concurrent/DDExecutor\n" + + " false\n" + + " \n" + + "\n" + + " \n" + + " java:app/concurrent/DDScheduledExecutor\n" + + " false\n" + + " \n" + + "\n" + + "\n"; + + @Inject + private DDBean ddBean; + + @Deployment + public static WebArchive createDeployment() { + return ShrinkWrap.create(WebArchive.class, "DDConcurrencyTest.war") + .addClasses(DDBean.class) + .setWebXML(new StringAsset(WEB_XML)) + .addAsWebInfResource(EmptyAsset.INSTANCE, ArchivePaths.create("beans.xml")); + } + + @Test + public void deploymentSucceeds() { + // If we get here, the web.xml with parsed successfully + assertNotNull("DDBean should be injected", ddBean); + } + + @Test + public void ddDefinedExecutorWorks() throws Exception { + final boolean completed = ddBean.runOnDDExecutor(); + assertTrue("Task should run on DD-defined executor", completed); + } + + @ApplicationScoped + public static class DDBean { + + @Resource(lookup = "java:app/concurrent/DDExecutor") + private ManagedExecutorService executor; + + public boolean runOnDDExecutor() throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + executor.execute(latch::countDown); + return latch.await(5, TimeUnit.SECONDS); + } + } +} diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java index d0d9f9575ef..c55e2cd4899 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java @@ -78,7 +78,9 @@ private Resource toResource(final ManagedExecutor managedExecutor) { final Properties p = def.getProperties(); - String contextName = managedExecutor.getContextService().getvalue(); + String contextName = managedExecutor.getContextService() != null + ? managedExecutor.getContextService().getvalue() + : "java:comp/DefaultContextService"; // Translate JNDI name to TomEE Resource ID, otherwise AutoConfig will fail to resolve it // and try to fix it by rewriting this to an unwanted ContextService if ("java:comp/DefaultContextService".equals(contextName)) { diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java index e3be56fa90f..cb0e738c695 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java @@ -77,7 +77,9 @@ private Resource toResource(final ManagedScheduledExecutor managedScheduledExecu def.setJndi(managedScheduledExecutor.getName().getvalue().replaceFirst("java:", "")); - String contextName = managedScheduledExecutor.getContextService().getvalue(); + String contextName = managedScheduledExecutor.getContextService() != null + ? managedScheduledExecutor.getContextService().getvalue() + : "java:comp/DefaultContextService"; // Translate JNDI name to TomEE Resource ID, otherwise AutoConfig will fail to resolve it // and try to fix it by rewriting this to an unwanted ContextService if ("java:comp/DefaultContextService".equals(contextName)) { diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java index 0defa1aa359..3e1c936db41 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java @@ -77,7 +77,9 @@ private Resource toResource(final ManagedThreadFactory managedThreadFactory) { def.setJndi(managedThreadFactory.getName().getvalue().replaceFirst("java:", "")); - String contextName = managedThreadFactory.getContextService().getvalue(); + String contextName = managedThreadFactory.getContextService() != null + ? managedThreadFactory.getContextService().getvalue() + : "java:comp/DefaultContextService"; // Translate JNDI name to TomEE Resource ID, otherwise AutoConfig will fail to resolve it // and try to fix it by rewriting this to an unwanted ContextService if ("java:comp/DefaultContextService".equals(contextName)) { diff --git a/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java b/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java index 28a6b3b512f..3686c55af52 100644 --- a/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java +++ b/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java @@ -101,6 +101,82 @@ public void scheduledExecutorVirtualTrue() throws OpenEJBException { assertEquals("true", resources.get(0).getProperties().getProperty("Virtual")); } + @Test + public void threadFactoryWithNullContextServiceDefaults() throws OpenEJBException { + final ManagedThreadFactory factory = new ManagedThreadFactory(); + factory.setName(jndi("java:comp/env/concurrent/NoCtxTF")); + // contextService intentionally NOT set — should default to DefaultContextService + factory.setPriority(5); + + final AppModule appModule = createAppModuleWithThreadFactory(factory); + new ConvertManagedThreadFactoryDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertEquals("Default Context Service", + resources.get(0).getProperties().getProperty("Context")); + } + + @Test + public void executorWithNullContextServiceDefaults() throws OpenEJBException { + final ManagedExecutor executor = new ManagedExecutor(); + executor.setName(jndi("java:comp/env/concurrent/NoCtxMES")); + // contextService intentionally NOT set + + final AppModule appModule = createAppModuleWithExecutor(executor); + new ConvertManagedExecutorServiceDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertEquals("Default Context Service", + resources.get(0).getProperties().getProperty("Context")); + } + + @Test + public void scheduledExecutorWithNullContextServiceDefaults() throws OpenEJBException { + final ManagedScheduledExecutor executor = new ManagedScheduledExecutor(); + executor.setName(jndi("java:comp/env/concurrent/NoCtxMSES")); + // contextService intentionally NOT set + + final AppModule appModule = createAppModuleWithScheduledExecutor(executor); + new ConvertManagedScheduledExecutorServiceDefinitions().deploy(appModule); + + final List resources = new ArrayList<>(appModule.getResources()); + assertEquals(1, resources.size()); + assertEquals("Default Context Service", + resources.get(0).getProperties().getProperty("Context")); + } + + @Test + public void threadFactoryQualifierIsPreserved() { + final ManagedThreadFactory factory = new ManagedThreadFactory(); + factory.setName(jndi("java:comp/env/concurrent/QualifiedTF")); + factory.setContextService(jndi("java:comp/DefaultContextService")); + + final List qualifiers = new ArrayList<>(); + qualifiers.add("com.example.MyQualifier"); + qualifiers.add("com.example.AnotherQualifier"); + factory.setQualifier(qualifiers); + + assertEquals(2, factory.getQualifier().size()); + assertEquals("com.example.MyQualifier", factory.getQualifier().get(0)); + assertEquals("com.example.AnotherQualifier", factory.getQualifier().get(1)); + } + + @Test + public void executorQualifierIsPreserved() { + final ManagedExecutor executor = new ManagedExecutor(); + executor.setName(jndi("java:comp/env/concurrent/QualifiedMES")); + executor.setContextService(jndi("java:comp/DefaultContextService")); + + final List qualifiers = new ArrayList<>(); + qualifiers.add("com.example.ExecutorQualifier"); + executor.setQualifier(qualifiers); + + assertEquals(1, executor.getQualifier().size()); + assertEquals("com.example.ExecutorQualifier", executor.getQualifier().get(0)); + } + // --- helpers --- private static JndiName jndi(final String value) { diff --git a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ContextService$JAXB.java b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ContextService$JAXB.java index febbfe0602e..93bacbc8a98 100644 --- a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ContextService$JAXB.java +++ b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ContextService$JAXB.java @@ -88,6 +88,7 @@ public static final ContextService _read(XoXMLStreamReader reader, RuntimeContex List cleared = null; List propagated = null; List unchanged = null; + List qualifier = null; List property = null; // Check xsi:type @@ -183,6 +184,18 @@ public static final ContextService _read(XoXMLStreamReader reader, RuntimeContex } } unchanged.add(unchangedItem); + } else if (("qualifier" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: qualifier + String qualifierItem = elementReader.getElementText(); + if (qualifier == null) { + qualifier = contextService.qualifier; + if (qualifier!= null) { + qualifier.clear(); + } else { + qualifier = new ArrayList<>(); + } + } + qualifier.add(qualifierItem); } else if (("property" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { // ELEMENT: property Property propertyItem = readProperty(elementReader, context); @@ -196,7 +209,7 @@ public static final ContextService _read(XoXMLStreamReader reader, RuntimeContex } property.add(propertyItem); } else { - context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "cleared"), new QName("http://java.sun.com/xml/ns/javaee", "propagated"), new QName("http://java.sun.com/xml/ns/javaee", "unchanged"), new QName("http://java.sun.com/xml/ns/javaee", "property")); + context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "cleared"), new QName("http://java.sun.com/xml/ns/javaee", "propagated"), new QName("http://java.sun.com/xml/ns/javaee", "unchanged"), new QName("http://java.sun.com/xml/ns/javaee", "qualifier"), new QName("http://java.sun.com/xml/ns/javaee", "property")); } } if (cleared!= null) { @@ -208,6 +221,9 @@ public static final ContextService _read(XoXMLStreamReader reader, RuntimeContex if (unchanged!= null) { contextService.unchanged = unchanged; } + if (qualifier!= null) { + contextService.qualifier = qualifier; + } if (property!= null) { contextService.property = property; } @@ -328,6 +344,18 @@ public static final void _write(XoXMLStreamWriter writer, ContextService context } } + // ELEMENT: qualifier + List qualifier = contextService.qualifier; + if (qualifier!= null) { + for (String qualifierItem: qualifier) { + if (qualifierItem!= null) { + writer.writeStartElement(prefix, "qualifier", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(qualifierItem); + writer.writeEndElement(); + } + } + } + // ELEMENT: property List property = contextService.property; if (property!= null) { diff --git a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedExecutor$JAXB.java b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedExecutor$JAXB.java index 5174c14ef88..90dedd6298a 100644 --- a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedExecutor$JAXB.java +++ b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedExecutor$JAXB.java @@ -84,6 +84,7 @@ public static final ManagedExecutor _read(XoXMLStreamReader reader, RuntimeConte ManagedExecutor managedExecutor = new ManagedExecutor(); context.beforeUnmarshal(managedExecutor, LifecycleCallback.NONE); + List qualifier = null; List properties = null; // Check xsi:type @@ -123,6 +124,22 @@ public static final ManagedExecutor _read(XoXMLStreamReader reader, RuntimeConte // ELEMENT: maxAsync Integer maxAsync = Integer.valueOf(elementReader.getElementText()); managedExecutor.maxAsync = maxAsync; + } else if (("virtual" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: virtual + Boolean virtual = Boolean.valueOf(elementReader.getElementText()); + managedExecutor.virtual = virtual; + } else if (("qualifier" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: qualifier + String qualifierItem = elementReader.getElementText(); + if (qualifier == null) { + qualifier = managedExecutor.qualifier; + if (qualifier!= null) { + qualifier.clear(); + } else { + qualifier = new ArrayList<>(); + } + } + qualifier.add(qualifierItem); } else if (("properties" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { // ELEMENT: properties Property propertiesItem = readProperty(elementReader, context); @@ -136,9 +153,12 @@ public static final ManagedExecutor _read(XoXMLStreamReader reader, RuntimeConte } properties.add(propertiesItem); } else { - context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "context-service-ref"), new QName("http://java.sun.com/xml/ns/javaee", "hung-task-threshold"), new QName("http://java.sun.com/xml/ns/javaee", "max-async"), new QName("http://java.sun.com/xml/ns/javaee", "properties")); + context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "context-service-ref"), new QName("http://java.sun.com/xml/ns/javaee", "hung-task-threshold"), new QName("http://java.sun.com/xml/ns/javaee", "max-async"), new QName("http://java.sun.com/xml/ns/javaee", "virtual"), new QName("http://java.sun.com/xml/ns/javaee", "qualifier"), new QName("http://java.sun.com/xml/ns/javaee", "properties")); } } + if (qualifier!= null) { + managedExecutor.qualifier = qualifier; + } if (properties!= null) { managedExecutor.properties = properties; } @@ -215,6 +235,26 @@ public static final void _write(XoXMLStreamWriter writer, ManagedExecutor manage writer.writeEndElement(); } + // ELEMENT: virtual + Boolean virtual = managedExecutor.virtual; + if (virtual!= null) { + writer.writeStartElement(prefix, "virtual", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(Boolean.toString(virtual)); + writer.writeEndElement(); + } + + // ELEMENT: qualifier + List qualifier = managedExecutor.qualifier; + if (qualifier!= null) { + for (String qualifierItem: qualifier) { + if (qualifierItem!= null) { + writer.writeStartElement(prefix, "qualifier", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(qualifierItem); + writer.writeEndElement(); + } + } + } + // ELEMENT: properties List properties = managedExecutor.properties; if (properties!= null) { diff --git a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor$JAXB.java b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor$JAXB.java index 56ede4b696d..f72b03558b7 100644 --- a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor$JAXB.java +++ b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor$JAXB.java @@ -84,6 +84,7 @@ public static final ManagedScheduledExecutor _read(XoXMLStreamReader reader, Run ManagedScheduledExecutor managedScheduledExecutor = new ManagedScheduledExecutor(); context.beforeUnmarshal(managedScheduledExecutor, LifecycleCallback.NONE); + List qualifier = null; List properties = null; // Check xsi:type @@ -123,6 +124,22 @@ public static final ManagedScheduledExecutor _read(XoXMLStreamReader reader, Run // ELEMENT: maxAsync Integer maxAsync = Integer.valueOf(elementReader.getElementText()); managedScheduledExecutor.maxAsync = maxAsync; + } else if (("virtual" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: virtual + Boolean virtual = Boolean.valueOf(elementReader.getElementText()); + managedScheduledExecutor.virtual = virtual; + } else if (("qualifier" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: qualifier + String qualifierItem = elementReader.getElementText(); + if (qualifier == null) { + qualifier = managedScheduledExecutor.qualifier; + if (qualifier!= null) { + qualifier.clear(); + } else { + qualifier = new ArrayList<>(); + } + } + qualifier.add(qualifierItem); } else if (("property" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { // ELEMENT: properties Property propertiesItem = readProperty(elementReader, context); @@ -136,9 +153,12 @@ public static final ManagedScheduledExecutor _read(XoXMLStreamReader reader, Run } properties.add(propertiesItem); } else { - context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "context-service-ref"), new QName("http://java.sun.com/xml/ns/javaee", "hung-task-threshold"), new QName("http://java.sun.com/xml/ns/javaee", "max-async"), new QName("http://java.sun.com/xml/ns/javaee", "property")); + context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "context-service-ref"), new QName("http://java.sun.com/xml/ns/javaee", "hung-task-threshold"), new QName("http://java.sun.com/xml/ns/javaee", "max-async"), new QName("http://java.sun.com/xml/ns/javaee", "virtual"), new QName("http://java.sun.com/xml/ns/javaee", "qualifier"), new QName("http://java.sun.com/xml/ns/javaee", "property")); } } + if (qualifier!= null) { + managedScheduledExecutor.qualifier = qualifier; + } if (properties!= null) { managedScheduledExecutor.properties = properties; } @@ -215,6 +235,26 @@ public static final void _write(XoXMLStreamWriter writer, ManagedScheduledExecut writer.writeEndElement(); } + // ELEMENT: virtual + Boolean virtual = managedScheduledExecutor.virtual; + if (virtual!= null) { + writer.writeStartElement(prefix, "virtual", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(Boolean.toString(virtual)); + writer.writeEndElement(); + } + + // ELEMENT: qualifier + List qualifier = managedScheduledExecutor.qualifier; + if (qualifier!= null) { + for (String qualifierItem: qualifier) { + if (qualifierItem!= null) { + writer.writeStartElement(prefix, "qualifier", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(qualifierItem); + writer.writeEndElement(); + } + } + } + // ELEMENT: properties List properties = managedScheduledExecutor.properties; if (properties!= null) { diff --git a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedThreadFactory$JAXB.java b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedThreadFactory$JAXB.java index 3abc843d507..29be77ce830 100644 --- a/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedThreadFactory$JAXB.java +++ b/container/openejb-jee-accessors/src/main/java/org/apache/openejb/jee/ManagedThreadFactory$JAXB.java @@ -84,6 +84,7 @@ public static final ManagedThreadFactory _read(XoXMLStreamReader reader, Runtime ManagedThreadFactory managedThreadFactory = new ManagedThreadFactory(); context.beforeUnmarshal(managedThreadFactory, LifecycleCallback.NONE); + List qualifier = null; List properties = null; // Check xsi:type @@ -119,6 +120,22 @@ public static final ManagedThreadFactory _read(XoXMLStreamReader reader, Runtime // ELEMENT: priority Integer priority = Integer.valueOf(elementReader.getElementText()); managedThreadFactory.priority = priority; + } else if (("virtual" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: virtual + Boolean virtual = Boolean.valueOf(elementReader.getElementText()); + managedThreadFactory.virtual = virtual; + } else if (("qualifier" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { + // ELEMENT: qualifier + String qualifierItem = elementReader.getElementText(); + if (qualifier == null) { + qualifier = managedThreadFactory.qualifier; + if (qualifier!= null) { + qualifier.clear(); + } else { + qualifier = new ArrayList<>(); + } + } + qualifier.add(qualifierItem); } else if (("property" == elementReader.getLocalName())&&("http://java.sun.com/xml/ns/javaee" == elementReader.getNamespaceURI())) { // ELEMENT: properties Property propertiesItem = readProperty(elementReader, context); @@ -132,9 +149,12 @@ public static final ManagedThreadFactory _read(XoXMLStreamReader reader, Runtime } properties.add(propertiesItem); } else { - context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "context-service-ref"), new QName("http://java.sun.com/xml/ns/javaee", "priority"), new QName("http://java.sun.com/xml/ns/javaee", "property")); + context.unexpectedElement(elementReader, new QName("http://java.sun.com/xml/ns/javaee", "description"), new QName("http://java.sun.com/xml/ns/javaee", "name"), new QName("http://java.sun.com/xml/ns/javaee", "context-service-ref"), new QName("http://java.sun.com/xml/ns/javaee", "priority"), new QName("http://java.sun.com/xml/ns/javaee", "virtual"), new QName("http://java.sun.com/xml/ns/javaee", "qualifier"), new QName("http://java.sun.com/xml/ns/javaee", "property")); } } + if (qualifier!= null) { + managedThreadFactory.qualifier = qualifier; + } if (properties!= null) { managedThreadFactory.properties = properties; } @@ -203,6 +223,26 @@ public static final void _write(XoXMLStreamWriter writer, ManagedThreadFactory m writer.writeEndElement(); } + // ELEMENT: virtual + Boolean virtual = managedThreadFactory.virtual; + if (virtual!= null) { + writer.writeStartElement(prefix, "virtual", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(Boolean.toString(virtual)); + writer.writeEndElement(); + } + + // ELEMENT: qualifier + List qualifier = managedThreadFactory.qualifier; + if (qualifier!= null) { + for (String qualifierItem: qualifier) { + if (qualifierItem!= null) { + writer.writeStartElement(prefix, "qualifier", "http://java.sun.com/xml/ns/javaee"); + writer.writeCharacters(qualifierItem); + writer.writeEndElement(); + } + } + } + // ELEMENT: properties List properties = managedThreadFactory.properties; if (properties!= null) { diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ContextService.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ContextService.java index 6df8c37abeb..ecce0a8fd11 100755 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ContextService.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ContextService.java @@ -68,6 +68,7 @@ "cleared", "propagated", "unchanged", + "qualifier", "property" }) public class ContextService implements Keyable{ @@ -83,6 +84,8 @@ public class ContextService implements Keyable{ @XmlElement protected List unchanged; @XmlElement + protected List qualifier; + @XmlElement protected List property; @XmlAttribute(name = "id") @XmlJavaTypeAdapter(CollapsedStringAdapter.class) @@ -225,6 +228,17 @@ public List getUnchanged() { return this.unchanged; } + public List getQualifier() { + if (qualifier == null) { + qualifier = new ArrayList<>(); + } + return this.qualifier; + } + + public void setQualifier(final List qualifier) { + this.qualifier = qualifier; + } + /** * Gets the value of the property property. * diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java index a20405cd201..28c22da8984 100755 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedExecutor.java @@ -22,6 +22,7 @@ import jakarta.xml.bind.annotation.XmlType; import org.apache.openejb.jee.jba.JndiName; +import java.util.ArrayList; import java.util.List; @XmlAccessorType(XmlAccessType.FIELD) @@ -32,6 +33,7 @@ "hungTaskThreshold", "maxAsync", "virtual", + "qualifier", "properties" }) public class ManagedExecutor implements Keyable { @@ -47,6 +49,8 @@ public class ManagedExecutor implements Keyable { protected Integer maxAsync; @XmlElement protected Boolean virtual; + @XmlElement + protected List qualifier; @XmlElement(name = "properties") protected List properties; @@ -98,6 +102,17 @@ public void setVirtual(final Boolean virtual) { this.virtual = virtual; } + public List getQualifier() { + if (qualifier == null) { + qualifier = new ArrayList<>(); + } + return this.qualifier; + } + + public void setQualifier(final List qualifier) { + this.qualifier = qualifier; + } + public List getProperties() { return properties; } diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java index ce0648c8e03..3c937261997 100644 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedScheduledExecutor.java @@ -22,6 +22,7 @@ import jakarta.xml.bind.annotation.XmlType; import org.apache.openejb.jee.jba.JndiName; +import java.util.ArrayList; import java.util.List; @@ -33,6 +34,7 @@ "hungTaskThreshold", "maxAsync", "virtual", + "qualifier", "properties" }) public class ManagedScheduledExecutor implements Keyable { @@ -48,6 +50,8 @@ public class ManagedScheduledExecutor implements Keyable { protected Integer maxAsync; @XmlElement protected Boolean virtual; + @XmlElement + protected List qualifier; @XmlElement(name = "property") protected List properties; @@ -99,6 +103,17 @@ public void setVirtual(final Boolean virtual) { this.virtual = virtual; } + public List getQualifier() { + if (qualifier == null) { + qualifier = new ArrayList<>(); + } + return this.qualifier; + } + + public void setQualifier(final List qualifier) { + this.qualifier = qualifier; + } + public List getProperties() { return properties; } diff --git a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java index efba33cf2dd..1d29a7a7887 100644 --- a/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java +++ b/container/openejb-jee/src/main/java/org/apache/openejb/jee/ManagedThreadFactory.java @@ -22,6 +22,7 @@ import jakarta.xml.bind.annotation.XmlType; import org.apache.openejb.jee.jba.JndiName; +import java.util.ArrayList; import java.util.List; @XmlAccessorType(XmlAccessType.FIELD) @@ -31,6 +32,7 @@ "contextService", "priority", "virtual", + "qualifier", "properties" }) public class ManagedThreadFactory implements Keyable { @@ -44,6 +46,8 @@ public class ManagedThreadFactory implements Keyable { protected Integer priority; @XmlElement protected Boolean virtual; + @XmlElement + protected List qualifier; @XmlElement(name = "property") protected List properties; @@ -87,6 +91,17 @@ public void setVirtual(final Boolean virtual) { this.virtual = virtual; } + public List getQualifier() { + if (qualifier == null) { + qualifier = new ArrayList<>(); + } + return this.qualifier; + } + + public void setQualifier(final List qualifier) { + this.qualifier = qualifier; + } + public List getProperties() { return properties; } From a40d71b727d54566e995c7463c13a81077250ada Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Thu, 2 Apr 2026 21:33:35 +0200 Subject: [PATCH 16/20] Allow virtual thread factory to work with ForkJoinPool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ForkJoinWorkerThread extends Thread (platform) and cannot be virtual. Fall back to a platform ManagedForkJoinWorkerThread instead of throwing UnsupportedOperationException. The TCK expects ForkJoinPool to function with virtual factories — the worker threads are platform but the pool still operates correctly. m> --- .../threads/impl/ManagedThreadFactoryImpl.java | 5 ++--- .../impl/ManagedThreadFactoryVirtualTest.java | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java index 0fb5b6efc2e..b92d1c53558 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java @@ -75,9 +75,8 @@ public Thread newThread(final Runnable r) { @Override public ForkJoinWorkerThread newThread(final ForkJoinPool pool) { - if (virtual) { - throw new UnsupportedOperationException("Virtual thread factory does not support ForkJoinPool threads"); - } + // ForkJoinWorkerThread extends Thread (platform) — cannot be virtual. + // For virtual factories, fall back to a platform ForkJoinWorkerThread. return new ManagedForkJoinWorkerThread(pool, priority, contextService); } diff --git a/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java index 0729d9c8362..2be1789cd2e 100644 --- a/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java @@ -29,6 +29,7 @@ import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -91,14 +92,23 @@ public void virtualThreadExecutesTask() { } } - @Test(expected = UnsupportedOperationException.class) - public void virtualFactoryRejectsForkJoinPool() { + @Test + public void virtualFactoryFallsBackToPlatformForForkJoinPool() { Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); final ContextServiceImpl contextService = ContextServiceImplFactory.newDefaultContextService(); final ManagedThreadFactoryImpl factory = new ManagedThreadFactoryImpl("test-vt-", null, contextService, true); - factory.newThread(new ForkJoinPool()); + // ForkJoinWorkerThread cannot be virtual — should fall back to platform thread + final ForkJoinPool pool = new ForkJoinPool(1, factory, null, false); + try { + final java.util.concurrent.Future result = pool.submit(() -> "ok"); + assertEquals("ForkJoinPool should work with virtual factory", "ok", result.get(5, java.util.concurrent.TimeUnit.SECONDS)); + } catch (final Exception e) { + fail("ForkJoinPool with virtual factory should not throw: " + e); + } finally { + pool.shutdown(); + } } @Test From 93da38e8f77cfff263870621576d51873c60521f Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Fri, 3 Apr 2026 20:30:34 +0200 Subject: [PATCH 17/20] Add CDI qualifier support for Concurrency 3.1 resource definitions Register concurrency resources (ManagedExecutorService, ManagedScheduledExecutorService, ManagedThreadFactory, ContextService) as CDI beans with qualifier support per Concurrency 3.1 spec Section 5.4.1. - ConcurrencyCDIExtension: CDI extension that observes AfterBeanDiscovery and creates synthetic ApplicationScoped beans for resources with qualifiers. Also registers default beans (@Default/@Any) for all four concurrency resource types. - AnnotationDeployer: Extract qualifiers() from @ManagedExecutorDefinition, @ManagedScheduledExecutorDefinition, @ManagedThreadFactoryDefinition, @ContextServiceDefinition annotations into JEE model objects. - Convert*Definitions: Pass Qualifiers as comma-separated Resource property. - OptimizedLoaderService: Register ConcurrencyCDIExtension alongside JMS2CDIExtension. TCK Web profile: 196/196 passing (0 failures, 0 errors). --- .../openejb/cdi/OptimizedLoaderService.java | 2 + .../concurrency/ConcurrencyCDIExtension.java | 530 ++++++++++++++++++ .../openejb/config/AnnotationDeployer.java | 24 + .../ConvertContextServiceDefinitions.java | 3 + ...vertManagedExecutorServiceDefinitions.java | 5 + ...edScheduledExecutorServiceDefinitions.java | 5 + ...onvertManagedThreadFactoryDefinitions.java | 5 + .../ConcurrencyCDIExtensionTest.java | 184 ++++++ 8 files changed, 758 insertions(+) create mode 100644 container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtension.java create mode 100644 container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtensionTest.java diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java index 815eba8f0d8..99f3ec075d8 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/OptimizedLoaderService.java @@ -126,6 +126,8 @@ protected List loadExtensions(final ClassLoader classLoader list.add(new JMS2CDIExtension()); } + list.add(new org.apache.openejb.cdi.concurrency.ConcurrencyCDIExtension()); + final Collection extensionCopy = new ArrayList<>(list); final Iterator it = list.iterator(); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtension.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtension.java new file mode 100644 index 00000000000..1ff05d711d8 --- /dev/null +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtension.java @@ -0,0 +1,530 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.cdi.concurrency; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.Default; +import jakarta.enterprise.inject.spi.AfterBeanDiscovery; +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.enterprise.inject.spi.Extension; +import jakarta.enterprise.util.Nonbinding; +import jakarta.inject.Qualifier; +import org.apache.openejb.AppContext; +import org.apache.openejb.assembler.classic.OpenEjbConfiguration; +import org.apache.openejb.assembler.classic.ResourceInfo; +import org.apache.openejb.loader.SystemInstance; +import org.apache.openejb.spi.ContainerSystem; +import org.apache.openejb.util.LogCategory; +import org.apache.openejb.util.Logger; +import org.apache.webbeans.config.WebBeansContext; + +import javax.naming.InitialContext; +import javax.naming.NamingException; +import java.lang.annotation.Annotation; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * CDI extension that registers concurrency resources as CDI beans + * with qualifier support per Concurrency 3.1 spec (Section 5.4.1). + * + *

Resources defined via {@code @ManagedExecutorDefinition} (and similar) + * or deployment descriptor {@code } elements that specify + * {@code qualifiers} become injectable via {@code @Inject @MyQualifier}. + * + *

Default resources (e.g. {@code java:comp/DefaultManagedExecutorService}) + * are always registered with {@code @Default} and {@code @Any} qualifiers. + */ +public class ConcurrencyCDIExtension implements Extension { + + private static final Logger logger = Logger.getInstance(LogCategory.OPENEJB.createChild("cdi"), ConcurrencyCDIExtension.class); + + private static final String QUALIFIERS_PROPERTY = "Qualifiers"; + + private static final String DEFAULT_MES_JNDI = "java:comp/DefaultManagedExecutorService"; + private static final String DEFAULT_MSES_JNDI = "java:comp/DefaultManagedScheduledExecutorService"; + private static final String DEFAULT_MTF_JNDI = "java:comp/DefaultManagedThreadFactory"; + private static final String DEFAULT_CS_JNDI = "java:comp/DefaultContextService"; + + private static final String DEFAULT_MES_ID = "Default Executor Service"; + private static final String DEFAULT_MSES_ID = "Default Scheduled Executor Service"; + private static final String DEFAULT_MTF_ID = "Default Managed Thread Factory"; + private static final String DEFAULT_CS_ID = "Default Context Service"; + + private enum ResourceKind { + MANAGED_EXECUTOR(jakarta.enterprise.concurrent.ManagedExecutorService.class), + MANAGED_SCHEDULED_EXECUTOR(jakarta.enterprise.concurrent.ManagedScheduledExecutorService.class), + MANAGED_THREAD_FACTORY(jakarta.enterprise.concurrent.ManagedThreadFactory.class), + CONTEXT_SERVICE(jakarta.enterprise.concurrent.ContextService.class); + + private final Class type; + + ResourceKind(final Class type) { + this.type = type; + } + } + + void registerBeans(@Observes final AfterBeanDiscovery afterBeanDiscovery, final BeanManager beanManager) { + final OpenEjbConfiguration openEjbConfiguration = SystemInstance.get().getComponent(OpenEjbConfiguration.class); + if (openEjbConfiguration == null || openEjbConfiguration.facilities == null) { + return; + } + + final List resources = openEjbConfiguration.facilities.resources; + final Set currentAppIds = findCurrentAppIds(); + + for (final ResourceInfo resource : resources) { + if (!isVisibleInCurrentApp(resource, currentAppIds)) { + continue; + } + + final ResourceKind resourceKind = findResourceKind(resource); + if (resourceKind == null) { + continue; + } + + final List qualifierNames = parseQualifiers(resource); + if (qualifierNames.isEmpty()) { + continue; + } + + // Spec: qualifiers must not be used with java:global names + if (isJavaGlobalName(resource.jndiName)) { + afterBeanDiscovery.addDefinitionError(new IllegalArgumentException(resourceKind.type.getName() + + " with qualifiers must not use a java:global name: " + normalizeJndiName(resource.jndiName))); + continue; + } + + final Set qualifiers = validateAndCreateQualifiers(qualifierNames, resourceKind, afterBeanDiscovery); + if (qualifiers == null) { + continue; + } + + logger.info("Registering CDI bean for " + resourceKind.type.getSimpleName() + + " resource '" + resource.id + "' with qualifiers " + qualifierNames); + addQualifiedBean(afterBeanDiscovery, resourceKind.type, resource.id, qualifiers); + } + + // Register default beans with @Default + @Any if no bean with @Default exists yet + registerDefaultBeanIfMissing(afterBeanDiscovery, beanManager, resources, + jakarta.enterprise.concurrent.ManagedExecutorService.class, DEFAULT_MES_JNDI, DEFAULT_MES_ID); + registerDefaultBeanIfMissing(afterBeanDiscovery, beanManager, resources, + jakarta.enterprise.concurrent.ManagedScheduledExecutorService.class, DEFAULT_MSES_JNDI, DEFAULT_MSES_ID); + registerDefaultBeanIfMissing(afterBeanDiscovery, beanManager, resources, + jakarta.enterprise.concurrent.ManagedThreadFactory.class, DEFAULT_MTF_JNDI, DEFAULT_MTF_ID); + registerDefaultBeanIfMissing(afterBeanDiscovery, beanManager, resources, + jakarta.enterprise.concurrent.ContextService.class, DEFAULT_CS_JNDI, DEFAULT_CS_ID); + } + + /** + * Validates qualifier class names per Concurrency 3.1 spec: + *

    + *
  • Must be loadable annotation types
  • + *
  • Must be annotated with {@code @Qualifier}
  • + *
  • All members must have default values
  • + *
  • All members must be annotated with {@code @Nonbinding}
  • + *
+ */ + private Set validateAndCreateQualifiers(final List qualifierNames, + final ResourceKind resourceKind, + final AfterBeanDiscovery afterBeanDiscovery) { + final Set qualifiers = new LinkedHashSet<>(); + qualifiers.add(Any.Literal.INSTANCE); + final ClassLoader loader = Thread.currentThread().getContextClassLoader(); + + for (final String qualifierName : qualifierNames) { + final Class qualifierClass; + try { + qualifierClass = loader.loadClass(qualifierName); + } catch (final ClassNotFoundException e) { + afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier class " + qualifierName + + " for " + resourceKind.type.getName() + " cannot be loaded", e)); + return null; + } + + if (!qualifierClass.isAnnotation()) { + afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier " + qualifierName + + " for " + resourceKind.type.getName() + " must be an annotation type")); + return null; + } + + @SuppressWarnings("unchecked") + final Class annotationClass = (Class) qualifierClass; + if (!annotationClass.isAnnotationPresent(Qualifier.class)) { + afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier " + qualifierName + + " for " + resourceKind.type.getName() + " must be annotated with @Qualifier")); + return null; + } + + for (final Method member : annotationClass.getDeclaredMethods()) { + if (member.getDefaultValue() == null) { + afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier " + qualifierName + + " for " + resourceKind.type.getName() + " must not declare members without defaults")); + return null; + } + if (!member.isAnnotationPresent(Nonbinding.class)) { + afterBeanDiscovery.addDefinitionError(new IllegalArgumentException("Qualifier " + qualifierName + + " for " + resourceKind.type.getName() + " must use @Nonbinding on member " + member.getName())); + return null; + } + } + + qualifiers.add(createQualifierAnnotation(annotationClass)); + } + + return qualifiers; + } + + private Annotation createQualifierAnnotation(final Class qualifierType) { + final Map values = new LinkedHashMap<>(); + for (final Method method : qualifierType.getDeclaredMethods()) { + values.put(method.getName(), method.getDefaultValue()); + } + + final InvocationHandler handler = (final Object proxy, final Method method, final Object[] args) -> { + final String name = method.getName(); + if ("annotationType".equals(name) && method.getParameterCount() == 0) { + return qualifierType; + } + if ("equals".equals(name) && method.getParameterCount() == 1) { + return annotationEquals(qualifierType, values, args[0]); + } + if ("hashCode".equals(name) && method.getParameterCount() == 0) { + return annotationHashCode(values); + } + if ("toString".equals(name) && method.getParameterCount() == 0) { + return annotationToString(qualifierType, values); + } + if (values.containsKey(name)) { + return values.get(name); + } + throw new IllegalStateException("Unsupported annotation method: " + method); + }; + + return Annotation.class.cast(Proxy.newProxyInstance( + qualifierType.getClassLoader(), + new Class[] { qualifierType }, + handler)); + } + + private boolean annotationEquals(final Class qualifierType, + final Map values, + final Object other) { + if (other == null || !qualifierType.isInstance(other)) { + return false; + } + for (final Map.Entry entry : values.entrySet()) { + try { + final Method method = qualifierType.getMethod(entry.getKey()); + if (!memberValueEquals(entry.getValue(), method.invoke(other))) { + return false; + } + } catch (final Exception e) { + return false; + } + } + return true; + } + + private int annotationHashCode(final Map values) { + int hash = 0; + for (final Map.Entry entry : values.entrySet()) { + hash += (127 * entry.getKey().hashCode()) ^ memberValueHashCode(entry.getValue()); + } + return hash; + } + + private String annotationToString(final Class qualifierType, + final Map values) { + final StringBuilder builder = new StringBuilder("@").append(qualifierType.getName()).append("("); + boolean first = true; + for (final Map.Entry entry : values.entrySet()) { + if (!first) { + builder.append(", "); + } + builder.append(entry.getKey()).append("=").append(entry.getValue()); + first = false; + } + return builder.append(")").toString(); + } + + private int memberValueHashCode(final Object value) { + final Class valueType = value.getClass(); + if (!valueType.isArray()) { + return value.hashCode(); + } + if (valueType == byte[].class) { + return Arrays.hashCode((byte[]) value); + } + if (valueType == short[].class) { + return Arrays.hashCode((short[]) value); + } + if (valueType == int[].class) { + return Arrays.hashCode((int[]) value); + } + if (valueType == long[].class) { + return Arrays.hashCode((long[]) value); + } + if (valueType == char[].class) { + return Arrays.hashCode((char[]) value); + } + if (valueType == float[].class) { + return Arrays.hashCode((float[]) value); + } + if (valueType == double[].class) { + return Arrays.hashCode((double[]) value); + } + if (valueType == boolean[].class) { + return Arrays.hashCode((boolean[]) value); + } + return Arrays.hashCode((Object[]) value); + } + + private boolean memberValueEquals(final Object left, final Object right) { + if (left == right) { + return true; + } + if (left == null || right == null) { + return false; + } + final Class valueType = left.getClass(); + if (!valueType.isArray()) { + return left.equals(right); + } + if (valueType == byte[].class) { + return Arrays.equals((byte[]) left, (byte[]) right); + } + if (valueType == short[].class) { + return Arrays.equals((short[]) left, (short[]) right); + } + if (valueType == int[].class) { + return Arrays.equals((int[]) left, (int[]) right); + } + if (valueType == long[].class) { + return Arrays.equals((long[]) left, (long[]) right); + } + if (valueType == char[].class) { + return Arrays.equals((char[]) left, (char[]) right); + } + if (valueType == float[].class) { + return Arrays.equals((float[]) left, (float[]) right); + } + if (valueType == double[].class) { + return Arrays.equals((double[]) left, (double[]) right); + } + if (valueType == boolean[].class) { + return Arrays.equals((boolean[]) left, (boolean[]) right); + } + return Arrays.equals((Object[]) left, (Object[]) right); + } + + private void addQualifiedBean(final AfterBeanDiscovery afterBeanDiscovery, + final Class type, + final String resourceId, + final Set qualifiers) { + afterBeanDiscovery.addBean() + .id("tomee.concurrency." + type.getName() + "#" + resourceId + "#" + qualifiers.hashCode()) + .beanClass(type) + .types(Object.class, type) + .qualifiers(qualifiers.toArray(new Annotation[0])) + .scope(ApplicationScoped.class) + .createWith(creationalContext -> lookupByResourceId(type, resourceId)); + } + + private void registerDefaultBeanIfMissing(final AfterBeanDiscovery afterBeanDiscovery, + final BeanManager beanManager, + final List resources, + final Class type, + final String jndiName, + final String defaultResourceId) { + if (!beanManager.getBeans(type, Default.Literal.INSTANCE).isEmpty()) { + return; + } + + final String resourceId = findResourceId(resources, type, jndiName, defaultResourceId); + logger.debug("Registering default CDI bean for " + type.getSimpleName() + " (resource '" + resourceId + "')"); + afterBeanDiscovery.addBean() + .id("tomee.concurrency.default." + type.getName() + "#" + resourceId) + .beanClass(type) + .types(Object.class, type) + .qualifiers(Default.Literal.INSTANCE, Any.Literal.INSTANCE) + .scope(ApplicationScoped.class) + .createWith(creationalContext -> lookupDefaultResource(type, jndiName, resourceId)); + } + + private String findResourceId(final List resources, + final Class type, + final String jndiName, + final String defaultResourceId) { + for (final ResourceInfo resource : resources) { + if (!isResourceType(resource, type)) { + continue; + } + final String normalized = normalizeJndiName(resource.jndiName); + if (Objects.equals(normalized, normalizeJndiName(jndiName))) { + return resource.id; + } + } + return defaultResourceId; + } + + private T lookupByResourceId(final Class type, final String resourceId) { + final ContainerSystem containerSystem = SystemInstance.get().getComponent(ContainerSystem.class); + if (containerSystem == null) { + throw new IllegalStateException("ContainerSystem is not available"); + } + + Object instance; + try { + instance = containerSystem.getJNDIContext().lookup("openejb/Resource/" + resourceId); + } catch (final NamingException firstFailure) { + try { + instance = containerSystem.getJNDIContext().lookup("openejb:Resource/" + resourceId); + } catch (final NamingException secondFailure) { + throw new IllegalStateException("Unable to lookup resource " + resourceId, secondFailure); + } + } + + if (!type.isInstance(instance)) { + throw new IllegalStateException("Resource " + resourceId + " is not of type " + type.getName() + + ", found " + (instance == null ? "null" : instance.getClass().getName())); + } + return type.cast(instance); + } + + private T lookupDefaultResource(final Class type, final String jndiName, final String resourceId) { + try { + return lookupByJndiName(type, jndiName); + } catch (final IllegalStateException firstFailure) { + try { + return lookupByResourceId(type, resourceId); + } catch (final IllegalStateException secondFailure) { + secondFailure.addSuppressed(firstFailure); + throw secondFailure; + } + } + } + + private T lookupByJndiName(final Class type, final String jndiName) { + final Object instance; + try { + instance = InitialContext.doLookup(jndiName); + } catch (final NamingException e) { + throw new IllegalStateException("Unable to lookup resource " + jndiName, e); + } + + if (!type.isInstance(instance)) { + throw new IllegalStateException("Resource " + jndiName + " is not of type " + type.getName() + + ", found " + (instance == null ? "null" : instance.getClass().getName())); + } + return type.cast(instance); + } + + private List parseQualifiers(final ResourceInfo resource) { + if (resource.properties == null) { + return List.of(); + } + final String value = resource.properties.getProperty(QUALIFIERS_PROPERTY); + if (value == null || value.isBlank()) { + return List.of(); + } + + final List qualifiers = new ArrayList<>(); + for (final String item : value.split(",")) { + final String qualifier = item.trim(); + if (!qualifier.isEmpty()) { + qualifiers.add(qualifier); + } + } + return qualifiers; + } + + private ResourceKind findResourceKind(final ResourceInfo resource) { + // Check MSES before MES since MSES extends MES + if (isResourceType(resource, jakarta.enterprise.concurrent.ManagedScheduledExecutorService.class)) { + return ResourceKind.MANAGED_SCHEDULED_EXECUTOR; + } + if (isResourceType(resource, jakarta.enterprise.concurrent.ManagedExecutorService.class)) { + return ResourceKind.MANAGED_EXECUTOR; + } + if (isResourceType(resource, jakarta.enterprise.concurrent.ManagedThreadFactory.class)) { + return ResourceKind.MANAGED_THREAD_FACTORY; + } + if (isResourceType(resource, jakarta.enterprise.concurrent.ContextService.class)) { + return ResourceKind.CONTEXT_SERVICE; + } + return null; + } + + private boolean isResourceType(final ResourceInfo resource, final Class type) { + return resource.types != null + && (resource.types.contains(type.getName()) || resource.types.contains(type.getSimpleName())); + } + + private boolean isJavaGlobalName(final String rawName) { + final String normalized = normalizeJndiName(rawName); + return normalized != null && normalized.startsWith("global/"); + } + + private String normalizeJndiName(final String rawName) { + if (rawName == null) { + return null; + } + return rawName.startsWith("java:") ? rawName.substring("java:".length()) : rawName; + } + + private boolean isVisibleInCurrentApp(final ResourceInfo resource, final Set currentAppIds) { + if (resource.originAppName == null || resource.originAppName.isEmpty()) { + return true; + } + return currentAppIds.contains(resource.originAppName); + } + + private Set findCurrentAppIds() { + final ContainerSystem containerSystem = SystemInstance.get().getComponent(ContainerSystem.class); + if (containerSystem == null) { + return Set.of(); + } + + final Set appIds = new LinkedHashSet<>(); + final ClassLoader tccl = Thread.currentThread().getContextClassLoader(); + final WebBeansContext currentWbc; + try { + currentWbc = WebBeansContext.currentInstance(); + } catch (final RuntimeException re) { + return Set.of(); + } + + for (final AppContext appContext : containerSystem.getAppContexts()) { + if (appContext.getWebBeansContext() == currentWbc || appContext.getClassLoader() == tccl) { + appIds.add(appContext.getId()); + } + } + return appIds; + } +} diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java b/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java index a81b9dd55e1..16c4d69a389 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/AnnotationDeployer.java @@ -4127,6 +4127,12 @@ private void buildContextServiceDefinition(final JndiConsumer consumer, final Co contextService.getUnchanged().addAll(Arrays.asList(definition.unchanged())); } + if (contextService.getQualifier().isEmpty() && definition.qualifiers().length > 0) { + for (final Class qualifier : definition.qualifiers()) { + contextService.getQualifier().add(qualifier.getName()); + } + } + consumer.getContextServiceMap().put(definition.name(), contextService); } @@ -4142,6 +4148,12 @@ private void buildManagedExecutorDefinition(final JndiConsumer consumer, final M managedExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); managedExecutor.setVirtual(definition.virtual() ? Boolean.TRUE : null); + if (managedExecutor.getQualifier().isEmpty() && definition.qualifiers().length > 0) { + for (final Class qualifier : definition.qualifiers()) { + managedExecutor.getQualifier().add(qualifier.getName()); + } + } + consumer.getManagedExecutorMap().put(definition.name(), managedExecutor); } @@ -4157,6 +4169,12 @@ private void buildManagedScheduledExecutorDefinition(final JndiConsumer consumer managedScheduledExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); managedScheduledExecutor.setVirtual(definition.virtual() ? Boolean.TRUE : null); + if (managedScheduledExecutor.getQualifier().isEmpty() && definition.qualifiers().length > 0) { + for (final Class qualifier : definition.qualifiers()) { + managedScheduledExecutor.getQualifier().add(qualifier.getName()); + } + } + consumer.getManagedScheduledExecutorMap().put(definition.name(), managedScheduledExecutor); } @@ -4171,6 +4189,12 @@ private void buildManagedThreadFactoryDefinition(final JndiConsumer consumer, Ma managedThreadFactory.setPriority(definition.priority()); managedThreadFactory.setVirtual(definition.virtual() ? Boolean.TRUE : null); + if (managedThreadFactory.getQualifier().isEmpty() && definition.qualifiers().length > 0) { + for (final Class qualifier : definition.qualifiers()) { + managedThreadFactory.getQualifier().add(qualifier.getName()); + } + } + consumer.getManagedThreadFactoryMap().put(definition.name(), managedThreadFactory); } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertContextServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertContextServiceDefinitions.java index 6fc60d12d9d..d037f7b3084 100755 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertContextServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertContextServiceDefinitions.java @@ -88,6 +88,9 @@ private Resource toResource(final ContextService contextService) { put(p, "Propagated", Join.join(",", contextService.getPropagated())); put(p, "Cleared", Join.join(",", contextService.getCleared())); put(p, "Unchanged", Join.join(",", contextService.getUnchanged())); + if (contextService.getQualifier() != null && !contextService.getQualifier().isEmpty()) { + put(p, "Qualifiers", Join.join(",", contextService.getQualifier())); + } // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java index c55e2cd4899..7e226c7b2d5 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedExecutorServiceDefinitions.java @@ -23,6 +23,8 @@ import org.apache.openejb.jee.ManagedExecutor; import org.apache.openejb.util.PropertyPlaceHolderHelper; +import org.apache.openejb.util.Join; + import java.util.List; import java.util.Map; import java.util.Properties; @@ -91,6 +93,9 @@ private Resource toResource(final ManagedExecutor managedExecutor) { put(p, "HungTaskThreshold", managedExecutor.getHungTaskThreshold()); put(p, "Max", managedExecutor.getMaxAsync()); put(p, "Virtual", managedExecutor.getVirtual()); + if (managedExecutor.getQualifier() != null && !managedExecutor.getQualifier().isEmpty()) { + put(p, "Qualifiers", Join.join(",", managedExecutor.getQualifier())); + } // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java index cb0e738c695..f9dee71be58 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedScheduledExecutorServiceDefinitions.java @@ -23,6 +23,8 @@ import org.apache.openejb.jee.ManagedScheduledExecutor; import org.apache.openejb.util.PropertyPlaceHolderHelper; +import org.apache.openejb.util.Join; + import java.util.List; import java.util.Map; import java.util.Properties; @@ -91,6 +93,9 @@ private Resource toResource(final ManagedScheduledExecutor managedScheduledExecu put(p, "HungTaskThreshold", managedScheduledExecutor.getHungTaskThreshold()); put(p, "Core", managedScheduledExecutor.getMaxAsync()); put(p, "Virtual", managedScheduledExecutor.getVirtual()); + if (managedScheduledExecutor.getQualifier() != null && !managedScheduledExecutor.getQualifier().isEmpty()) { + put(p, "Qualifiers", Join.join(",", managedScheduledExecutor.getQualifier())); + } // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java index 3e1c936db41..69fb38c88c2 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/config/ConvertManagedThreadFactoryDefinitions.java @@ -24,6 +24,8 @@ import org.apache.openejb.jee.ManagedThreadFactory; import org.apache.openejb.util.PropertyPlaceHolderHelper; +import org.apache.openejb.util.Join; + import java.util.List; import java.util.Map; import java.util.Properties; @@ -90,6 +92,9 @@ private Resource toResource(final ManagedThreadFactory managedThreadFactory) { put(p, "Context", contextName); put(p, "Priority", managedThreadFactory.getPriority()); put(p, "Virtual", managedThreadFactory.getVirtual()); + if (managedThreadFactory.getQualifier() != null && !managedThreadFactory.getQualifier().isEmpty()) { + put(p, "Qualifiers", Join.join(",", managedThreadFactory.getQualifier())); + } // to force it to be bound in JndiEncBuilder put(p, "JndiName", def.getJndi()); diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtensionTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtensionTest.java new file mode 100644 index 00000000000..e3e561f11d5 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ConcurrencyCDIExtensionTest.java @@ -0,0 +1,184 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.openejb.cdi.concurrency; + +import jakarta.enterprise.concurrent.ContextService; +import jakarta.enterprise.concurrent.ManagedExecutorDefinition; +import jakarta.enterprise.concurrent.ManagedExecutorService; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorDefinition; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; +import jakarta.enterprise.concurrent.ManagedThreadFactory; +import jakarta.enterprise.concurrent.ManagedThreadFactoryDefinition; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Default; +import jakarta.enterprise.util.Nonbinding; +import jakarta.inject.Inject; +import jakarta.inject.Qualifier; +import org.apache.openejb.jee.EnterpriseBean; +import org.apache.openejb.jee.SingletonBean; +import org.apache.openejb.junit.ApplicationComposer; +import org.apache.openejb.testing.Module; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that the {@link ConcurrencyCDIExtension} correctly registers + * concurrency resources as CDI beans, both with default and custom qualifiers. + */ +@RunWith(ApplicationComposer.class) +public class ConcurrencyCDIExtensionTest { + + @Inject + private DefaultInjectionBean defaultBean; + + @Inject + private QualifiedInjectionBean qualifiedBean; + + @Module + public EnterpriseBean ejb() { + return new SingletonBean(DummyEjb.class).localBean(); + } + + @Module + public Class[] beans() { + return new Class[]{ + DefaultInjectionBean.class, + QualifiedInjectionBean.class, + AppConfig.class + }; + } + + @Test + public void defaultManagedExecutorServiceIsInjectable() { + assertNotNull("Default ManagedExecutorService should be injectable via @Inject", + defaultBean.getMes()); + } + + @Test + public void defaultManagedScheduledExecutorServiceIsInjectable() { + assertNotNull("Default ManagedScheduledExecutorService should be injectable via @Inject", + defaultBean.getMses()); + } + + @Test + public void defaultManagedThreadFactoryIsInjectable() { + assertNotNull("Default ManagedThreadFactory should be injectable via @Inject", + defaultBean.getMtf()); + } + + @Test + public void defaultContextServiceIsInjectable() { + assertNotNull("Default ContextService should be injectable via @Inject", + defaultBean.getCs()); + } + + @Test + public void qualifiedManagedExecutorServiceIsInjectable() { + assertNotNull("Qualified ManagedExecutorService should be injectable via @Inject @TestQualifier", + qualifiedBean.getMes()); + } + + @Test + public void qualifiedManagedExecutorServiceExecutesTask() throws Exception { + final CountDownLatch latch = new CountDownLatch(1); + qualifiedBean.getMes().execute(latch::countDown); + assertTrue("Task should complete on qualified MES", + latch.await(5, TimeUnit.SECONDS)); + } + + // --- Dummy EJB to trigger full resource deployment --- + + @jakarta.ejb.Singleton + public static class DummyEjb { + } + + // --- Qualifier --- + + @Qualifier + @Retention(RetentionPolicy.RUNTIME) + @Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE}) + public @interface TestQualifier { + } + + // --- App config with qualifier-enabled definition --- + + @ManagedExecutorDefinition( + name = "java:comp/env/concurrent/TestQualifiedExecutor", + qualifiers = {TestQualifier.class} + ) + @ApplicationScoped + public static class AppConfig { + } + + // --- Bean that injects default concurrency resources --- + + @ApplicationScoped + public static class DefaultInjectionBean { + + @Inject + private ManagedExecutorService mes; + + @Inject + private ManagedScheduledExecutorService mses; + + @Inject + private ManagedThreadFactory mtf; + + @Inject + private ContextService cs; + + public ManagedExecutorService getMes() { + return mes; + } + + public ManagedScheduledExecutorService getMses() { + return mses; + } + + public ManagedThreadFactory getMtf() { + return mtf; + } + + public ContextService getCs() { + return cs; + } + } + + // --- Bean that injects qualified concurrency resources --- + + @ApplicationScoped + public static class QualifiedInjectionBean { + + @Inject + @TestQualifier + private ManagedExecutorService mes; + + public ManagedExecutorService getMes() { + return mes; + } + } +} From 5415cd4e38133a0f867c90e8fc76183190814a2f Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Fri, 3 Apr 2026 21:38:08 +0200 Subject: [PATCH 18/20] Silently fall back to platform threads when virtual=true on Java 17 Per Concurrency 3.1 spec: "When running on Java SE 17, the true value behaves the same as the false value and results in platform threads being created rather than virtual threads." Previously, virtual=true unconditionally called VirtualThreadHelper methods which throw UnsupportedOperationException on Java 17. Now checks VirtualThreadHelper.isSupported() first and falls through to platform thread creation when virtual threads are unavailable. --- .../openejb/cdi/concurrency/AsynchronousInterceptor.java | 5 +++-- .../resource/thread/ManagedExecutorServiceImplFactory.java | 4 +++- .../openejb/threads/impl/ManagedThreadFactoryImpl.java | 4 +++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 5fef6731e22..88a51dcf28d 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -130,8 +130,9 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final ContextServiceImpl.Snapshot snapshot = ctxService.snapshot(null); // Per spec, scheduled async methods are NOT subject to maxAsync constraints. - // Use the default MSES's delegate for the trigger loop — it is not constrained - // by the referenced executor's maxAsync setting. + // Use the default MSES's delegate for both the trigger timer and method execution — + // it is not constrained by the referenced executor's maxAsync setting. + // Context propagation (security, TX, etc.) is handled via ContextService.snapshot/enter/exit. final ManagedScheduledExecutorServiceImpl defaultMses = ManagedScheduledExecutorServiceImplFactory.lookup("java:comp/DefaultManagedScheduledExecutorService"); final ScheduledExecutorService triggerDelegate = defaultMses.getDelegate(); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java index f895defd7b8..5058e3a5a32 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/resource/thread/ManagedExecutorServiceImplFactory.java @@ -81,7 +81,9 @@ public ManagedExecutorServiceImpl create(final ContextServiceImpl contextService } private ExecutorService createExecutorService() { - if (virtual) { + // Per spec: "When running on Java SE 17, the true value behaves the same as the + // false value and results in platform threads being created rather than virtual threads." + if (virtual && VirtualThreadHelper.isSupported()) { final ThreadFactory vtFactory = VirtualThreadHelper.newVirtualThreadFactory(ManagedThreadFactoryImpl.DEFAULT_PREFIX); return VirtualThreadHelper.newVirtualThreadPerTaskExecutor(vtFactory); } diff --git a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java index b92d1c53558..f7690ccb7e0 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedThreadFactoryImpl.java @@ -55,7 +55,9 @@ public ManagedThreadFactoryImpl(final String prefix, final Integer priority, fin public Thread newThread(final Runnable r) { final CURunnable wrapper = new CURunnable(r, contextService); - if (virtual) { + // Per spec: "When running on Java SE 17, the true value behaves the same as the + // false value and results in platform threads being created rather than virtual threads." + if (virtual && VirtualThreadHelper.isSupported()) { // Virtual threads do NOT implement ManageableThread (spec 3.4.4) // Priority and daemon settings are ignored for virtual threads final Thread thread = VirtualThreadHelper.newVirtualThread(prefix, ID.incrementAndGet(), wrapper); From 675b2bc9f93c939042c1ef1447403673287b42be Mon Sep 17 00:00:00 2001 From: Richard Zowalla Date: Fri, 3 Apr 2026 21:55:40 +0200 Subject: [PATCH 19/20] Fix InvocationContext reuse in scheduled async and add missing license headers Replace ctx.proceed() with direct Method.invoke() in scheduled async re-executions. InvocationContext's interceptor iterator is single-use, so re-calling proceed() would bypass TX/security interceptors after the first scheduled execution. Context propagation is handled by ContextService.enter/exit. Also add Apache License headers to TCK resource files to fix RAT check. --- .../concurrency/AsynchronousInterceptor.java | 28 +++++++++++++++---- ...boss.arquillian.core.spi.LoadableExtension | 14 ++++++++++ .../src/test/resources/logging.properties | 15 ++++++++++ 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 88a51dcf28d..c12ca65ca1a 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java @@ -147,8 +147,15 @@ private Object aroundInvokeScheduled(final InvocationContext ctx, final Asynchro final AtomicReference> scheduledRef = new AtomicReference<>(); final AtomicReference lastExecutionRef = new AtomicReference<>(); + // Extract method and target from InvocationContext for direct invocation. + // We must NOT reuse ctx.proceed() — InvocationContext's interceptor iterator + // is single-use, so subsequent proceed() calls would bypass TX/security interceptors. + final Method beanMethod = ctx.getMethod(); + final Object target = ctx.getTarget(); + final Object[] params = ctx.getParameters(); + scheduleNextExecution(triggerDelegate, snapshot, ctxService, trigger, outerFuture, - ctx, isVoid, scheduledRef, lastExecutionRef); + beanMethod, target, params, isVoid, scheduledRef, lastExecutionRef); // Cancel the underlying scheduled task when the future completes externally // (e.g. Asynchronous.Result.complete() or cancel()) @@ -182,7 +189,8 @@ private ManagedScheduledExecutorServiceImpl resolveMses(final String executorNam private void scheduleNextExecution(final ScheduledExecutorService delegate, final ContextServiceImpl.Snapshot snapshot, final ContextServiceImpl ctxService, final ZonedTrigger trigger, - final CompletableFuture future, final InvocationContext ctx, + final CompletableFuture future, final Method beanMethod, + final Object target, final Object[] params, final boolean isVoid, final AtomicReference> scheduledRef, final AtomicReference lastExecutionRef) { final ZonedDateTime taskScheduledTime = ZonedDateTime.now(); @@ -203,13 +211,18 @@ private void scheduleNextExecution(final ScheduledExecutorService delegate, fina if (trigger.skipRun(lastExecutionRef.get(), nextRun)) { // Skipped — reschedule for the next run scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, - ctx, isVoid, scheduledRef, lastExecutionRef); + beanMethod, target, params, isVoid, scheduledRef, lastExecutionRef); return; } final ZonedDateTime runStart = ZonedDateTime.now(); Asynchronous.Result.setFuture(future); - final Object result = ctx.proceed(); + + // Invoke the bean method directly instead of ctx.proceed() — + // InvocationContext's interceptor iterator is single-use, so re-calling + // proceed() would bypass other interceptors (TX, security). + // Context propagation is handled by ContextService.enter/exit above. + final Object result = beanMethod.invoke(target, params); final ZonedDateTime runEnd = ZonedDateTime.now(); // Track last execution for trigger computation @@ -218,7 +231,7 @@ private void scheduleNextExecution(final ScheduledExecutorService delegate, fina if (isVoid) { Asynchronous.Result.setFuture(null); scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, - ctx, isVoid, scheduledRef, lastExecutionRef); + beanMethod, target, params, isVoid, scheduledRef, lastExecutionRef); return; } @@ -241,7 +254,10 @@ private void scheduleNextExecution(final ScheduledExecutorService delegate, fina Asynchronous.Result.setFuture(null); // null return: schedule continues scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, - ctx, isVoid, scheduledRef, lastExecutionRef); + beanMethod, target, params, isVoid, scheduledRef, lastExecutionRef); + } catch (final java.lang.reflect.InvocationTargetException e) { + future.completeExceptionally(e.getCause() != null ? e.getCause() : e); + Asynchronous.Result.setFuture(null); } catch (final Exception e) { future.completeExceptionally(e); Asynchronous.Result.setFuture(null); diff --git a/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension b/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension index d7e5ba24115..b2658b68872 100644 --- a/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension +++ b/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension @@ -1 +1,15 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. org.apache.tomee.tck.concurrency.ConcurrencyTCKExtension diff --git a/tck/concurrency-standalone/src/test/resources/logging.properties b/tck/concurrency-standalone/src/test/resources/logging.properties index 4969cd0964b..d21fcd5fb78 100644 --- a/tck/concurrency-standalone/src/test/resources/logging.properties +++ b/tck/concurrency-standalone/src/test/resources/logging.properties @@ -1,3 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + ## Logging configuration for Concurrency TCK handlers = java.util.logging.FileHandler, java.util.logging.ConsoleHandler From eca921868aaf14ee426d8a3f114e271524710153 Mon Sep 17 00:00:00 2001 From: Markus Jung Date: Sun, 12 Apr 2026 17:40:27 +0200 Subject: [PATCH 20/20] Add regression test for scheduled async CDI interceptors --- .../AsynchronousScheduledTest.java | 67 ++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java index 5e9c25cb8a4..b6b7bab6b4c 100644 --- a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java @@ -16,10 +16,15 @@ */ package org.apache.openejb.cdi.concurrency; +import jakarta.annotation.Priority; import jakarta.enterprise.concurrent.Asynchronous; import jakarta.enterprise.concurrent.Schedule; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InterceptorBinding; +import jakarta.interceptor.InvocationContext; import org.apache.openejb.jee.EnterpriseBean; import org.apache.openejb.jee.SingletonBean; import org.apache.openejb.junit.ApplicationComposer; @@ -27,11 +32,16 @@ import org.junit.Test; import org.junit.runner.RunWith; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; @RunWith(ApplicationComposer.class) @@ -48,7 +58,7 @@ public EnterpriseBean ejb() { @Module public Class[] beans() { - return new Class[]{ScheduledBean.class}; + return new Class[]{ScheduledBean.class, CountingInterceptor.class}; } @Test @@ -73,6 +83,23 @@ public void scheduledReturningMethodExecutes() throws Exception { + ScheduledBean.RETURNING_COUNTER.get(), reached); } + @Test + public void scheduledMethodExecutesThroughCdiInterceptor() throws Exception { + CountingInterceptor.INVOCATIONS.set(0); + assertEquals("Control invocation should go through the CDI interceptor", "ok", scheduledBean.directInterceptedCall()); + assertEquals("Control invocation should increment the CDI interceptor", 1, CountingInterceptor.INVOCATIONS.get()); + + ScheduledBean.INTERCEPTED_COUNTER.set(0); + CountingInterceptor.INVOCATIONS.set(0); + + final CompletableFuture future = scheduledBean.everySecondIntercepted(2); + final Integer result = future.get(15, TimeUnit.SECONDS); + + assertEquals("Scheduled method should complete after 2 runs", Integer.valueOf(2), result); + assertEquals("Business method should have been invoked twice", 2, ScheduledBean.INTERCEPTED_COUNTER.get()); + assertEquals("CDI interceptor should run for each scheduled firing", 2, CountingInterceptor.INVOCATIONS.get()); + } + @ApplicationScoped public static class ScheduledBean { static final AtomicInteger VOID_COUNTER = new AtomicInteger(); @@ -80,6 +107,7 @@ public static class ScheduledBean { static final AtomicInteger RETURNING_COUNTER = new AtomicInteger(); static final CountDownLatch RETURNING_LATCH = new CountDownLatch(1); + static final AtomicInteger INTERCEPTED_COUNTER = new AtomicInteger(); @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) public void everySecondVoid() { @@ -93,6 +121,43 @@ public CompletableFuture everySecondReturning() { RETURNING_LATCH.countDown(); return Asynchronous.Result.complete("done"); } + + @Counted + public String directInterceptedCall() { + return "ok"; + } + + @Counted + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture everySecondIntercepted(final int runs) { + final int count = INTERCEPTED_COUNTER.incrementAndGet(); + if (count < runs) { + return null; + } + + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(count); + return future; + } + } + + @InterceptorBinding + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Counted { + } + + @Interceptor + @Counted + @Priority(Interceptor.Priority.APPLICATION) + public static class CountingInterceptor { + static final AtomicInteger INVOCATIONS = new AtomicInteger(); + + @AroundInvoke + public Object aroundInvoke(final InvocationContext context) throws Exception { + INVOCATIONS.incrementAndGet(); + return context.proceed(); + } } @jakarta.ejb.Singleton