From 59059ec006f6b43bc54e7def89920c1da876af5d Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Mon, 1 Jun 2026 21:23:10 -0700 Subject: [PATCH 1/3] Add Temporal Operation --- settings.gradle | 1 + .../workflow/KotlinTemporalOperationTest.kt | 83 ++++++ .../internal/nexus/NexusTaskHandlerImpl.java | 2 +- .../nexus/TemporalOperationProcessor.java | 178 +++++++++++++ .../temporal/nexus/TemporalNexusClient.java | 40 +-- .../io/temporal/nexus/TemporalOperation.java | 53 ++++ .../nexus/TemporalOperationHandler.java | 46 ++-- .../nexus/TemporalOperationProcessorTest.java | 250 ++++++++++++++++++ .../nexus/GenericHandlerCancelTest.java | 28 +- .../nexus/GenericHandlerDoubleStartTest.java | 47 ++-- .../nexus/GenericHandlerSyncResultTest.java | 14 +- .../nexus/GenericHandlerTypedProcTest.java | 142 +++++----- .../GenericHandlerTypedStartWorkflowTest.java | 142 +++++----- ...enericHandlerUntypedStartWorkflowTest.java | 33 ++- .../TemporalOperationAnnotationTest.java | 175 ++++++++++++ .../template/WorkersTemplate.java | 7 +- .../testing/internal/TestServiceUtils.java | 5 +- 17 files changed, 989 insertions(+), 257 deletions(-) create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperation.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java diff --git a/settings.gradle b/settings.gradle index fe80370b0c..e7add03442 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ rootProject.name='temporal-java-sdk' +includeBuild('../nexus/nexus-sdk-java') include 'temporal-bom' include 'temporal-serviceclient' include 'temporal-sdk' diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt new file mode 100644 index 0000000000..a009138538 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt @@ -0,0 +1,83 @@ + +package io.temporal.workflow + +import io.nexusrpc.Operation +import io.nexusrpc.Service +import io.nexusrpc.handler.ServiceImpl +import io.temporal.client.WorkflowClientOptions +import io.temporal.client.WorkflowOptions +import io.temporal.common.converter.DefaultDataConverter +import io.temporal.common.converter.JacksonJsonPayloadConverter +import io.temporal.common.converter.KotlinObjectMapperFactory +import io.temporal.nexus.TemporalNexusClient +import io.temporal.nexus.TemporalOperation +import io.temporal.nexus.TemporalOperationResult +import io.temporal.nexus.TemporalOperationStartContext +import io.temporal.testing.internal.SDKTestWorkflowRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.time.Duration + +class KotlinTemporalOperationTest { + + @Rule + @JvmField + var testWorkflowRule: SDKTestWorkflowRule = SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(CallerWorkflowImpl::class.java) + .setNexusServiceImplementation(KotlinSugarServiceImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setDataConverter(DefaultDataConverter(JacksonJsonPayloadConverter(KotlinObjectMapperFactory.new()))) + .build() + ) + .build() + + @Service + interface KotlinSugarService { + @Operation + fun greet(input: String): String + } + + @ServiceImpl(service = KotlinSugarService::class) + class KotlinSugarServiceImpl { + @TemporalOperation + fun greet( + ctx: TemporalOperationStartContext, + client: TemporalNexusClient, + input: String + ): TemporalOperationResult { + return TemporalOperationResult.sync("kotlin-$input") + } + } + + @WorkflowInterface + interface CallerWorkflow { + @WorkflowMethod + fun execute(arg: String): String + } + + class CallerWorkflowImpl : CallerWorkflow { + override fun execute(arg: String): String { + val stub = Workflow.newNexusServiceStub( + KotlinSugarService::class.java, + NexusServiceOptions { + setOperationOptions( + NexusOperationOptions { + setScheduleToCloseTimeout(Duration.ofSeconds(10)) + } + ) + } + ) + return stub.greet(arg) + } + } + + @Test + fun temporalOperationSugar_endToEnd() { + val client = testWorkflowRule.workflowClient + val options = WorkflowOptions.newBuilder().setTaskQueue(testWorkflowRule.taskQueue).build() + val workflowStub = client.newWorkflowStub(CallerWorkflow::class.java, options) + assertEquals("kotlin-hi", workflowStub.execute("hi")) + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index 0fac5263a9..748faf8685 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -403,7 +403,7 @@ private void registerNexusService(Object nexusService) { if (nexusService instanceof Class) { throw new IllegalArgumentException("Nexus service object instance expected, not the class"); } - ServiceImplInstance instance = ServiceImplInstance.fromInstance(nexusService); + ServiceImplInstance instance = TemporalOperationProcessor.process(nexusService); InternalUtils.checkMethodName(instance); if (serviceImplInstances.put(instance.getDefinition().getName(), instance) != null) { throw new TypeAlreadyRegisteredException( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java new file mode 100644 index 0000000000..8ab83cdf73 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java @@ -0,0 +1,178 @@ +package io.temporal.internal.nexus; + +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Primitives; +import io.nexusrpc.OperationDefinition; +import io.nexusrpc.ServiceDefinition; +import io.nexusrpc.handler.MethodExtension; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImplInstance; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * Entry point for registering a Nexus service instance whose class may contain {@link + * TemporalOperation}-annotated methods. Delegates to {@link + * ServiceImplInstance#fromInstance(Object, java.util.List)} with a single {@link MethodExtension} + * that recognizes {@link TemporalOperation} alongside the built-in {@link OperationImpl}. + */ +public final class TemporalOperationProcessor { + + private static final ImmutableList EXTENSIONS = + ImmutableList.of(new TemporalOperationExtension()); + + private TemporalOperationProcessor() {} + + public static ServiceImplInstance process(Object instance) { + return ServiceImplInstance.fromInstance(instance, EXTENSIONS); + } + + /** Recognizes {@link TemporalOperation}-annotated methods during nexusrpc service scanning. */ + private static final class TemporalOperationExtension implements MethodExtension { + @Override + public Result extract(Object instance, Method method, ServiceDefinition serviceDefinition) { + if (method.getDeclaredAnnotation(TemporalOperation.class) == null) { + return null; + } + if (method.isAnnotationPresent(OperationImpl.class)) { + throw new IllegalArgumentException( + "@TemporalOperation and @OperationImpl cannot be combined on method " + + method.getName()); + } + + validateSignature(method); + + OperationDefinition operationDefinition = + serviceDefinition.getOperations().values().stream() + .filter(o -> method.getName().equals(o.getMethodName())) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "No matching @Operation on service " + + serviceDefinition.getName() + + " for @TemporalOperation method " + + method.getName())); + + validateTypes(method, operationDefinition); + + MethodHandle handle; + try { + handle = MethodHandles.lookup().unreflect(method).bindTo(instance); + } catch (IllegalAccessException e) { + throw new RuntimeException( + "Failed to obtain method handle for @TemporalOperation method " + method.getName(), e); + } + + TemporalOperationHandler.StartHandler startHandler = + (ctx, client, input) -> invokeStartHandler(handle, ctx, client, input); + + return new Result( + operationDefinition.getName(), + new TemporalOperationHandler(startHandler) {}); + } + } + + private static void validateSignature(Method method) { + if (!Modifier.isPublic(method.getModifiers())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + method.getName() + " must be public"); + } + if (Modifier.isStatic(method.getModifiers())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + method.getName() + " must not be static"); + } + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length != 3 + || !TemporalOperationStartContext.class.equals(paramTypes[0]) + || !TemporalNexusClient.class.equals(paramTypes[1])) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " must accept (TemporalOperationStartContext, TemporalNexusClient, I); got " + + describeSignature(method)); + } + if (!TemporalOperationResult.class.equals(method.getReturnType())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " must return TemporalOperationResult; got " + + method.getGenericReturnType().getTypeName() + + ". Use @OperationImpl for custom handler shapes."); + } + } + + private static void validateTypes(Method method, OperationDefinition operationDefinition) { + Type expectedInputType = operationDefinition.getInputType(); + Type declaredInputType = method.getGenericParameterTypes()[2]; + if (!typesMatch(declaredInputType, expectedInputType)) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " input type mismatch: expected " + + expectedInputType.getTypeName() + + " but got " + + declaredInputType.getTypeName()); + } + Type returnType = method.getGenericReturnType(); + if (!(returnType instanceof ParameterizedType)) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " must use parameterized TemporalOperationResult, not the raw type."); + } + Type resultTypeArg = ((ParameterizedType) returnType).getActualTypeArguments()[0]; + if (!typesMatch(resultTypeArg, operationDefinition.getOutputType())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " output type mismatch: expected " + + operationDefinition.getOutputType().getTypeName() + + " but got " + + resultTypeArg.getTypeName()); + } + } + + // Package-private for testing. + @SuppressWarnings("unchecked") + static TemporalOperationResult invokeStartHandler( + MethodHandle handle, + TemporalOperationStartContext ctx, + TemporalNexusClient client, + Object input) { + try { + return (TemporalOperationResult) handle.invoke(ctx, client, input); + } catch (RuntimeException | Error e) { + throw e; + } catch (Throwable t) { + throw new RuntimeException("@TemporalOperation method threw checked exception", t); + } + } + + private static boolean typesMatch(Type declared, Type expected) { + if (declared.equals(expected)) { + return true; + } + if (declared instanceof Class && expected instanceof Class) { + return Primitives.wrap((Class) declared).equals(Primitives.wrap((Class) expected)); + } + return false; + } + + private static String describeSignature(Method method) { + return Arrays.stream(method.getParameterTypes()) + .map(Class::getSimpleName) + .collect(Collectors.joining(", ", "(", ")")); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java index 4eed1fe350..d21708516c 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java @@ -10,21 +10,22 @@ * Nexus-aware client wrapping {@link WorkflowClient}. Provides methods for interacting with * Temporal from within a Nexus operation handler. * - *

Obtained via the {@link TemporalOperationHandler.StartHandler} parameter. + *

Passed to {@link TemporalOperation}-annotated methods (and {@link + * TemporalOperationHandler.StartHandler} implementations) alongside the start context and input. * - *

Example usage to start a workflow from an operation handler: + *

Example usage to start a workflow from an operation: * *

{@code
- * @OperationImpl
- * public OperationHandler startTransfer() {
- *   return TemporalOperationHandler.create((context, client, input) -> {
- *     return client.startWorkflow(
- *         TransferWorkflow.class,
- *         TransferWorkflow::transfer, input.getFromAccount(), input.getToAccount(),
- *         WorkflowOptions.newBuilder()
- *             .setWorkflowId("transfer-" + input.getTransferId())
- *             .build());
- *   });
+ * @TemporalOperation
+ * public TemporalOperationResult startTransfer(
+ *     TemporalOperationStartContext ctx, TemporalNexusClient client, TransferInput input) {
+ *   return client.startWorkflow(
+ *       TransferWorkflow.class,
+ *       TransferWorkflow::transfer,
+ *       input,
+ *       WorkflowOptions.newBuilder()
+ *           .setWorkflowId("transfer-" + input.getTransferId())
+ *           .build());
  * }
  * }
* @@ -32,14 +33,13 @@ * TemporalOperationResult#sync} result. For example, to send a signal: * *
{@code
- * @OperationImpl
- * public OperationHandler cancelOrder() {
- *   return TemporalOperationHandler.create((context, client, input) -> {
- *     client.getWorkflowClient()
- *         .newUntypedWorkflowStub("order-" + input.getOrderId())
- *         .signal("requestCancellation", input);
- *     return TemporalOperationResult.sync(null);
- *   });
+ * @TemporalOperation
+ * public TemporalOperationResult cancelOrder(
+ *     TemporalOperationStartContext ctx, TemporalNexusClient client, CancelOrderInput input) {
+ *   client.getWorkflowClient()
+ *       .newUntypedWorkflowStub("order-" + input.getOrderId())
+ *       .signal("requestCancellation", input);
+ *   return TemporalOperationResult.sync(null);
  * }
  * }
*/ diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperation.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperation.java new file mode 100644 index 0000000000..631da9eb5a --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperation.java @@ -0,0 +1,53 @@ +package io.temporal.nexus; + +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.common.Experimental; +import java.lang.annotation.*; + +/** + * Marks a method on a {@link ServiceImpl}-annotated class as a Temporal-backed Nexus operation. The + * method body is the start handler — the framework wraps it in a {@link + * TemporalOperationHandler} at registration time, with default cancel behavior matching {@link + * TemporalOperationHandler#cancel(OperationContext, OperationCancelDetails)}. + * + *

The method must: + * + *

    + *
  • be {@code public}, + *
  • accept exactly three parameters: {@link TemporalOperationStartContext}, {@link + * TemporalNexusClient}, and the operation input type, + *
  • return {@link TemporalOperationResult}. + *
+ * + *

Workflow-run example: + * + *

{@code
+ * @ServiceImpl(service = TransferService.class)
+ * public class TransferServiceImpl {
+ *   @TemporalOperation
+ *   public TemporalOperationResult transfer(
+ *       TemporalOperationStartContext ctx, TemporalNexusClient client, TransferInput input) {
+ *     return client.startWorkflow(
+ *         TransferWorkflow.class,
+ *         TransferWorkflow::transfer,
+ *         input,
+ *         WorkflowOptions.newBuilder()
+ *             .setWorkflowId("transfer-" + input.getTransferId())
+ *             .build());
+ *   }
+ * }
+ * }
+ * + *

For custom cancel, or any other handler composition, use {@link OperationImpl} with a {@link + * TemporalOperationHandler} subclass that overrides {@link + * TemporalOperationHandler#cancelWorkflowRun}. Both annotations can coexist on the same {@link + * ServiceImpl} class, but never on the same method. + */ +@Experimental +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface TemporalOperation {} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java index 6a01d11fc6..257d7e69fc 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java @@ -13,26 +13,35 @@ * Generic Nexus operation handler backed by Temporal. Implements {@link OperationHandler} and * provides a composable way to map Temporal operations (start workflow, etc.) to Nexus operations. * - *

Usage example: + *

For the common case (default cancel behavior), prefer {@link TemporalOperation}, which + * collapses the operation factory into the method itself. Subclass this class only when you need to + * customize cancel behavior. Override {@link #cancelWorkflowRun} to change how workflow-run + * cancellations are handled. The {@link #start} and {@link #cancel} methods should not be + * overridden — they contain the core dispatch logic. + * + *

Custom-cancel example: * *

{@code
  * @OperationImpl
  * public OperationHandler startTransfer() {
- *   return TemporalOperationHandler.create((context, client, input) -> {
- *     return client.startWorkflow(
- *         TransferWorkflow.class,
- *         TransferWorkflow::transfer, input.getFromAccount(), input.getToAccount(),
- *         WorkflowOptions.newBuilder()
- *             .setWorkflowId("transfer-" + input.getTransferId())
- *             .build());
- *   });
+ *   return new TemporalOperationHandler(
+ *       (context, client, input) ->
+ *           client.startWorkflow(
+ *               TransferWorkflow.class,
+ *               TransferWorkflow::transfer,
+ *               input,
+ *               WorkflowOptions.newBuilder()
+ *                   .setWorkflowId("transfer-" + input.getTransferId())
+ *                   .build())) {
+ *     @Override
+ *     protected void cancelWorkflowRun(
+ *         TemporalOperationCancelContext ctx, CancelWorkflowRunInput input) {
+ *       // custom logic
+ *     }
+ *   };
  * }
  * }
* - *

This class supports subclassing to customize cancel behavior. Override {@link - * #cancelWorkflowRun} to change how workflow-run cancellations are handled. The {@link #start} and - * {@link #cancel} methods should not be overridden — they contain the core dispatch logic. - * * @param the input type * @param the result type */ @@ -57,17 +66,6 @@ protected TemporalOperationHandler(StartHandler startHandler) { this.startHandler = startHandler; } - /** - * Creates a {@link TemporalOperationHandler} from a start handler. Subclass and override {@link - * #cancelWorkflowRun} to customize cancel behavior. - * - * @param startHandler the handler to invoke on start operation requests - * @return an operation handler backed by the given start handler - */ - public static TemporalOperationHandler create(StartHandler startHandler) { - return new TemporalOperationHandler<>(startHandler); - } - @Override public final OperationStartResult start( OperationContext ctx, OperationStartDetails details, T input) { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java new file mode 100644 index 0000000000..6831a04ff4 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java @@ -0,0 +1,250 @@ +package io.temporal.internal.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; +import org.junit.Assert; +import org.junit.Test; + +public class TemporalOperationProcessorTest { + + @Service + public interface SingleOpService { + @Operation + String op(String input); + } + + @Service + public interface CompositeGenericService { + @Operation + java.util.List compose(java.util.Map input); + } + + @Test + public void happyPath_registersTemporalOperation() { + TemporalOperationProcessor.process(new ValidSugar()); + // No exception → registration succeeded. End-to-end behavior is covered in + // TemporalOperationAnnotationTest. + } + + @Test + public void happyPath_compositeGenerics() { + // Validation must traverse parameterized types (List, Map). + TemporalOperationProcessor.process(new CompositeOk()); + } + + @Test + public void rejects_compositeGenericInputMismatch() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new CompositeBadInput())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("input type mismatch")); + } + + @Test + public void rejects_compositeGenericOutputMismatch() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new CompositeBadOutput())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("output type mismatch")); + } + + @Test + public void rejects_rawReturnType() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new RawReturnType())); + Assert.assertTrue( + e.getMessage(), e.getMessage().contains("must use parameterized TemporalOperationResult")); + } + + @Test + public void rejects_nonPublicMethod() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new NonPublicMethod())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("must be public")); + } + + @Test + public void rejects_staticMethod() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new StaticMethod())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("must not be static")); + } + + @Test + public void invokeStartHandler_propagatesRuntimeExceptionUnwrapped() throws Exception { + // A RuntimeException thrown from a user @TemporalOperation method must arrive at the + // caller without an InvocationTargetException or RuntimeException wrapper inserted by the + // dispatch path. + ThrowingHandler instance = new ThrowingHandler(); + java.lang.reflect.Method m = + ThrowingHandler.class.getMethod( + "op", TemporalOperationStartContext.class, TemporalNexusClient.class, String.class); + java.lang.invoke.MethodHandle handle = + java.lang.invoke.MethodHandles.lookup().unreflect(m).bindTo(instance); + IllegalStateException thrown = + Assert.assertThrows( + IllegalStateException.class, + () -> TemporalOperationProcessor.invokeStartHandler(handle, null, null, "in")); + Assert.assertEquals("user-thrown", thrown.getMessage()); + // First user frame is at the top — no reflective wrapper class in between. + Assert.assertEquals(ThrowingHandler.class.getName(), thrown.getStackTrace()[0].getClassName()); + } + + @Test + public void rejects_badReturnType() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new BadReturnType())); + Assert.assertTrue( + e.getMessage(), e.getMessage().contains("must return TemporalOperationResult")); + } + + @Test + public void rejects_badArity() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new BadArity())); + Assert.assertTrue( + e.getMessage(), + e.getMessage() + .contains("must accept (TemporalOperationStartContext, TemporalNexusClient, I)")); + } + + @Test + public void rejects_dualAnnotation() { + IllegalArgumentException e = + Assert.assertThrows( + IllegalArgumentException.class, + () -> TemporalOperationProcessor.process(new DualAnnotated())); + Assert.assertTrue( + e.getMessage(), + e.getMessage().contains("@TemporalOperation and @OperationImpl cannot be combined")); + } + + // ----- Fixtures ----- + + @ServiceImpl(service = SingleOpService.class) + public static class ValidSugar { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class BadReturnType { + @TemporalOperation + public String op(TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return input; + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class BadArity { + @TemporalOperation + public TemporalOperationResult op(TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = CompositeGenericService.class) + public static class CompositeOk { + @TemporalOperation + public TemporalOperationResult> compose( + TemporalOperationStartContext ctx, + TemporalNexusClient client, + java.util.Map input) { + return TemporalOperationResult.sync(java.util.Collections.emptyList()); + } + } + + @ServiceImpl(service = CompositeGenericService.class) + public static class CompositeBadInput { + // Map value type is String instead of Integer. + @TemporalOperation + public TemporalOperationResult> compose( + TemporalOperationStartContext ctx, + TemporalNexusClient client, + java.util.Map input) { + return TemporalOperationResult.sync(java.util.Collections.emptyList()); + } + } + + @ServiceImpl(service = CompositeGenericService.class) + public static class CompositeBadOutput { + // Result list element type is Integer instead of String. + @TemporalOperation + public TemporalOperationResult> compose( + TemporalOperationStartContext ctx, + TemporalNexusClient client, + java.util.Map input) { + return TemporalOperationResult.sync(java.util.Collections.emptyList()); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @ServiceImpl(service = SingleOpService.class) + public static class RawReturnType { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class NonPublicMethod { + @TemporalOperation + TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class StaticMethod { + @TemporalOperation + public static TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class ThrowingHandler { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + throw new IllegalStateException("user-thrown"); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class DualAnnotated { + @TemporalOperation + @OperationImpl + public OperationHandler op() { + return new TemporalOperationHandler( + (ctx, client, input) -> TemporalOperationResult.sync(input)) {}; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java index 4773e6c521..1882995c7a 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java @@ -2,8 +2,6 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.enums.v1.EventType; import io.temporal.client.WorkflowFailedException; @@ -11,7 +9,10 @@ import io.temporal.client.WorkflowStub; import io.temporal.failure.CanceledFailure; import io.temporal.internal.Signal; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import java.time.Duration; @@ -121,17 +122,16 @@ public interface TestNexusCancelService { @ServiceImpl(service = TestNexusCancelService.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> - client.startWorkflow( - WaitForCancelWorkflowInterface.class, - WaitForCancelWorkflowInterface::execute, - input, - WorkflowOptions.newBuilder() - .setWorkflowId("generic-cancel-test-" + context.getService()) - .build())); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + return client.startWorkflow( + WaitForCancelWorkflowInterface.class, + WaitForCancelWorkflowInterface::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("generic-cancel-test-" + context.getService()) + .build()); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java index 9d0c4e6c9f..1ce58248a4 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java @@ -3,14 +3,15 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; import io.nexusrpc.handler.HandlerException; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowFailedException; import io.temporal.client.WorkflowOptions; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.NexusOperationFailure; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestWorkflows; @@ -94,27 +95,25 @@ public interface TestNexusServiceDoubleStart { @ServiceImpl(service = TestNexusServiceDoubleStart.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> { - // First start should succeed but the workflow blocks indefinitely - client.startWorkflow( - BlockingWorkflow.class, - BlockingWorkflow::execute, - input, - WorkflowOptions.newBuilder() - .setWorkflowId("double-start-first-" + context.getService()) - .build()); - // Second start should throw - return client.startWorkflow( - BlockingWorkflow.class, - BlockingWorkflow::execute, - input, - WorkflowOptions.newBuilder() - .setWorkflowId("double-start-second-" + context.getService()) - .build()); - }); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + // First start should succeed but the workflow blocks indefinitely + client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("double-start-first-" + context.getService()) + .build()); + // Second start should throw + return client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("double-start-second-" + context.getService()) + .build()); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java index 01eda16b01..65f871752e 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java @@ -2,11 +2,11 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestWorkflows; @@ -55,10 +55,10 @@ public interface TestNexusSyncService { @ServiceImpl(service = TestNexusSyncService.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> TemporalOperationResult.sync("sync-" + input)); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync("sync-" + input); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java index ae3d01be32..bcbeb41457 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java @@ -2,11 +2,12 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowOptions; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; @@ -60,75 +61,72 @@ public interface TestNexusServiceProc { @ServiceImpl(service = TestNexusServiceProc.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> { - String prefix = "generic-handler-test-proc" + input + "-"; - String workflowId = prefix + context.getService() + "-" + context.getOperation(); - WorkflowOptions options = - WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); - switch (input) { - case 0: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc.class, - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc::proc, - options); - case 1: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc::proc1, - "input", - options); - case 2: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc::proc2, - "input", - 2, - options); - case 3: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc::proc3, - "input", - 2, - 3, - options); - case 4: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc::proc4, - "input", - 2, - 3, - 4, - options); - case 5: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc::proc5, - "input", - 2, - 3, - 4, - 5, - options); - case 6: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc::proc6, - "input", - 2, - 3, - 4, - 5, - 6, - options); - default: - throw new IllegalArgumentException("unexpected input: " + input); - } - }); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, Integer input) { + String prefix = "generic-handler-test-proc" + input + "-"; + String workflowId = prefix + context.getService() + "-" + context.getOperation(); + WorkflowOptions options = WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); + switch (input) { + case 0: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc.class, + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc::proc, + options); + case 1: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc::proc1, + "input", + options); + case 2: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc::proc2, + "input", + 2, + options); + case 3: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc::proc3, + "input", + 2, + 3, + options); + case 4: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc::proc4, + "input", + 2, + 3, + 4, + options); + case 5: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc::proc5, + "input", + 2, + 3, + 4, + 5, + options); + case 6: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc::proc6, + "input", + 2, + 3, + 4, + 5, + 6, + options); + default: + throw new IllegalArgumentException("unexpected input: " + input); + } } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java index 49662cc6af..3ff5f36b43 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java @@ -2,11 +2,12 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowOptions; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; @@ -61,75 +62,72 @@ public interface TestNexusServiceGeneric { @ServiceImpl(service = TestNexusServiceGeneric.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> { - String prefix = "generic-handler-test-func" + input + "-"; - String workflowId = prefix + context.getService() + "-" + context.getOperation(); - WorkflowOptions options = - WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); - switch (input) { - case 0: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc.class, - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc::func, - options); - case 1: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc::func1, - "input", - options); - case 2: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc::func2, - "input", - 2, - options); - case 3: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc::func3, - "input", - 2, - 3, - options); - case 4: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc::func4, - "input", - 2, - 3, - 4, - options); - case 5: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc::func5, - "input", - 2, - 3, - 4, - 5, - options); - case 6: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc::func6, - "input", - 2, - 3, - 4, - 5, - 6, - options); - default: - throw new IllegalArgumentException("unexpected input: " + input); - } - }); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, Integer input) { + String prefix = "generic-handler-test-func" + input + "-"; + String workflowId = prefix + context.getService() + "-" + context.getOperation(); + WorkflowOptions options = WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); + switch (input) { + case 0: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc.class, + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc::func, + options); + case 1: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc::func1, + "input", + options); + case 2: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc::func2, + "input", + 2, + options); + case 3: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc::func3, + "input", + 2, + 3, + options); + case 4: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc::func4, + "input", + 2, + 3, + 4, + options); + case 5: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc::func5, + "input", + 2, + 3, + 4, + 5, + options); + case 6: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc::func6, + "input", + 2, + 3, + 4, + 5, + 6, + options); + default: + throw new IllegalArgumentException("unexpected input: " + input); + } } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java index 1f682d4534..17ebc2561e 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java @@ -2,11 +2,12 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowOptions; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; @@ -57,21 +58,17 @@ public interface TestNexusServiceUntyped { @ServiceImpl(service = TestNexusServiceUntyped.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> - client.startWorkflow( - "func1", - String.class, - WorkflowOptions.newBuilder() - .setWorkflowId( - "generic-handler-untyped-" - + context.getService() - + "-" - + context.getOperation()) - .build(), - input)); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + return client.startWorkflow( + "func1", + String.class, + WorkflowOptions.newBuilder() + .setWorkflowId( + "generic-handler-untyped-" + context.getService() + "-" + context.getOperation()) + .build(), + input); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java new file mode 100644 index 0000000000..21642a90ee --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java @@ -0,0 +1,175 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** End-to-end coverage of the {@link TemporalOperation} sugar. */ +public class TemporalOperationAnnotationTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(SugarCaller.class, MixedCaller.class, SugarTargetWorkflowImpl.class) + .setNexusServiceImplementation( + new SugarServiceImpl(), new SyncSugarServiceImpl(), new MixedServiceImpl()) + .build(); + + @Test + public void workflowRunSugar_endToEnd() { + TestWorkflows.TestWorkflow1 stub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + Assert.assertEquals("workflow:wf-input", stub.execute("wf-input")); + } + + @Test + public void syncSugar_endToEnd() { + SyncCallerWorkflow stub = + testWorkflowRule.newWorkflowStubTimeoutOptions(SyncCallerWorkflow.class); + Assert.assertEquals("sync:hi", stub.execute("hi")); + } + + @Test + public void mixedClass_bothOperationsReachable() { + MixedCallerWorkflow stub = + testWorkflowRule.newWorkflowStubTimeoutOptions(MixedCallerWorkflow.class); + Assert.assertEquals("workflow:mixed-input|legacy:legacy-input", stub.execute("mixed-input")); + } + + // ----- Caller workflows ----- + + @WorkflowInterface + public interface SyncCallerWorkflow { + @WorkflowMethod + String execute(String arg); + } + + @WorkflowInterface + public interface MixedCallerWorkflow { + @WorkflowMethod + String execute(String arg); + } + + @WorkflowInterface + public interface SugarTargetWorkflow { + @WorkflowMethod + String run(String arg); + } + + public static class SugarTargetWorkflowImpl implements SugarTargetWorkflow { + @Override + public String run(String arg) { + return "workflow:" + arg; + } + } + + /** Drives the workflow-run sugar test via {@link TestWorkflows.TestWorkflow1}. */ + public static class SugarCaller implements TestWorkflows.TestWorkflow1, SyncCallerWorkflow { + @Override + public String execute(String input) { + NexusServiceOptions options = defaultServiceOptions(); + // SyncCallerWorkflow re-uses the same impl method via the SyncCallerWorkflow interface; + // distinguish by sentinel input so each test exercises one path. + if ("hi".equals(input)) { + return Workflow.newNexusServiceStub(SyncSugarService.class, options).greet(input); + } + return Workflow.newNexusServiceStub(SugarService.class, options).start(input); + } + } + + public static class MixedCaller implements MixedCallerWorkflow { + @Override + public String execute(String input) { + MixedService stub = Workflow.newNexusServiceStub(MixedService.class, defaultServiceOptions()); + return stub.workflowOp(input) + "|" + stub.legacyOp("legacy-input"); + } + } + + private static NexusServiceOptions defaultServiceOptions() { + return NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()) + .build(); + } + + // ----- Nexus services ----- + + @Service + public interface SugarService { + @Operation + String start(String input); + } + + @Service + public interface SyncSugarService { + @Operation + String greet(String input); + } + + @Service + public interface MixedService { + @Operation + String workflowOp(String input); + + @Operation + String legacyOp(String input); + } + + @ServiceImpl(service = SugarService.class) + public static class SugarServiceImpl { + @TemporalOperation + public TemporalOperationResult start( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return client.startWorkflow( + SugarTargetWorkflow.class, + SugarTargetWorkflow::run, + input, + WorkflowOptions.newBuilder().setWorkflowId("sugar-" + ctx.getRequestId()).build()); + } + } + + @ServiceImpl(service = SyncSugarService.class) + public static class SyncSugarServiceImpl { + @TemporalOperation + public TemporalOperationResult greet( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync("sync:" + input); + } + } + + @ServiceImpl(service = MixedService.class) + public static class MixedServiceImpl { + @TemporalOperation + public TemporalOperationResult workflowOp( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return client.startWorkflow( + SugarTargetWorkflow.class, + SugarTargetWorkflow::run, + input, + WorkflowOptions.newBuilder().setWorkflowId("mixed-" + ctx.getRequestId()).build()); + } + + @OperationImpl + public OperationHandler legacyOp() { + return new TemporalOperationHandler( + (ctx, client, input) -> TemporalOperationResult.sync("legacy:" + input)) {}; + } + } +} diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java index 1ab6bc7a2b..7772036d5d 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java @@ -4,7 +4,6 @@ import com.google.common.base.Preconditions; import io.nexusrpc.ServiceDefinition; -import io.nexusrpc.handler.ServiceImplInstance; import io.opentracing.Tracer; import io.temporal.client.WorkflowClient; import io.temporal.common.Experimental; @@ -14,6 +13,7 @@ import io.temporal.common.metadata.POJOWorkflowImplMetadata; import io.temporal.common.metadata.POJOWorkflowMethodMetadata; import io.temporal.internal.common.env.ReflectionUtils; +import io.temporal.internal.nexus.TemporalOperationProcessor; import io.temporal.internal.sync.POJOWorkflowImplementationFactory; import io.temporal.spring.boot.ActivityImpl; import io.temporal.spring.boot.NexusServiceImpl; @@ -453,7 +453,8 @@ private void createWorkerFromAnExplicitConfig( AopUtils.getTargetClass(bean), taskQueue); worker.registerNexusServiceImplementation(bean); - ServiceDefinition definition = ServiceImplInstance.fromInstance(bean).getDefinition(); + ServiceDefinition definition = + TemporalOperationProcessor.process(bean).getDefinition(); addRegisteredNexusServiceImpl(worker, beanName, bean.getClass().getName(), definition); }); } @@ -531,7 +532,7 @@ private void configureNexusServiceImplementationAutoDiscovery( worker, beanName, bean.getClass().getName(), - ServiceImplInstance.fromInstance(bean).getDefinition()); + TemporalOperationProcessor.process(bean).getDefinition()); if (log.isInfoEnabled()) { log.info( "Registering auto-discovered nexus service bean '{}' of class {} on a worker {} with a task queue '{}'", diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java b/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java index 3ce288b9c8..40c0cbeb0b 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java @@ -3,7 +3,6 @@ import static io.temporal.internal.common.InternalUtils.createNormalTaskQueue; import com.google.protobuf.ByteString; -import io.nexusrpc.handler.ServiceImplInstance; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.common.v1.WorkflowType; import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; @@ -15,6 +14,7 @@ import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.nexus.TemporalOperationProcessor; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.WorkflowImplementationOptions; import io.temporal.workflow.NexusServiceOptions; @@ -33,7 +33,8 @@ public static WorkflowImplementationOptions applyNexusServiceOptions( String endpoint) { Map newNexusServiceOptions = new HashMap<>(); for (Object nexusService : nexusServiceImplementations) { - String serviceName = ServiceImplInstance.fromInstance(nexusService).getDefinition().getName(); + String serviceName = + TemporalOperationProcessor.process(nexusService).getDefinition().getName(); NexusServiceOptions serviceOptionWithEndpoint = options.getNexusServiceOptions().get(serviceName); if (serviceOptionWithEndpoint == null) { From 2f6376464c091ab4d3ae2456625bef3a13558279 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 24 Jun 2026 14:28:14 -0700 Subject: [PATCH 2/3] Fix up some test coverage --- .../nexus/TemporalOperationProcessorTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java index 6831a04ff4..25245a67ed 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java @@ -27,6 +27,12 @@ public interface CompositeGenericService { java.util.List compose(java.util.Map input); } + @Service + public interface VoidIoService { + @Operation + Void op(); + } + @Test public void happyPath_registersTemporalOperation() { TemporalOperationProcessor.process(new ValidSugar()); @@ -40,6 +46,13 @@ public void happyPath_compositeGenerics() { TemporalOperationProcessor.process(new CompositeOk()); } + @Test + public void happyPath_voidInputAndOutput() { + // A no-input @Operation (Void op()) must register: declared Void param matches Void input, + // and a Void result type matches the operation's Void output. + TemporalOperationProcessor.process(new VoidIo()); + } + @Test public void rejects_compositeGenericInputMismatch() { IllegalArgumentException e = @@ -238,6 +251,15 @@ public TemporalOperationResult op( } } + @ServiceImpl(service = VoidIoService.class) + public static class VoidIo { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, Void input) { + return TemporalOperationResult.sync(null); + } + } + @ServiceImpl(service = SingleOpService.class) public static class DualAnnotated { @TemporalOperation From 240ac3836c8fbf86e25b8163359b77f65c56d6a2 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 24 Jun 2026 16:03:13 -0700 Subject: [PATCH 3/3] Run spotless --- .../spring/boot/autoconfigure/template/WorkersTemplate.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java index 7772036d5d..6fb972cf0c 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java @@ -453,8 +453,7 @@ private void createWorkerFromAnExplicitConfig( AopUtils.getTargetClass(bean), taskQueue); worker.registerNexusServiceImplementation(bean); - ServiceDefinition definition = - TemporalOperationProcessor.process(bean).getDefinition(); + ServiceDefinition definition = TemporalOperationProcessor.process(bean).getDefinition(); addRegisteredNexusServiceImpl(worker, beanName, bean.getClass().getName(), definition); }); }