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/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/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/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/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/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/AsynchronousInterceptor.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/AsynchronousInterceptor.java index 04f6c46773d..90a26355877 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,51 +18,85 @@ import jakarta.annotation.Priority; import jakarta.enterprise.concurrent.Asynchronous; +import jakarta.enterprise.concurrent.LastExecution; import jakarta.enterprise.concurrent.ManagedExecutorService; +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.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.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 @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"; + private static final ScheduledAsyncInvoker SCHEDULED_ASYNC_INVOKER = new ScheduledAsyncInvoker(); // ensure validation logic required by the spec only runs once per invoked Method private final Map validationCache = new ConcurrentHashMap<>(); @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; + if (SCHEDULED_ASYNC_INVOKER.isReentry(ctx)) { + return ctx.proceed(); + } + + 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 +113,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 +122,209 @@ 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 { + // 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 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); + + // Run scheduled firings on the requested executor so the user's thread factory, + // priorities, and virtual-thread settings apply. Use the secondary pool so firings + // are not throttled by maxAsync (per Concurrency 3.1 §3.1). + final ScheduledExecutorService triggerDelegate = mses.getScheduledAsyncDelegate(); + + // A single CompletableFuture represents ALL executions in the schedule. + // 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<>(); + final AtomicReference lastExecutionRef = new AtomicReference<>(); + + final ScheduledAsyncInvoker.Invocation scheduledInvocation = SCHEDULED_ASYNC_INVOKER.capture(ctx); + + scheduleNextExecution(triggerDelegate, snapshot, ctxService, trigger, outerFuture, + scheduledInvocation, 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); + } + scheduledInvocation.release(); + }); + + 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"); + // Borrow the default MSES's secondary pool so this short-lived wrapper does not + // leak a fresh ScheduledThreadPoolExecutor per invocation. ownsScheduledAsyncDelegate=false. + return new ManagedScheduledExecutorServiceImpl(defaultMses.getDelegate(), mesContextService, + defaultMses.getScheduledAsyncDelegate(), false); + } 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 ScheduledAsyncInvoker.Invocation scheduledInvocation, + 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 ContextServiceImpl.State state = ctxService.enter(snapshot); + try { + if (trigger.skipRun(lastExecutionRef.get(), nextRun)) { + // Skipped — reschedule for the next run + scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, + scheduledInvocation, isVoid, scheduledRef, lastExecutionRef); + return; + } + + final ZonedDateTime runStart = ZonedDateTime.now(); + Asynchronous.Result.setFuture(future); + + final Object result = scheduledInvocation.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); + scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, + scheduledInvocation, isVoid, scheduledRef, lastExecutionRef); + return; + } + + // Per spec: non-null return value stops the schedule + if (result != null) { + if (result instanceof CompletionStage cs && result != future) { + cs.whenComplete((final Object val, final Throwable err) -> { + if (err != null) { + future.completeExceptionally(err); + } else { + future.complete(val); + } + }); + } + Asynchronous.Result.setFuture(null); + // Don't reschedule — method returned non-null + return; + } + + Asynchronous.Result.setFuture(null); + // null return: schedule continues + scheduleNextExecution(delegate, snapshot, ctxService, trigger, future, + scheduledInvocation, 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); + } 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; + } + + @Override + public Object getResult() { + return result; + } + + @Override + public Date getScheduledStart() { + return Date.from(scheduledStart.toInstant()); + } + + @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) { 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 +332,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/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/cdi/concurrency/ScheduleHelper.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java new file mode 100644 index 00000000000..7afa269e7cc --- /dev/null +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduleHelper.java @@ -0,0 +1,181 @@ +/* + * 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.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(schedule.months()); + } + if (schedule.daysOfMonth().length > 0) { + trigger.daysOfMonth(schedule.daysOfMonth()); + } + if (schedule.daysOfWeek().length > 0) { + trigger.daysOfWeek(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); + } + + /** + * 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/cdi/concurrency/ScheduledAsyncInvoker.java b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncInvoker.java new file mode 100644 index 00000000000..492721ce7f0 --- /dev/null +++ b/container/openejb-core/src/main/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncInvoker.java @@ -0,0 +1,233 @@ +/* + * 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.Dependent; +import jakarta.enterprise.context.spi.Context; +import jakarta.enterprise.inject.Any; +import jakarta.enterprise.inject.spi.AnnotatedType; +import jakarta.enterprise.inject.spi.Bean; +import jakarta.enterprise.inject.spi.Interceptor; +import jakarta.enterprise.inject.spi.PassivationCapable; +import jakarta.interceptor.InvocationContext; +import org.apache.webbeans.component.InjectionTargetBean; +import org.apache.webbeans.config.WebBeansContext; +import org.apache.webbeans.context.creational.CreationalContextImpl; +import org.apache.webbeans.intercept.InterceptorResolutionService; +import org.apache.webbeans.portable.InjectionTargetImpl; +import org.apache.webbeans.proxy.OwbInterceptorProxy; +import org.apache.webbeans.exception.ProxyGenerationException; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +final class ScheduledAsyncInvoker { + private static final ThreadLocal SCHEDULED_REENTRY = new ThreadLocal<>(); + + boolean isReentry(final InvocationContext ctx) { + final ScheduledReentry scheduledReentry = SCHEDULED_REENTRY.get(); + return scheduledReentry != null && scheduledReentry.matches(ctx); + } + + Invocation capture(final InvocationContext ctx) { + final Method beanMethod = ctx.getMethod(); + final Object target = ctx.getTarget(); + final Object[] params = ctx.getParameters().clone(); + final Object invocationTarget = resolveInvocationTarget(beanMethod, target); + + return new Invocation() { + @Override + public Object proceed() throws Exception { + return invoke(beanMethod, target, invocationTarget, params); + } + + @Override + public void release() { + if (invocationTarget instanceof ProxyHandle proxyHandle) { + proxyHandle.release(); + } + } + }; + } + + private Object resolveInvocationTarget(final Method beanMethod, final Object target) { + final ResolvedBean resolvedBean = resolveBean(beanMethod, target); + final Object contextualInstance = resolveContextualInstance(resolvedBean.bean()); + + if (contextualInstance instanceof OwbInterceptorProxy) { + return contextualInstance; + } + + final InterceptorResolutionService.BeanInterceptorInfo interceptorInfo = resolvedBean.injectionTarget().getInterceptorInfo(); + if (interceptorInfo.getDecorators() != null && !interceptorInfo.getDecorators().isEmpty()) { + throw new IllegalStateException("Scheduled async execution requires the current contextual instance " + + "to preserve decorators for " + beanMethod); + } + + return createProxyHandle(resolvedBean, target); + } + + private ResolvedBean resolveBean(final Method beanMethod, final Object target) { + final WebBeansContext webBeansContext = WebBeansContext.currentInstance(); + final Set> candidates = new LinkedHashSet<>( + webBeansContext.getBeanManagerImpl().getBeans(target.getClass(), Any.Literal.INSTANCE)); + candidates.addAll(webBeansContext.getBeanManagerImpl().getBeans(beanMethod.getDeclaringClass(), Any.Literal.INSTANCE)); + + ResolvedBean preferred = null; + ResolvedBean fallback = null; + for (final Bean candidate : candidates) { + if (!(candidate instanceof InjectionTargetBean injectionTargetBean)) { + continue; + } + if (!(injectionTargetBean.getInjectionTarget() instanceof InjectionTargetImpl injectionTarget)) { + continue; + } + + final InterceptorResolutionService.BeanInterceptorInfo interceptorInfo = injectionTarget.getInterceptorInfo(); + if (interceptorInfo == null || !interceptorInfo.getBusinessMethodsInfo().containsKey(beanMethod)) { + continue; + } + + final ResolvedBean resolvedBean = new ResolvedBean(injectionTargetBean, injectionTarget); + if (injectionTargetBean.getBeanClass().isInstance(target)) { + if (preferred != null && preferred.bean() != candidate) { + throw new IllegalStateException("Ambiguous CDI bean resolution for scheduled async method " + beanMethod); + } + preferred = resolvedBean; + continue; + } + + if (fallback != null && fallback.bean() != candidate) { + throw new IllegalStateException("Ambiguous CDI bean resolution for scheduled async method " + beanMethod); + } + fallback = resolvedBean; + } + + if (preferred != null) { + return preferred; + } + if (fallback != null) { + return fallback; + } + throw new IllegalStateException("Unable to resolve the CDI bean for scheduled async method " + beanMethod); + } + + private Object resolveContextualInstance(final Bean bean) { + if (bean.getScope() == Dependent.class) { + return null; + } + + final Context context = WebBeansContext.currentInstance().getBeanManagerImpl().getContext(bean.getScope()); + return context.get(bean); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private ProxyHandle createProxyHandle(final ResolvedBean resolvedBean, final Object target) { + final WebBeansContext webBeansContext = WebBeansContext.currentInstance(); + final InjectionTargetBean bean = resolvedBean.bean(); + final CreationalContextImpl creationalContext = webBeansContext.getBeanManagerImpl().createCreationalContext(bean); + final InterceptorResolutionService.BeanInterceptorInfo interceptorInfo = resolvedBean.injectionTarget().getInterceptorInfo(); + final InterceptorResolutionService interceptorResolutionService = webBeansContext.getInterceptorResolutionService(); + final Map, Object> interceptorInstances = interceptorResolutionService + .createInterceptorInstances(interceptorInfo, creationalContext); + final InterceptorResolutionService.MethodInterceptionPlan interceptionPlan = + new InterceptorResolutionService.MethodInterceptionPlan( + interceptorResolutionService.createMethodInterceptors(interceptorInfo), + interceptorResolutionService.createMethodInterceptorBindings(interceptorInfo)); + + final AnnotatedType annotatedType = bean.getAnnotatedType(); + final Class beanClass = annotatedType.getJavaClass(); + final ClassLoader classLoader = beanClass.getClassLoader(); + Class proxyClass = webBeansContext.getInterceptorDecoratorProxyFactory().getCachedProxyClass(bean); + if (proxyClass == null) { + try { + proxyClass = webBeansContext.getInterceptorDecoratorProxyFactory() + .createProxyClass(interceptorInfo, annotatedType, classLoader); + } catch (final ProxyGenerationException e) { + creationalContext.release(); + throw new IllegalStateException("Unable to build interceptor proxy for scheduled async method", e); + } + } + + final String passivationId = bean instanceof PassivationCapable + ? ((PassivationCapable) bean).getId() + : null; + final Object proxy = interceptorResolutionService.createProxiedInstance( + target, + creationalContext, + creationalContext, + interceptorInfo, + proxyClass, + interceptionPlan, + passivationId, + interceptorInstances, + cc -> false, + (instance, decorators) -> decorators); + return new ProxyHandle(proxy, creationalContext); + } + + private Object invoke(final Method beanMethod, final Object reentryTarget, + final Object invocationTarget, final Object[] params) throws Exception { + final ScheduledReentry previous = SCHEDULED_REENTRY.get(); + SCHEDULED_REENTRY.set(new ScheduledReentry(beanMethod, reentryTarget)); + try { + final Object target = invocationTarget instanceof ProxyHandle proxyHandle ? proxyHandle.proxy() : invocationTarget; + return beanMethod.invoke(target, params.clone()); + } catch (final InvocationTargetException e) { + final Throwable cause = e.getCause(); + if (cause instanceof Error error) { + throw error; + } + if (cause instanceof Exception exception) { + throw exception; + } + throw new IllegalStateException(cause); + } finally { + if (previous == null) { + SCHEDULED_REENTRY.remove(); + } else { + SCHEDULED_REENTRY.set(previous); + } + } + } + + interface Invocation { + Object proceed() throws Exception; + + default void release() { + // no-op + } + } + + private record ResolvedBean(InjectionTargetBean bean, InjectionTargetImpl injectionTarget) { + } + + private record ProxyHandle(Object proxy, CreationalContextImpl creationalContext) { + private void release() { + creationalContext.release(); + } + } + + private record ScheduledReentry(Method method, Object target) { + private boolean matches(final InvocationContext ctx) { + return method.equals(ctx.getMethod()) && target == ctx.getTarget(); + } + } +} 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..4326211d08b 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); } @@ -4134,12 +4140,35 @@ private void buildManagedExecutorDefinition(final JndiConsumer consumer, final M ManagedExecutor existing = consumer.getManagedExecutorMap().get(definition.name()); final ManagedExecutor managedExecutor = (existing != null) ? existing : new ManagedExecutor(); - managedExecutor.setName(new JndiName()); - managedExecutor.getName().setvalue(definition.name()); - managedExecutor.setContextService(new JndiName()); - managedExecutor.getContextService().setvalue(definition.context()); - managedExecutor.setHungTaskThreshold(definition.hungTaskThreshold()); - managedExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); + if (managedExecutor.getName() == null) { + final JndiName jndiName = new JndiName(); + jndiName.setvalue(definition.name()); + managedExecutor.setName(jndiName); + } + + if (managedExecutor.getContextService() == null) { + final JndiName contextName = new JndiName(); + contextName.setvalue(definition.context()); + managedExecutor.setContextService(contextName); + } + + if (managedExecutor.getHungTaskThreshold() == null) { + managedExecutor.setHungTaskThreshold(definition.hungTaskThreshold()); + } + + if (managedExecutor.getMaxAsync() == null) { + managedExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); + } + + if (managedExecutor.getVirtual() == null) { + 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); } @@ -4148,12 +4177,35 @@ private void buildManagedScheduledExecutorDefinition(final JndiConsumer consumer ManagedScheduledExecutor existing = consumer.getManagedScheduledExecutorMap().get(definition.name()); final ManagedScheduledExecutor managedScheduledExecutor = (existing != null) ? existing : new ManagedScheduledExecutor(); - managedScheduledExecutor.setName(new JndiName()); - managedScheduledExecutor.getName().setvalue(definition.name()); - managedScheduledExecutor.setContextService(new JndiName()); - managedScheduledExecutor.getContextService().setvalue(definition.context()); - managedScheduledExecutor.setHungTaskThreshold(definition.hungTaskThreshold()); - managedScheduledExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); + if (managedScheduledExecutor.getName() == null) { + final JndiName jndiName = new JndiName(); + jndiName.setvalue(definition.name()); + managedScheduledExecutor.setName(jndiName); + } + + if (managedScheduledExecutor.getContextService() == null) { + final JndiName contextName = new JndiName(); + contextName.setvalue(definition.context()); + managedScheduledExecutor.setContextService(contextName); + } + + if (managedScheduledExecutor.getHungTaskThreshold() == null) { + managedScheduledExecutor.setHungTaskThreshold(definition.hungTaskThreshold()); + } + + if (managedScheduledExecutor.getMaxAsync() == null) { + managedScheduledExecutor.setMaxAsync(definition.maxAsync() == -1 ? null : definition.maxAsync()); + } + + if (managedScheduledExecutor.getVirtual() == null) { + 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); } @@ -4162,11 +4214,31 @@ private void buildManagedThreadFactoryDefinition(final JndiConsumer consumer, Ma ManagedThreadFactory existing = consumer.getManagedThreadFactoryMap().get(definition.name()); final ManagedThreadFactory managedThreadFactory = (existing != null) ? existing : new ManagedThreadFactory(); - managedThreadFactory.setName(new JndiName()); - managedThreadFactory.getName().setvalue(definition.name()); - managedThreadFactory.setContextService(new JndiName()); - managedThreadFactory.getContextService().setvalue(definition.context()); - managedThreadFactory.setPriority(definition.priority()); + if (managedThreadFactory.getName() == null) { + final JndiName jndiName = new JndiName(); + jndiName.setvalue(definition.name()); + managedThreadFactory.setName(jndiName); + } + + if (managedThreadFactory.getContextService() == null) { + final JndiName contextName = new JndiName(); + contextName.setvalue(definition.context()); + managedThreadFactory.setContextService(contextName); + } + + if (managedThreadFactory.getPriority() == null) { + managedThreadFactory.setPriority(definition.priority()); + } + + if (managedThreadFactory.getVirtual() == null) { + 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 df131b30d9f..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; @@ -78,7 +80,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)) { @@ -88,6 +92,10 @@ 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()); + 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 c06c0383e4c..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; @@ -77,7 +79,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)) { @@ -88,6 +92,10 @@ 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()); + 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 95eb0a12c9b..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; @@ -77,7 +79,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)) { @@ -87,6 +91,10 @@ 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()); + 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/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..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 @@ -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,13 @@ public ManagedExecutorServiceImpl create(final ContextServiceImpl contextService } private ExecutorService createExecutorService() { + // 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); + } + final BlockingQueue blockingQueue; if (queue < 0) { blockingQueue = new LinkedBlockingQueue<>(); @@ -134,4 +144,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 771b160e205..77f026a8246 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; @@ -26,38 +28,118 @@ 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); + + 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 + final boolean isDefault = DEFAULT_MES.equals(name) || DEFAULT_MSES.equals(name); + if (DEFAULT_MES.equals(name)) { + name = DEFAULT_MSES; + } + + // 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(); + 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) { + return mses; + } + } catch (final NamingException ignored) { + // 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(); + } + + throw new IllegalArgumentException("Cannot find ManagedScheduledExecutorService with name '" + name + "'"); + } + private int core = 5; + private int scheduledAsyncCore = 5; private String threadFactory = ManagedThreadFactoryImpl.class.getName(); + private boolean virtual; private String context; public ManagedScheduledExecutorServiceImpl create(final ContextServiceImpl contextService) { - return new ManagedScheduledExecutorServiceImpl(createScheduledExecutorService(), contextService); + final Pools pools = createScheduledExecutorServicePools(); + return new ManagedScheduledExecutorServiceImpl(pools.primary, contextService, pools.secondary, true); } public ManagedScheduledExecutorServiceImpl create() { - return new ManagedScheduledExecutorServiceImpl(createScheduledExecutorService(), ContextServiceImplFactory.lookupOrDefault(context)); + final Pools pools = createScheduledExecutorServicePools(); + return new ManagedScheduledExecutorServiceImpl(pools.primary, ContextServiceImplFactory.lookupOrDefault(context), + pools.secondary, true); } - private ScheduledExecutorService createScheduledExecutorService() { + private Pools createScheduledExecutorServicePools() { ManagedThreadFactory managedThreadFactory; try { - managedThreadFactory = ThreadFactories.findThreadFactory(threadFactory); + // For the default factory, bypass reflective instantiation so the configured + // virtual flag is honored — the no-arg constructor hardcodes virtual=false. + managedThreadFactory = ManagedThreadFactoryImpl.class.getName().equals(threadFactory) ? + new ManagedThreadFactoryImpl(ManagedThreadFactoryImpl.DEFAULT_PREFIX, null, ContextServiceImplFactory.lookupOrDefault(context), virtual) : + 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); + // Primary pool — regular async submissions; corePoolSize follows maxAsync. + final ScheduledExecutorService primary = + new ScheduledThreadPoolExecutor(core, managedThreadFactory, CURejectHandler.INSTANCE); + // Secondary pool — scheduled @Asynchronous firings. Per Concurrency 3.1 §3.1 these + // must NOT be throttled by maxAsync, so size the pool independently while reusing + // the same stateless thread factory (preserves naming / virtual / priority). + final ScheduledExecutorService secondary = + new ScheduledThreadPoolExecutor(scheduledAsyncCore, managedThreadFactory, CURejectHandler.INSTANCE); + return new Pools(primary, secondary); } public void setCore(final int core) { this.core = core; } + public void setScheduledAsyncCore(final int scheduledAsyncCore) { + this.scheduledAsyncCore = scheduledAsyncCore; + } + + private record Pools(ScheduledExecutorService primary, ScheduledExecutorService secondary) { + } + public void setThreadFactory(final String threadFactory) { this.threadFactory = threadFactory; } @@ -66,7 +148,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/future/CUTriggerScheduledFuture.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/future/CUTriggerScheduledFuture.java index d154bbf0795..7b9e7a85bb1 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/future/CUTriggerScheduledFuture.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/future/CUTriggerScheduledFuture.java @@ -44,6 +44,17 @@ public boolean cancel(boolean mayInterruptIfRunning) { return super.cancel(mayInterruptIfRunning); } + @Override + public boolean isCancelled() { + // Also honour the TriggerTask's cancelled flag. The underlying delegate future + // reachable through the facade can point at the just-completed execution if + // cancel() raced ahead of the recursive scheduleNextRun(); in that case cancel() + // set cancelled=true on the TriggerTask but the delegate future reports done + // rather than cancelled. Without this override, isCancelled() would return false + // for a future the caller explicitly cancelled. + return super.isCancelled() || ((TriggerTask) listener).isCancelled(); + } + @Override public V get() throws InterruptedException, ExecutionException { V result = super.get(); diff --git a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorServiceImpl.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorServiceImpl.java index 469a8230582..ac46f19aa17 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorServiceImpl.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorServiceImpl.java @@ -27,11 +27,14 @@ import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; import jakarta.enterprise.concurrent.ManagedTask; import jakarta.enterprise.concurrent.Trigger; +import org.apache.openejb.util.LogCategory; +import org.apache.openejb.util.Logger; import java.lang.reflect.InvocationHandler; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.util.Date; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Callable; @@ -42,13 +45,25 @@ import java.util.concurrent.atomic.AtomicReference; public class ManagedScheduledExecutorServiceImpl extends ManagedExecutorServiceImpl implements ManagedScheduledExecutorService { + private static final Logger LOGGER = Logger.getInstance(LogCategory.OPENEJB, ManagedScheduledExecutorServiceImpl.class); + private final ScheduledExecutorService delegate; private final ContextServiceImpl contextService; + private final ScheduledExecutorService scheduledAsyncDelegate; + private final boolean ownsScheduledAsyncDelegate; public ManagedScheduledExecutorServiceImpl(final ScheduledExecutorService delegate, final ContextServiceImpl contextService) { + this(delegate, contextService, delegate, false); + } + + public ManagedScheduledExecutorServiceImpl(final ScheduledExecutorService delegate, final ContextServiceImpl contextService, + final ScheduledExecutorService scheduledAsyncDelegate, + final boolean ownsScheduledAsyncDelegate) { super(delegate, contextService); this.delegate = delegate; this.contextService = contextService; + this.scheduledAsyncDelegate = scheduledAsyncDelegate != null ? scheduledAsyncDelegate : delegate; + this.ownsScheduledAsyncDelegate = ownsScheduledAsyncDelegate; } @@ -137,6 +152,35 @@ public ScheduledExecutorService getDelegate() { return delegate; } + /** + * Secondary scheduling pool used to dispatch {@code @Asynchronous(runAt=@Schedule(...))} + * firings. Per Jakarta Concurrency 3.1 §3.1, scheduled asynchronous methods are not + * subject to {@code max-async}, so firings must not queue behind regular async work + * occupying the primary delegate's core threads. + */ + public ScheduledExecutorService getScheduledAsyncDelegate() { + return scheduledAsyncDelegate; + } + + @Override + public void destroyResource() { + if (ownsScheduledAsyncDelegate && scheduledAsyncDelegate != null && scheduledAsyncDelegate != delegate) { + final List leftover = scheduledAsyncDelegate.shutdownNow(); + if (!leftover.isEmpty()) { + LOGGER.warning(leftover.size() + " scheduled-async tasks to execute"); + for (final Runnable runnable : leftover) { + try { + LOGGER.info("Executing " + runnable); + runnable.run(); + } catch (final Throwable th) { + LOGGER.error(th.getMessage(), th); + } + } + } + } + super.destroyResource(); + } + /** * Automatically resolves an AtomicReference * @param delegate 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..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 @@ -31,22 +31,40 @@ 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); + + // 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); + thread.setContextClassLoader(ManagedThreadFactoryImpl.class.getClassLoader()); + return thread; + } + final Thread thread = new ManagedThread(wrapper); thread.setDaemon(true); thread.setName(prefix + ID.incrementAndGet()); @@ -59,9 +77,15 @@ public Thread newThread(final Runnable r) { @Override public ForkJoinWorkerThread newThread(final ForkJoinPool pool) { + // ForkJoinWorkerThread extends Thread (platform) — cannot be virtual. + // For virtual factories, fall back to a platform ForkJoinWorkerThread. 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 +126,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..9f1ae9821c8 --- /dev/null +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/impl/VirtualThreadHelper.java @@ -0,0 +1,148 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.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"); + + // 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/main/java/org/apache/openejb/threads/task/TriggerTask.java b/container/openejb-core/src/main/java/org/apache/openejb/threads/task/TriggerTask.java index ff092738206..aac1780505a 100644 --- a/container/openejb-core/src/main/java/org/apache/openejb/threads/task/TriggerTask.java +++ b/container/openejb-core/src/main/java/org/apache/openejb/threads/task/TriggerTask.java @@ -142,6 +142,10 @@ public void cancelScheduling() { } } + public boolean isCancelled() { + return cancelled; + } + private static class LastExecutionImpl implements LastExecution { private final String identityName; private final Object result; 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..49825855f3d --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTCKStyleTest.java @@ -0,0 +1,299 @@ +/* + * 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 + } + } + + /** + * 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); + } + + /** + * 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 { + 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(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/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() { + throw new UnsupportedOperationException("Should not reach here with invalid executor"); + } + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} 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..efb4c549225 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/AsynchronousScheduledTest.java @@ -0,0 +1,237 @@ +/* + * 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.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; +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.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +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) +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, CountingInterceptor.class, + TracingOuterInterceptor.class, TracingInnerInterceptor.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); + } + + @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()); + } + + @Test + public void scheduledMethodPreservesInterceptorOrderingOnEveryFiring() throws Exception { + ScheduledBean.TRACE.clear(); + ScheduledBean.TRACED_COUNTER.set(0); + + final CompletableFuture future = scheduledBean.tracedSchedule(2); + final Integer result = future.get(15, TimeUnit.SECONDS); + + assertEquals("Scheduled method should complete after 2 runs", Integer.valueOf(2), result); + + // Two firings, each must walk outer -> inner -> body in priority order. + final List expected = Arrays.asList( + "outer", "inner", "body", + "outer", "inner", "body"); + assertEquals("Interceptor chain must run in priority order on every firing", + expected, ScheduledBean.TRACE); + } + + @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); + static final AtomicInteger INTERCEPTED_COUNTER = new AtomicInteger(); + static final AtomicInteger TRACED_COUNTER = new AtomicInteger(); + static final List TRACE = new CopyOnWriteArrayList<>(); + + @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"); + } + + @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; + } + + @Traced + @Asynchronous(runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture tracedSchedule(final int runs) { + TRACE.add("body"); + final int count = TRACED_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(); + } + } + + @InterceptorBinding + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.RUNTIME) + public @interface Traced { + } + + @Interceptor + @Traced + @Priority(Interceptor.Priority.LIBRARY_BEFORE) + public static class TracingOuterInterceptor { + static final List TRACE = new CopyOnWriteArrayList<>(); + + @AroundInvoke + public Object aroundInvoke(final InvocationContext context) throws Exception { + TRACE.add("outer"); + ScheduledBean.TRACE.add("outer"); + return context.proceed(); + } + } + + @Interceptor + @Traced + @Priority(Interceptor.Priority.APPLICATION) + public static class TracingInnerInterceptor { + static final List TRACE = new CopyOnWriteArrayList<>(); + + @AroundInvoke + public Object aroundInvoke(final InvocationContext context) throws Exception { + TRACE.add("inner"); + ScheduledBean.TRACE.add("inner"); + return context.proceed(); + } + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} 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; + } + } +} 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; + } + }; + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncCustomFactoryTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncCustomFactoryTest.java new file mode 100644 index 00000000000..e9018a0f908 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncCustomFactoryTest.java @@ -0,0 +1,147 @@ +/* + * 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.ManageableThread; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorDefinition; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; +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.apache.openejb.threads.impl.ManagedThreadFactoryImpl; +import org.junit.Test; +import org.junit.runner.RunWith; + +import javax.naming.InitialContext; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that scheduled {@code @Asynchronous} firings honor the requested + * {@link ManagedScheduledExecutorService}'s thread factory: the firing thread + * is produced by the same {@link ManagedThreadFactoryImpl} as the primary pool + * (same naming prefix, {@link ManageableThread} shape), not a stranger pool such + * as the default MSES. Locks down clause A of Concurrency 3.1 §3.1 (firings run + * with the requested executor's thread factory / virtual / priority). + */ +@RunWith(ApplicationComposer.class) +public class ScheduledAsyncCustomFactoryTest { + + private static final String MSES_JNDI = "java:module/custom/factoryMSES"; + + @Inject + private FactoryBean bean; + + @Module + public EnterpriseBean ejb() { + return new SingletonBean(DummyEjb.class).localBean(); + } + + @Module + public Class[] beans() { + return new Class[]{FactoryBean.class}; + } + + @Test + public void scheduledFiringUsesCustomExecutorsThreadFactory() throws Exception { + final ManagedScheduledExecutorService custom = InitialContext.doLookup(MSES_JNDI); + + // Collect thread ids from the custom MSES's primary pool by submitting short-lived + // probes that each block until a release latch fires. Capturing several probes + // forces the primary pool to spawn each of its worker threads concurrently. + final int primarySamples = 3; + final Set primaryThreadIds = ConcurrentHashMap.newKeySet(); + final CountDownLatch primaryStarted = new CountDownLatch(primarySamples); + final CountDownLatch release = new CountDownLatch(1); + final Set> primaryProbes = new HashSet<>(); + for (int i = 0; i < primarySamples; i++) { + primaryProbes.add(custom.submit(() -> { + primaryThreadIds.add(Thread.currentThread().getId()); + primaryStarted.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + assertTrue("Primary-pool probes should start", primaryStarted.await(5, TimeUnit.SECONDS)); + release.countDown(); + for (final Future f : primaryProbes) { + f.get(5, TimeUnit.SECONDS); + } + assertFalse("Primary pool should have reported at least one worker thread id", primaryThreadIds.isEmpty()); + + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = bean.capture(counter); + final ThreadSnapshot firing = future.get(15, TimeUnit.SECONDS); + + assertNotNull("Scheduled firing must complete", firing); + assertFalse("Firing must run on the secondary pool, not the primary — the custom executor's " + + "primary core-size is capped by maxAsync (firing thread id=" + firing.threadId + + ", primary ids=" + primaryThreadIds + ")", + primaryThreadIds.contains(firing.threadId)); + assertTrue("Firing thread must come from the custom MSES's ManagedThreadFactory " + + "(expected name prefix '" + ManagedThreadFactoryImpl.DEFAULT_PREFIX + + "', got '" + firing.threadName + "')", + firing.threadName.startsWith(ManagedThreadFactoryImpl.DEFAULT_PREFIX)); + assertTrue("Firing thread must be a ManageableThread produced by ManagedThreadFactoryImpl " + + "(got class " + firing.threadClass + ")", + firing.manageable); + } + + @ManagedScheduledExecutorDefinition(name = MSES_JNDI, maxAsync = 3) + @ApplicationScoped + public static class FactoryBean { + + @Asynchronous(executor = MSES_JNDI, runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture capture(final AtomicInteger counter) { + counter.incrementAndGet(); + final Thread t = Thread.currentThread(); + final ThreadSnapshot snap = new ThreadSnapshot( + t.getId(), + t.getName(), + t.getClass().getName(), + t instanceof ManageableThread); + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(snap); + return future; + } + } + + public record ThreadSnapshot(long threadId, String threadName, String threadClass, boolean manageable) { + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncExecutorRoutingTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncExecutorRoutingTest.java new file mode 100644 index 00000000000..994249f9fc5 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncExecutorRoutingTest.java @@ -0,0 +1,129 @@ +/* + * 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.ManagedScheduledExecutorDefinition; +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.resource.thread.ManagedScheduledExecutorServiceImplFactory; +import org.apache.openejb.testing.Module; +import org.apache.openejb.threads.impl.ManagedScheduledExecutorServiceImpl; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Verifies that a scheduled {@code @Asynchronous} firing runs on the executor named in + * {@link Asynchronous#executor()} rather than silently falling back to + * {@code java:comp/DefaultManagedScheduledExecutorService}. + */ +@RunWith(ApplicationComposer.class) +public class ScheduledAsyncExecutorRoutingTest { + + @Inject + private CustomExecutorBean bean; + + @Module + public EnterpriseBean ejb() { + return new SingletonBean(DummyEjb.class).localBean(); + } + + @Module + public Class[] beans() { + return new Class[]{CustomExecutorBean.class}; + } + + @Test + public void scheduledFiringRunsOnRequestedExecutorNotDefault() throws Exception { + final ManagedScheduledExecutorServiceImpl defaultMses = + ManagedScheduledExecutorServiceImplFactory.lookup("java:comp/DefaultManagedScheduledExecutorService"); + + // Saturate the default MSES core pool (size 5) so every worker thread is live + // simultaneously and we capture its id. Each probe records its thread id up front, + // then blocks on the release latch so the pool can't recycle a single worker across + // multiple probes before we've seen them all. + final int coreSize = 5; + final Set defaultThreadIds = ConcurrentHashMap.newKeySet(); + final CountDownLatch started = new CountDownLatch(coreSize); + final CountDownLatch release = new CountDownLatch(1); + final Set> probes = new HashSet<>(); + for (int i = 0; i < coreSize; i++) { + probes.add(defaultMses.submit(() -> { + defaultThreadIds.add(Thread.currentThread().getId()); + started.countDown(); + try { + release.await(5, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + })); + } + assertTrue("All default-MSES probe tasks should start", started.await(5, TimeUnit.SECONDS)); + release.countDown(); + for (final Future f : probes) { + f.get(5, TimeUnit.SECONDS); + } + + assertFalse("Default MSES should have a non-empty worker pool", defaultThreadIds.isEmpty()); + + // Now run the scheduled method that targets the CUSTOM executor. + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = bean.captureFiringThreadId(counter); + final Long firingThreadId = future.get(15, TimeUnit.SECONDS); + + assertEquals("Method body should have fired once", 1, counter.get()); + assertFalse("Scheduled firing must run on the requested custom executor, " + + "not on the default MSES thread pool (firing thread id=" + firingThreadId + + ", default pool thread ids=" + defaultThreadIds + ")", + defaultThreadIds.contains(firingThreadId)); + } + + @ManagedScheduledExecutorDefinition(name = "java:module/routing/customMSES") + @ApplicationScoped + public static class CustomExecutorBean { + + @Asynchronous(executor = "java:module/routing/customMSES", + runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture captureFiringThreadId(final AtomicInteger counter) { + counter.incrementAndGet(); + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(Thread.currentThread().getId()); + return future; + } + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncMaxAsyncIsolationTest.java b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncMaxAsyncIsolationTest.java new file mode 100644 index 00000000000..784cc40b227 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/cdi/concurrency/ScheduledAsyncMaxAsyncIsolationTest.java @@ -0,0 +1,125 @@ +/* + * 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.ManagedScheduledExecutorDefinition; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorService; +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 javax.naming.InitialContext; +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.assertNotNull; +import static org.junit.Assert.assertTrue; + +/** + * Verifies Concurrency 3.1 §3.1: "Scheduled asynchronous methods are treated similar to + * other scheduled tasks in that they are not subject to max-async constraints." + * + *

Mirrors the TCK test + * {@code ManagedScheduledExecutorDefinitionWebTests.testScheduledAsynchIgnoresMaxAsync}: + * a custom {@link ManagedScheduledExecutorService} is saturated with regular async + * submissions that fill all {@code maxAsync} slots; a scheduled {@code @Asynchronous} + * firing on the same executor must still execute and complete, rather than queueing + * behind the saturating workload. + */ +@RunWith(ApplicationComposer.class) +public class ScheduledAsyncMaxAsyncIsolationTest { + + private static final String MSES_JNDI = "java:module/maxasync/limitedMSES"; + private static final int MAX_ASYNC = 1; + + @Inject + private SaturatedBean bean; + + @Module + public EnterpriseBean ejb() { + return new SingletonBean(DummyEjb.class).localBean(); + } + + @Module + public Class[] beans() { + return new Class[]{SaturatedBean.class}; + } + + @Test + public void scheduledFiringBypassesMaxAsync() throws Exception { + final ManagedScheduledExecutorService mses = InitialContext.doLookup(MSES_JNDI); + + // Saturate every maxAsync slot with tasks that block until the test releases them. + // With maxAsync=1 the underlying ScheduledThreadPoolExecutor has corePoolSize=1, so a + // single blocking submission occupies the only worker thread. + final CountDownLatch saturationStarted = new CountDownLatch(MAX_ASYNC); + final CountDownLatch release = new CountDownLatch(1); + for (int i = 0; i < MAX_ASYNC; i++) { + mses.submit(() -> { + saturationStarted.countDown(); + try { + release.await(20, TimeUnit.SECONDS); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + } + assertTrue("Saturating tasks should occupy all maxAsync slots before the scheduled firing is triggered", + saturationStarted.await(5, TimeUnit.SECONDS)); + + try { + // A scheduled firing on the same executor must not be blocked by the saturating + // workload. With a one-second cron and a 15-second deadline there is ample headroom + // unless the firing is queued behind the maxAsync core threads. + final AtomicInteger counter = new AtomicInteger(); + final CompletableFuture future = bean.scheduledFire(counter); + final Integer result = future.get(15, TimeUnit.SECONDS); + + assertNotNull("Scheduled firing must complete despite saturated maxAsync slots", result); + assertEquals("Scheduled firing should have executed exactly once", 1, counter.get()); + } finally { + release.countDown(); + } + } + + @ManagedScheduledExecutorDefinition(name = MSES_JNDI, maxAsync = MAX_ASYNC) + @ApplicationScoped + public static class SaturatedBean { + + @Asynchronous(executor = MSES_JNDI, runAt = @Schedule(cron = "* * * * * *")) + public CompletableFuture scheduledFire(final AtomicInteger counter) { + final int count = counter.incrementAndGet(); + final CompletableFuture future = Asynchronous.Result.getFuture(); + future.complete(count); + return future; + } + } + + @jakarta.ejb.Singleton + public static class DummyEjb { + } +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/config/ConcurrencyDefinitionDDOverrideTest.java b/container/openejb-core/src/test/java/org/apache/openejb/config/ConcurrencyDefinitionDDOverrideTest.java new file mode 100644 index 00000000000..6bac44b0d3e --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/config/ConcurrencyDefinitionDDOverrideTest.java @@ -0,0 +1,263 @@ +/* + * 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 jakarta.enterprise.concurrent.ContextServiceDefinition; +import jakarta.enterprise.concurrent.ManagedExecutorDefinition; +import jakarta.enterprise.concurrent.ManagedScheduledExecutorDefinition; +import jakarta.enterprise.concurrent.ManagedThreadFactoryDefinition; +import jakarta.enterprise.util.Nonbinding; +import jakarta.inject.Qualifier; +import org.apache.openejb.jee.ContextService; +import org.apache.openejb.jee.ManagedExecutor; +import org.apache.openejb.jee.ManagedScheduledExecutor; +import org.apache.openejb.jee.ManagedThreadFactory; +import org.apache.openejb.jee.StatelessBean; +import org.apache.openejb.jee.jba.JndiName; +import org.apache.xbean.finder.AnnotationFinder; +import org.apache.xbean.finder.archive.ClassesArchive; +import org.junit.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +/** + * Verifies {@link AnnotationDeployer.ProcessAnnotatedBeans#buildAnnotatedRefs} applies the + * DD-wins merging rule (Jakarta EE platform §EE.5.2.5) when an annotation and a deployment + * descriptor entry share a name: the DD value is retained for every attribute, and the + * annotation only fills attributes the DD left unset. + */ +public class ConcurrencyDefinitionDDOverrideTest { + + // ---------------- ContextService ---------------- + + @Test + public void contextServiceDDOverridesAnnotation() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", CSBean.class.getName()); + + final ContextService dd = new ContextService(); + dd.setName(jndi("java:app/concurrent/CS")); + dd.getPropagated().add("StringContext"); + dd.getCleared().add("IntContext"); + dd.getUnchanged().add("Transaction"); + dd.getQualifier().add(DDQualifier.class.getName()); + bean.getContextServiceMap().put("java:app/concurrent/CS", dd); + + buildAnnotatedRefs(bean, CSBean.class); + + final ContextService merged = bean.getContextServiceMap().get("java:app/concurrent/CS"); + assertEquals("java:app/concurrent/CS", merged.getName().getvalue()); + assertEquals(List.of("StringContext"), merged.getPropagated()); + assertEquals(List.of("IntContext"), merged.getCleared()); + assertEquals(List.of("Transaction"), merged.getUnchanged()); + assertEquals(List.of(DDQualifier.class.getName()), merged.getQualifier()); + } + + @Test + public void contextServiceAnnotationFillsMissingDDFields() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", CSBean.class.getName()); + buildAnnotatedRefs(bean, CSBean.class); + + final ContextService merged = bean.getContextServiceMap().get("java:app/concurrent/CS"); + assertEquals(List.of(ContextServiceDefinition.APPLICATION), merged.getPropagated()); + assertEquals(List.of(ContextServiceDefinition.TRANSACTION), merged.getCleared()); + assertTrue(merged.getUnchanged().isEmpty()); + assertEquals(List.of(AnnoQualifier.class.getName()), merged.getQualifier()); + } + + // ---------------- ManagedExecutor ---------------- + + @Test + public void managedExecutorDDOverridesAnnotation() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", MEBean.class.getName()); + + final ManagedExecutor dd = new ManagedExecutor(); + dd.setName(jndi("java:app/concurrent/MES")); + dd.setContextService(jndi("java:app/concurrent/DDCtx")); + dd.setHungTaskThreshold(100_000L); + dd.setMaxAsync(5); + dd.setVirtual(Boolean.FALSE); + dd.getQualifier().add(DDQualifier.class.getName()); + bean.getManagedExecutorMap().put("java:app/concurrent/MES", dd); + + buildAnnotatedRefs(bean, MEBean.class); + + final ManagedExecutor merged = bean.getManagedExecutorMap().get("java:app/concurrent/MES"); + assertEquals("java:app/concurrent/MES", merged.getName().getvalue()); + assertEquals("java:app/concurrent/DDCtx", merged.getContextService().getvalue()); + assertEquals(Long.valueOf(100_000L), merged.getHungTaskThreshold()); + assertEquals(Integer.valueOf(5), merged.getMaxAsync()); + assertEquals(Boolean.FALSE, merged.getVirtual()); + assertEquals(List.of(DDQualifier.class.getName()), merged.getQualifier()); + } + + @Test + public void managedExecutorAnnotationFillsMissingDDFields() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", MEBean.class.getName()); + buildAnnotatedRefs(bean, MEBean.class); + + final ManagedExecutor merged = bean.getManagedExecutorMap().get("java:app/concurrent/MES"); + assertEquals("java:app/concurrent/AnnoCtx", merged.getContextService().getvalue()); + assertEquals(Long.valueOf(200_000L), merged.getHungTaskThreshold()); + assertEquals(Integer.valueOf(3), merged.getMaxAsync()); + assertEquals(Boolean.TRUE, merged.getVirtual()); + assertEquals(List.of(AnnoQualifier.class.getName()), merged.getQualifier()); + } + + // ---------------- ManagedScheduledExecutor ---------------- + + @Test + public void managedScheduledExecutorDDOverridesAnnotation() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", MSESBean.class.getName()); + + final ManagedScheduledExecutor dd = new ManagedScheduledExecutor(); + dd.setName(jndi("java:app/concurrent/MSES")); + dd.setContextService(jndi("java:app/concurrent/DDCtx")); + dd.setHungTaskThreshold(150_000L); + dd.setMaxAsync(4); + dd.setVirtual(Boolean.FALSE); + dd.getQualifier().add(DDQualifier.class.getName()); + bean.getManagedScheduledExecutorMap().put("java:app/concurrent/MSES", dd); + + buildAnnotatedRefs(bean, MSESBean.class); + + final ManagedScheduledExecutor merged = bean.getManagedScheduledExecutorMap().get("java:app/concurrent/MSES"); + assertEquals("java:app/concurrent/DDCtx", merged.getContextService().getvalue()); + assertEquals(Long.valueOf(150_000L), merged.getHungTaskThreshold()); + assertEquals(Integer.valueOf(4), merged.getMaxAsync()); + assertEquals(Boolean.FALSE, merged.getVirtual()); + assertEquals(List.of(DDQualifier.class.getName()), merged.getQualifier()); + } + + @Test + public void managedScheduledExecutorAnnotationFillsMissingDDFields() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", MSESBean.class.getName()); + buildAnnotatedRefs(bean, MSESBean.class); + + final ManagedScheduledExecutor merged = bean.getManagedScheduledExecutorMap().get("java:app/concurrent/MSES"); + assertEquals("java:app/concurrent/AnnoCtx", merged.getContextService().getvalue()); + assertEquals(Long.valueOf(250_000L), merged.getHungTaskThreshold()); + assertEquals(Integer.valueOf(2), merged.getMaxAsync()); + assertEquals(Boolean.TRUE, merged.getVirtual()); + assertEquals(List.of(AnnoQualifier.class.getName()), merged.getQualifier()); + } + + // ---------------- ManagedThreadFactory ---------------- + + @Test + public void managedThreadFactoryDDOverridesAnnotation() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", MTFBean.class.getName()); + + final ManagedThreadFactory dd = new ManagedThreadFactory(); + dd.setName(jndi("java:app/concurrent/MTF")); + dd.setContextService(jndi("java:app/concurrent/DDCtx")); + dd.setPriority(7); + dd.setVirtual(Boolean.FALSE); + dd.getQualifier().add(DDQualifier.class.getName()); + bean.getManagedThreadFactoryMap().put("java:app/concurrent/MTF", dd); + + buildAnnotatedRefs(bean, MTFBean.class); + + final ManagedThreadFactory merged = bean.getManagedThreadFactoryMap().get("java:app/concurrent/MTF"); + assertEquals("java:app/concurrent/DDCtx", merged.getContextService().getvalue()); + assertEquals(Integer.valueOf(7), merged.getPriority()); + assertEquals(Boolean.FALSE, merged.getVirtual()); + assertEquals(List.of(DDQualifier.class.getName()), merged.getQualifier()); + } + + @Test + public void managedThreadFactoryAnnotationFillsMissingDDFields() throws Exception { + final StatelessBean bean = new StatelessBean("Bean", MTFBean.class.getName()); + buildAnnotatedRefs(bean, MTFBean.class); + + final ManagedThreadFactory merged = bean.getManagedThreadFactoryMap().get("java:app/concurrent/MTF"); + assertEquals("java:app/concurrent/AnnoCtx", merged.getContextService().getvalue()); + assertEquals(Integer.valueOf(8), merged.getPriority()); + assertEquals(Boolean.TRUE, merged.getVirtual()); + assertEquals(List.of(AnnoQualifier.class.getName()), merged.getQualifier()); + } + + // ---------------- helpers ---------------- + + private static void buildAnnotatedRefs(final StatelessBean bean, final Class beanClass) throws Exception { + final AnnotationFinder finder = new AnnotationFinder(new ClassesArchive(beanClass)).link(); + new AnnotationDeployer.ProcessAnnotatedBeans(false) + .buildAnnotatedRefs(bean, finder, beanClass.getClassLoader()); + } + + private static JndiName jndi(final String value) { + final JndiName n = new JndiName(); + n.setvalue(value); + return n; + } + + @Qualifier + @Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER }) + @Retention(RetentionPolicy.RUNTIME) + public @interface DDQualifier { + } + + @Qualifier + @Target({ ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER }) + @Retention(RetentionPolicy.RUNTIME) + public @interface AnnoQualifier { + @Nonbinding String value() default ""; + } + + @ContextServiceDefinition( + name = "java:app/concurrent/CS", + propagated = ContextServiceDefinition.APPLICATION, + cleared = ContextServiceDefinition.TRANSACTION, + qualifiers = AnnoQualifier.class) + public static class CSBean { + } + + @ManagedExecutorDefinition( + name = "java:app/concurrent/MES", + context = "java:app/concurrent/AnnoCtx", + hungTaskThreshold = 200_000, + maxAsync = 3, + virtual = true, + qualifiers = AnnoQualifier.class) + public static class MEBean { + } + + @ManagedScheduledExecutorDefinition( + name = "java:app/concurrent/MSES", + context = "java:app/concurrent/AnnoCtx", + hungTaskThreshold = 250_000, + maxAsync = 2, + virtual = true, + qualifiers = AnnoQualifier.class) + public static class MSESBean { + } + + @ManagedThreadFactoryDefinition( + name = "java:app/concurrent/MTF", + context = "java:app/concurrent/AnnoCtx", + priority = 8, + virtual = true, + qualifiers = AnnoQualifier.class) + public static class MTFBean { + } +} 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..3686c55af52 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/config/ConvertVirtualDefinitionsTest.java @@ -0,0 +1,217 @@ +/* + * 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")); + } + + @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) { + 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/junit/jre/EnabledForJreRange.java b/container/openejb-core/src/test/java/org/apache/openejb/junit/jre/EnabledForJreRange.java new file mode 100644 index 00000000000..b427d16fb88 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/junit/jre/EnabledForJreRange.java @@ -0,0 +1,37 @@ +/* + * 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.junit.jre; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Enables a JUnit 4 test method only when the running JRE feature version falls in + * {@code [min, max]}. Mirrors {@code org.junit.jupiter.api.condition.EnabledForJreRange} + * for codebases that cannot depend on JUnit Jupiter. Tests outside the range are skipped + * via {@code Assume} semantics (surefire reports them as skipped). + * + *

Must be combined with {@link JreConditionRule} as a {@code @Rule}. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface EnabledForJreRange { + int min() default 0; + int max() default Integer.MAX_VALUE; +} diff --git a/container/openejb-core/src/test/java/org/apache/openejb/junit/jre/JreConditionRule.java b/container/openejb-core/src/test/java/org/apache/openejb/junit/jre/JreConditionRule.java new file mode 100644 index 00000000000..c3205ff6c89 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/junit/jre/JreConditionRule.java @@ -0,0 +1,52 @@ +/* + * 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.junit.jre; + +import org.junit.AssumptionViolatedException; +import org.junit.rules.TestRule; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +/** + * JUnit 4 rule that skips a test method when the running JRE feature version is + * outside the range declared by {@link EnabledForJreRange} on the method. Skipping is + * implemented by throwing {@link AssumptionViolatedException}, matching surefire's + * "skipped" category. + */ +public final class JreConditionRule implements TestRule { + + @Override + public Statement apply(final Statement base, final Description description) { + final EnabledForJreRange range = description.getAnnotation(EnabledForJreRange.class); + if (range == null) { + return base; + } + + final int current = Runtime.version().feature(); + if (current < range.min() || current > range.max()) { + return new Statement() { + @Override + public void evaluate() { + throw new AssumptionViolatedException( + "Requires Java " + range.min() + ".." + range.max() + + ", running on " + current); + } + }; + } + return base; + } +} 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; diff --git a/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorSecondaryPoolLifecycleTest.java b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorSecondaryPoolLifecycleTest.java new file mode 100644 index 00000000000..b1373f5ed8f --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedScheduledExecutorSecondaryPoolLifecycleTest.java @@ -0,0 +1,86 @@ +/* + * 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.Test; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +/** + * Verifies the ownership contract for + * {@link ManagedScheduledExecutorServiceImpl}'s secondary scheduled-async pool: + * when the MSES owns its secondary pool, {@code destroyResource()} must shut it down; + * when it merely borrows one from another MSES, {@code destroyResource()} must NOT + * touch the borrowed pool. + */ +public class ManagedScheduledExecutorSecondaryPoolLifecycleTest { + + @Test + public void ownedSecondaryIsShutDownOnDestroy() { + final ScheduledExecutorService primary = new ScheduledThreadPoolExecutor(1); + final ScheduledExecutorService secondary = new ScheduledThreadPoolExecutor(1); + final ContextServiceImpl ctx = ContextServiceImplFactory.getOrCreateDefaultSingleton(); + + final ManagedScheduledExecutorServiceImpl mses = + new ManagedScheduledExecutorServiceImpl(primary, ctx, secondary, true); + + assertSame("secondary exposed via getScheduledAsyncDelegate()", secondary, mses.getScheduledAsyncDelegate()); + + mses.destroyResource(); + + assertTrue("owned secondary must be shut down", secondary.isShutdown()); + assertTrue("primary must be shut down", primary.isShutdown()); + } + + @Test + public void borrowedSecondaryIsNotShutDownOnDestroy() { + final ScheduledExecutorService primary = new ScheduledThreadPoolExecutor(1); + final ScheduledExecutorService borrowedSecondary = new ScheduledThreadPoolExecutor(1); + final ContextServiceImpl ctx = ContextServiceImplFactory.getOrCreateDefaultSingleton(); + + final ManagedScheduledExecutorServiceImpl mses = + new ManagedScheduledExecutorServiceImpl(primary, ctx, borrowedSecondary, false); + + mses.destroyResource(); + + assertTrue("primary must be shut down", primary.isShutdown()); + assertFalse("borrowed secondary must NOT be shut down — the lending MSES owns it", + borrowedSecondary.isShutdown()); + + borrowedSecondary.shutdownNow(); + } + + @Test + public void legacyConstructorAliasesDelegateAsSecondary() { + final ScheduledExecutorService primary = new ScheduledThreadPoolExecutor(1); + final ContextServiceImpl ctx = ContextServiceImplFactory.getOrCreateDefaultSingleton(); + + final ManagedScheduledExecutorServiceImpl mses = + new ManagedScheduledExecutorServiceImpl(primary, ctx); + + assertSame("legacy 2-arg ctor must alias secondary to primary for back-compat", + primary, mses.getScheduledAsyncDelegate()); + + mses.destroyResource(); + assertTrue("primary shut down by super.destroyResource()", primary.isShutdown()); + } +} 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..f8679084dce --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/ManagedThreadFactoryVirtualTest.java @@ -0,0 +1,171 @@ +/* + * 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.resource.thread.ManagedScheduledExecutorServiceImplFactory; +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.Future; +import java.util.concurrent.ScheduledExecutorService; +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; +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 + 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); + + // 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 + 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()); + } + + /** + * Regression: the default-factory branch in + * {@link ManagedScheduledExecutorServiceImplFactory#create()} used to instantiate + * {@code ManagedThreadFactoryImpl} via reflection on its no-arg constructor, which + * hard-codes {@code virtual=false}. As a result, {@code virtual=true} on the factory + * was silently ignored unless reflection failed and the catch block kicked in. + */ + @Test + public void scheduledFactoryHonorsVirtualFlagOnDefaultThreadFactoryPath() throws Exception { + Assume.assumeTrue("Virtual threads require Java 21+", VirtualThreadHelper.isSupported()); + + final ManagedScheduledExecutorServiceImplFactory factory = new ManagedScheduledExecutorServiceImplFactory(); + factory.setVirtual(true); + final ManagedScheduledExecutorServiceImpl mses = factory.create(); + try { + // Per Concurrency 3.1 §3.4.4 virtual threads do NOT implement ManageableThread. + final Future future = mses.submit(() -> !(Thread.currentThread() instanceof ManageableThread)); + assertTrue("Scheduled factory with virtual=true must produce virtual threads", + future.get(5, TimeUnit.SECONDS)); + } finally { + shutdown(mses); + } + } + + @Test + public void scheduledFactoryDefaultsToPlatformThreads() throws Exception { + final ManagedScheduledExecutorServiceImplFactory factory = new ManagedScheduledExecutorServiceImplFactory(); + final ManagedScheduledExecutorServiceImpl mses = factory.create(); + try { + final Future future = mses.submit(() -> Thread.currentThread() instanceof ManageableThread); + assertTrue("Default scheduled factory must produce platform (ManageableThread) threads", + future.get(5, TimeUnit.SECONDS)); + } finally { + shutdown(mses); + } + } + + private static void shutdown(final ManagedScheduledExecutorServiceImpl mses) { + final ScheduledExecutorService delegate = mses.getDelegate(); + if (delegate != null) { + delegate.shutdownNow(); + } + } +} 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..c47d9e31eb2 --- /dev/null +++ b/container/openejb-core/src/test/java/org/apache/openejb/threads/impl/VirtualThreadHelperTest.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.threads.impl; + +import org.apache.openejb.junit.jre.EnabledForJreRange; +import org.apache.openejb.junit.jre.JreConditionRule; +import org.junit.Rule; +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 { + + @Rule + public final JreConditionRule jreCondition = new JreConditionRule(); + + @Test + @EnabledForJreRange(min = 21) + public void newVirtualThreadCreatesThread() { + 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 + @EnabledForJreRange(min = 21) + public void newVirtualThreadFactoryCreatesThreads() { + 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 + @EnabledForJreRange(min = 21) + public void newVirtualThreadPerTaskExecutorWorks() { + 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) + @EnabledForJreRange(max = 20) + public void newVirtualThreadThrowsOnUnsupported() { + VirtualThreadHelper.newVirtualThread("test-", 1, () -> {}); + } + + @Test(expected = UnsupportedOperationException.class) + @EnabledForJreRange(max = 20) + public void newVirtualThreadFactoryThrowsOnUnsupported() { + VirtualThreadHelper.newVirtualThreadFactory("test-"); + } +} 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 82335aea84d..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) @@ -31,6 +32,8 @@ "contextService", "hungTaskThreshold", "maxAsync", + "virtual", + "qualifier", "properties" }) public class ManagedExecutor implements Keyable { @@ -44,6 +47,10 @@ public class ManagedExecutor implements Keyable { protected Long hungTaskThreshold; @XmlElement(name = "max-async") protected Integer maxAsync; + @XmlElement + protected Boolean virtual; + @XmlElement + protected List qualifier; @XmlElement(name = "properties") protected List properties; @@ -87,6 +94,25 @@ public void setMaxAsync(Integer maxAsync) { this.maxAsync = maxAsync; } + public Boolean getVirtual() { + return virtual; + } + + 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 aec88382427..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; @@ -32,6 +33,8 @@ "contextService", "hungTaskThreshold", "maxAsync", + "virtual", + "qualifier", "properties" }) public class ManagedScheduledExecutor implements Keyable { @@ -45,6 +48,10 @@ public class ManagedScheduledExecutor implements Keyable { protected Long hungTaskThreshold; @XmlElement(name = "max-async") protected Integer maxAsync; + @XmlElement + protected Boolean virtual; + @XmlElement + protected List qualifier; @XmlElement(name = "property") protected List properties; @@ -88,6 +95,25 @@ public void setMaxAsync(Integer maxAsync) { this.maxAsync = maxAsync; } + public Boolean getVirtual() { + return virtual; + } + + 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 23a44ba5b65..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) @@ -30,6 +31,8 @@ "name", "contextService", "priority", + "virtual", + "qualifier", "properties" }) public class ManagedThreadFactory implements Keyable { @@ -41,6 +44,10 @@ public class ManagedThreadFactory implements Keyable { protected JndiName contextService; @XmlElement protected Integer priority; + @XmlElement + protected Boolean virtual; + @XmlElement + protected List qualifier; @XmlElement(name = "property") protected List properties; @@ -76,6 +83,25 @@ public void setPriority(Integer priority) { this.priority = priority; } + public Boolean getVirtual() { + return virtual; + } + + 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/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..f762ba44105 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/** + + web + 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/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..e7870778ade --- /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..b2658b68872 --- /dev/null +++ b/tck/concurrency-standalone/src/test/resources/META-INF/services/org.jboss.arquillian.core.spi.LoadableExtension @@ -0,0 +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/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..d21fcd5fb78 --- /dev/null +++ b/tck/concurrency-standalone/src/test/resources/logging.properties @@ -0,0 +1,36 @@ +# 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 + +.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