From 1a82c8268f6ea4b442bf61cd3ed913fc2c42a8cf Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 18 May 2026 18:06:04 -0700 Subject: [PATCH 01/33] Add standalone Nexus client (start/describe/cancel/terminate/list/count, typed and untyped service clients) --- .../java/io/temporal/client/NexusClient.java | 151 +++++++ .../io/temporal/client/NexusClientHandle.java | 45 ++ .../client/NexusClientHandleImpl.java | 343 +++++++++++++++ .../io/temporal/client/NexusClientImpl.java | 216 +++++++++ ...usClientOperationExecutionDescription.java | 26 ++ .../temporal/client/NexusClientOptions.java | 155 +++++++ .../client/NexusOperationExecutionCount.java | 94 ++++ .../NexusOperationExecutionMetadata.java | 214 +++++++++ .../temporal/client/NexusServiceClient.java | 65 +++ .../client/NexusServiceClientImpl.java | 59 +++ .../client/StartNexusOperationOptions.java | 248 +++++++++++ .../client/UntypedNexusClientHandle.java | 56 +++ .../client/UntypedNexusServiceClient.java | 29 ++ .../client/UntypedNexusServiceClientImpl.java | 88 ++++ .../NexusClientCallsInterceptor.java | 413 ++++++++++++++++++ .../NexusClientCallsInterceptorBase.java | 76 ++++ .../interceptors/NexusClientInterceptor.java | 24 + .../NexusClientInterceptorBase.java | 13 + .../client/RootNexusClientInvoker.java | 243 +++++++++++ .../external/GenericWorkflowClient.java | 30 ++ .../external/GenericWorkflowClientImpl.java | 135 ++++++ .../internal/util/MethodExtractor.java | 10 + .../client/nexus/NexusClientHandleTest.java | 357 +++++++++++++++ .../NexusClientInterceptorChainTest.java | 108 +++++ .../client/nexus/NexusClientTest.java | 215 +++++++++ .../client/nexus/NexusServiceClientTest.java | 226 ++++++++++ .../TestWorkflowMutableStateImpl.java | 2 +- 27 files changed, 3640 insertions(+), 1 deletion(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java new file mode 100644 index 000000000..6e87dfa81 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java @@ -0,0 +1,151 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.lang.reflect.Type; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +/** + * Client for managing standalone Nexus operation executions. Obtain an instance via {@link + * #newInstance(WorkflowServiceStubs)} or {@link #newInstance(WorkflowServiceStubs, + * NexusClientOptions)}. Do not create this object per request; share it for the lifetime of the + * process. + * + *

Standalone Nexus operations run independently of any workflow — they are scheduled, monitored, + * and managed directly through this client (and the service-bound clients it produces) rather than + * from within a workflow execution. + * + *

To start operations, build a service-bound client and call {@code start}/{@code execute}: + * + *

{@code
+ * NexusClient client = NexusClient.newInstance(stubs, options);
+ *
+ * // Typed: bind to an @ServiceInterface and invoke a method reference.
+ * NexusServiceClient svc =
+ *     NexusServiceClient.newInstance(MyService.class, "my-endpoint", stubs, options);
+ * String result = svc.execute(MyService::greet, "world");
+ *
+ * // Untyped: dispatch by operation name string.
+ * UntypedNexusServiceClient untyped =
+ *     client.newUntypedNexusServiceClient("my-endpoint", "MyService");
+ * UntypedNexusClientHandle handle = untyped.start("greet", null, "world");
+ * }
+ * + *

To act on an existing operation (describe, cancel, terminate, get result), obtain a handle via + * {@link #getHandle}: + * + *

{@code
+ * NexusClientHandle handle = client.getHandle(operationId, runId, String.class);
+ * String result = handle.getResult();
+ * handle.cancel("user requested");
+ * }
+ * + *

For visibility queries across all operations in the namespace, see {@link + * #listNexusOperationExecutions} and {@link #countNexusOperationExecutions}. + * + * @see NexusServiceClient + * @see UntypedNexusServiceClient + * @see NexusClientHandle + */ +@Experimental +public interface NexusClient { + + /** + * Creates a client with default {@link NexusClientOptions}. + * + * @param service gRPC stubs connected to a Temporal Service endpoint + */ + static NexusClient newInstance(WorkflowServiceStubs service) { + return NexusClientImpl.newInstance(service, NexusClientOptions.getDefaultInstance()); + } + + /** + * Creates a client with the supplied options. + * + * @param service gRPC stubs connected to a Temporal Service endpoint + * @param options namespace, data converter, interceptors, and defaults applied to operations + * started through this client + */ + static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) { + return NexusClientImpl.newInstance(service, options); + } + + /** Returns the underlying gRPC stubs this client routes RPCs through. */ + WorkflowServiceStubs getWorkflowServiceStubs(); + + /** + * Returns an untyped handle to an existing operation execution, targeting the latest run. To bind + * a result type, wrap the handle with {@link NexusClientHandle#fromUntyped}. + * + * @param operationId the user-assigned operation ID + * @return an untyped handle + */ + UntypedNexusClientHandle getHandle(String operationId); + + /** + * Returns an untyped handle to an existing operation execution, optionally pinned to a specific + * run. + * + * @param operationId the user-assigned operation ID + * @param runId the server-assigned run ID, or {@code null} to target the latest run + * @return an untyped handle + */ + UntypedNexusClientHandle getHandle(String operationId, @Nullable String runId); + + /** + * Returns a typed handle to an existing operation execution, bound to {@code resultClass}. + * + * @param operationId the user-assigned operation ID + * @param runId the server-assigned run ID, or {@code null} to target the latest run + * @param resultClass expected result type + * @param result type + */ + NexusClientHandle getHandle( + String operationId, @Nullable String runId, Class resultClass); + + /** + * Returns a typed handle to an existing operation execution, bound to {@code resultClass}/{@code + * resultType}. Use the {@code resultType} variant when the result is a generic type whose + * parameters cannot be captured by {@link Class} alone (e.g. {@code List}). + * + * @param operationId the user-assigned operation ID + * @param runId the server-assigned run ID, or {@code null} to target the latest run + * @param resultClass expected result class + * @param resultType generic type for deserialization; may be {@code null} + * @param result type + */ + NexusClientHandle getHandle( + String operationId, @Nullable String runId, Class resultClass, @Nullable Type resultType); + + /** + * Builds an untyped service-bound client targeting the given endpoint and service. Use this to + * dispatch operations by name string when no service interface is available. + * + * @param endpoint Nexus endpoint name registered on the Temporal Service + * @param serviceName Nexus service name on that endpoint + */ + UntypedNexusServiceClient newUntypedNexusServiceClient(String endpoint, String serviceName); + + /** + * Returns a stream of standalone Nexus operation executions matching the given visibility query. + * The stream paginates lazily over server-side results — pages are fetched on demand as the + * stream is consumed. + * + * @param query Temporal visibility query string, or {@code null} to return all executions in the + * client namespace + * @return a lazy stream of matching executions + */ + Stream listNexusOperationExecutions(@Nullable String query); + + /** + * Returns the count of standalone Nexus operation executions matching the given visibility query, + * optionally with aggregation groups. + * + * @param query Temporal visibility query string, or {@code null} to count all executions in the + * client namespace + * @return execution count, optionally with aggregation groups when the query uses {@code GROUP + * BY} + */ + NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java new file mode 100644 index 000000000..5500eadd8 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java @@ -0,0 +1,45 @@ +package io.temporal.client; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; + +/** + * Typed handle for interacting with an existing standalone Nexus operation execution. Add a result + * type binding to an {@link UntypedNexusClientHandle} (returned by {@link + * NexusClient#getHandle(String)}) by calling one of the {@link #fromUntyped} factories. + */ +public interface NexusClientHandle extends UntypedNexusClientHandle { + + /** Wrap an {@link UntypedNexusClientHandle} as a typed handle bound to {@code resultClass}. */ + static NexusClientHandle fromUntyped( + UntypedNexusClientHandle handle, Class resultClass) { + return fromUntyped(handle, resultClass, null); + } + + /** + * Wrap an {@link UntypedNexusClientHandle} as a typed handle bound to {@code resultClass} and + * {@code resultType}. Pass a non-null {@code resultType} when the result is a generic type whose + * parameters cannot be captured by {@link Class} alone (e.g. {@code List}). + */ + static NexusClientHandle fromUntyped( + UntypedNexusClientHandle handle, Class resultClass, @Nullable Type resultType) { + return NexusClientHandleImpl.fromUntyped(handle, resultClass, resultType); + } + + /** Block until the operation completes and return the typed result. */ + R getResult(); + + /** Block up to {@code timeout} for the operation to complete and return the typed result. */ + R getResult(long timeout, java.util.concurrent.TimeUnit unit) + throws java.util.concurrent.TimeoutException; + + /** Returns a future that completes with the typed result when the operation finishes. */ + CompletableFuture getResultAsync(); + + /** + * Returns a future that completes with the typed result, or completes exceptionally with a {@link + * java.util.concurrent.TimeoutException} if {@code timeout} elapses first. + */ + CompletableFuture getResultAsync(long timeout, java.util.concurrent.TimeUnit unit); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java new file mode 100644 index 000000000..c283635fd --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java @@ -0,0 +1,343 @@ +package io.temporal.client; + +import io.grpc.Deadline; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.NexusOperationWaitStage; +import io.temporal.api.failure.v1.Failure; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput; +import java.lang.reflect.Type; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +/** + * Single implementation of {@link NexusClientHandle}/{@link UntypedNexusClientHandle}. Constructed + * untyped by {@link NexusClient#getHandle(String)} and bound to a result type via {@link + * NexusClientHandle#fromUntyped}. + */ +public class NexusClientHandleImpl implements NexusClientHandle { + + /** Default deadline applied to per-handle non-poll RPCs (e.g. {@code describe}). */ + private static final long DEFAULT_DEADLINE_SECONDS = 30; + + /** + * Per-poll deadline used by {@link #getResult} and {@link #getResultAsync}. The server holds the + * request up to this long waiting for completion; if the operation hasn't finished, we re-poll. + */ + private static final long POLL_DEADLINE_SECONDS = 60; + + final NexusClientCallsInterceptor interceptor; + final String operationId; + final @Nullable String runId; + final DataConverter dataConverter; + final @Nullable Class resultClass; + final @Nullable Type resultType; + + /** Construct an untyped handle. Used by {@link NexusClientImpl#getHandle}. */ + public NexusClientHandleImpl( + NexusClientCallsInterceptor interceptor, + String operationId, + @Nullable String runId, + DataConverter dataConverter) { + this(interceptor, operationId, runId, dataConverter, null, null); + } + + /** + * Implementation of {@link NexusClientHandle#fromUntyped(UntypedNexusClientHandle, Class, Type)}. + * Lives here so the interface doesn't reach into impl-private state. + */ + static NexusClientHandle fromUntyped( + UntypedNexusClientHandle handle, Class resultClass, @Nullable Type resultType) { + if (!(handle instanceof NexusClientHandleImpl)) { + throw new IllegalArgumentException( + "Unsupported handle implementation: " + handle.getClass().getName()); + } + NexusClientHandleImpl source = (NexusClientHandleImpl) handle; + return new NexusClientHandleImpl<>( + source.interceptor, + source.operationId, + source.runId, + source.dataConverter, + resultClass, + resultType); + } + + /** Construct a typed handle. Use {@link NexusClientHandle#fromUntyped} from caller code. */ + NexusClientHandleImpl( + NexusClientCallsInterceptor interceptor, + String operationId, + @Nullable String runId, + DataConverter dataConverter, + @Nullable Class resultClass, + @Nullable Type resultType) { + if (interceptor == null) { + throw new IllegalArgumentException("interceptor is required"); + } + if (operationId == null) { + throw new IllegalArgumentException("operationId is required"); + } + if (dataConverter == null) { + throw new IllegalArgumentException("dataConverter is required"); + } + this.interceptor = interceptor; + this.operationId = operationId; + this.runId = runId; + this.dataConverter = dataConverter; + this.resultClass = resultClass; + this.resultType = resultType; + } + + @Override + public String getNexusOperationId() { + return operationId; + } + + @Override + public @Nullable String getNexusOperationRunId() { + return runId; + } + + @Override + public NexusClientOperationExecutionDescription describe() { + DescribeNexusOperationExecutionInput input = + new DescribeNexusOperationExecutionInput( + operationId, + runId, + /* includeInput= */ false, + /* includeOutcome= */ true, + Deadline.after(DEFAULT_DEADLINE_SECONDS, TimeUnit.SECONDS)); + DescribeNexusOperationExecutionOutput output = + interceptor.describeNexusOperationExecution(input); + return output.getDescription(); + } + + @Override + public void cancel() { + cancel(null); + } + + @Override + public void cancel(@Nullable String reason) { + interceptor.requestCancelNexusOperationExecution( + new RequestCancelNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public void terminate() { + terminate(null); + } + + @Override + public void terminate(@Nullable String reason) { + interceptor.terminateNexusOperationExecution( + new TerminateNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public X getResult(Class resultClass) { + return getResult(resultClass, null); + } + + @Override + public X getResult(Class resultClass, @Nullable Type resultType) { + PollNexusOperationExecutionOutput out = pollUntilCompleted(); + return extractResult(out, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync(Class resultClass) { + return getResultAsync(resultClass, null); + } + + @Override + public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { + return pollAsyncUntilCompleted().thenApply(out -> extractResult(out, resultClass, resultType)); + } + + @Override + public X getResult(long timeout, TimeUnit unit, Class resultClass) + throws TimeoutException { + return getResult(timeout, unit, resultClass, null); + } + + @Override + public X getResult( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException { + long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); + PollNexusOperationExecutionOutput out = pollSyncUntilCompletedOrDeadline(deadlineNanos); + return extractResult(out, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass) { + return getResultAsync(timeout, unit, resultClass, null); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { + long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); + return pollAsyncUntilCompletedOrDeadline(deadlineNanos) + .thenApply(out -> extractResult(out, resultClass, resultType)); + } + + @Override + public R getResult() { + if (resultClass == null) { + throw new IllegalStateException( + "getResult() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); + } + return getResult(resultClass, resultType); + } + + @Override + public R getResult(long timeout, TimeUnit unit) throws TimeoutException { + if (resultClass == null) { + throw new IllegalStateException( + "getResult() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); + } + return getResult(timeout, unit, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync() { + if (resultClass == null) { + throw new IllegalStateException( + "getResultAsync() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); + } + return getResultAsync(resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { + if (resultClass == null) { + throw new IllegalStateException( + "getResultAsync() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); + } + return getResultAsync(timeout, unit, resultClass, resultType); + } + + /** Long-poll loop: re-poll if the server returns before the operation completes. */ + private PollNexusOperationExecutionOutput pollUntilCompleted() { + while (true) { + PollNexusOperationExecutionOutput out = + interceptor.pollNexusOperationExecution(buildPollInput()); + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return out; + } + } + } + + /** Async long-poll loop using {@code thenCompose} to recurse without blocking a thread. */ + private CompletableFuture pollAsyncUntilCompleted() { + return interceptor + .pollNexusOperationExecutionAsync(buildPollInput()) + .thenCompose( + out -> { + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(out); + } + return pollAsyncUntilCompleted(); + }); + } + + /** Sync poll loop bounded by an absolute nanos deadline. */ + private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(long deadlineNanos) + throws TimeoutException { + while (true) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new TimeoutException("getResult timed out before the operation completed"); + } + long pollDeadlineNanos = + Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); + PollNexusOperationExecutionInput pollInput = + new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); + PollNexusOperationExecutionOutput out; + try { + out = interceptor.pollNexusOperationExecution(pollInput); + } catch (RuntimeException e) { + if (System.nanoTime() >= deadlineNanos) { + TimeoutException timeout = + new TimeoutException("getResult timed out before the operation completed"); + timeout.initCause(e); + throw timeout; + } + throw e; + } + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return out; + } + } + } + + /** Async poll loop bounded by an absolute nanos deadline. */ + private CompletableFuture pollAsyncUntilCompletedOrDeadline( + long deadlineNanos) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally( + new TimeoutException("getResultAsync timed out before the operation completed")); + return failed; + } + long pollDeadlineNanos = + Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); + PollNexusOperationExecutionInput pollInput = + new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); + return interceptor + .pollNexusOperationExecutionAsync(pollInput) + .thenCompose( + out -> { + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(out); + } + return pollAsyncUntilCompletedOrDeadline(deadlineNanos); + }); + } + + private PollNexusOperationExecutionInput buildPollInput() { + return new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + Deadline.after(POLL_DEADLINE_SECONDS, TimeUnit.SECONDS)); + } + + /** + * Convert a completed poll response into the typed result, throwing the operation's failure as an + * exception if it failed. + */ + private X extractResult( + PollNexusOperationExecutionOutput out, Class resultClass, @Nullable Type resultType) { + Optional failure = out.getFailure(); + if (failure.isPresent()) { + throw dataConverter.failureToException(failure.get()); + } + Optional payload = out.getResult(); + if (!payload.isPresent()) { + return null; + } + return dataConverter.fromPayload( + payload.get(), resultClass, resultType != null ? resultType : resultClass); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java new file mode 100644 index 000000000..c1754ff7b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -0,0 +1,216 @@ +package io.temporal.client; + +import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread; + +import com.google.protobuf.ByteString; +import com.uber.m3.tally.Scope; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.CountNexusOperationExecutionsInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.CountNexusOperationExecutionsOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.ListNexusOperationExecutionsInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.ListNexusOperationExecutionsOutput; +import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.internal.WorkflowThreadMarker; +import io.temporal.internal.client.NamespaceInjectWorkflowServiceStubs; +import io.temporal.internal.client.RootNexusClientInvoker; +import io.temporal.internal.client.external.GenericWorkflowClient; +import io.temporal.internal.client.external.GenericWorkflowClientImpl; +import io.temporal.serviceclient.MetricsTag; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Experimental +public class NexusClientImpl implements NexusClient { + + private static final Logger log = LoggerFactory.getLogger(NexusClientImpl.class); + + private final WorkflowServiceStubs workflowServiceStubs; + private final NexusClientOptions options; + private final GenericWorkflowClient genericClient; + private final Scope metricsScope; + private final NexusClientCallsInterceptor nexusClientCallsInvoker; + private final List interceptors; + + public static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) { + enforceNonWorkflowThread(); + return WorkflowThreadMarker.protectFromWorkflowThread( + new NexusClientImpl(service, options), NexusClient.class); + } + + NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientOptions options) { + workflowServiceStubs = + new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace()); + this.workflowServiceStubs = workflowServiceStubs; + this.options = options; + this.metricsScope = + workflowServiceStubs + .getOptions() + .getMetricsScope() + .tagged(MetricsTag.defaultTags(options.getNamespace())); + this.genericClient = new GenericWorkflowClientImpl(workflowServiceStubs, metricsScope); + this.interceptors = options.getInterceptors(); + this.nexusClientCallsInvoker = initializeClientInvoker(); + if (log.isDebugEnabled()) { + log.debug( + "NexusClient initialized: namespace={}, interceptors={}", + options.getNamespace(), + interceptors.size()); + } + } + + private NexusClientCallsInterceptor initializeClientInvoker() { + NexusClientCallsInterceptor invoker = new RootNexusClientInvoker(genericClient, options); + for (NexusClientInterceptor clientInterceptor : interceptors) { + NexusClientCallsInterceptor wrapped = clientInterceptor.nexusClientCallsInterceptor(invoker); + if (wrapped == null) { + throw new IllegalStateException( + "NexusClientInterceptor " + + clientInterceptor.getClass().getName() + + " returned null from nexusClientCallsInterceptor; expected a non-null" + + " NexusClientCallsInterceptor wrapping the supplied next link"); + } + invoker = wrapped; + } + return invoker; + } + + @Override + public WorkflowServiceStubs getWorkflowServiceStubs() { + return workflowServiceStubs; + } + + @Override + public UntypedNexusClientHandle getHandle(String operationId) { + return getHandle(operationId, null); + } + + @Override + public UntypedNexusClientHandle getHandle(String operationId, @Nullable String runId) { + return new NexusClientHandleImpl<>( + nexusClientCallsInvoker, operationId, runId, options.getDataConverter()); + } + + @Override + public NexusClientHandle getHandle( + String operationId, @Nullable String runId, Class resultClass) { + return getHandle(operationId, runId, resultClass, null); + } + + @Override + public NexusClientHandle getHandle( + String operationId, + @Nullable String runId, + Class resultClass, + @Nullable java.lang.reflect.Type resultType) { + return new NexusClientHandleImpl<>( + nexusClientCallsInvoker, + operationId, + runId, + options.getDataConverter(), + resultClass, + resultType); + } + + @Override + public UntypedNexusServiceClient newUntypedNexusServiceClient( + String endpoint, String serviceName) { + return new UntypedNexusServiceClientImpl( + nexusClientCallsInvoker, endpoint, serviceName, options); + } + + /** + * Returns the head of the interceptor chain. Package-private so service-client builders can route + * start RPCs through the chain without exposing it on the public {@link NexusClient} interface. + */ + NexusClientCallsInterceptor getNexusClientCallsInvoker() { + return nexusClientCallsInvoker; + } + + private static final int DEFAULT_LIST_PAGE_SIZE = 1000; + + @Override + public Stream listNexusOperationExecutions( + @Nullable String query) { + Iterator iter = + new ListPageIterator(nexusClientCallsInvoker, query, DEFAULT_LIST_PAGE_SIZE); + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize(iter, Spliterator.ORDERED | Spliterator.NONNULL), + false); + } + + @Override + public NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query) { + CountNexusOperationExecutionsOutput out = + nexusClientCallsInvoker.countNexusOperationExecutions( + new CountNexusOperationExecutionsInput(query)); + List publicGroups = + out.getGroups().stream() + .map( + g -> + new NexusOperationExecutionCount.AggregationGroup( + g.getCount(), g.getGroupValues())) + .collect(Collectors.toList()); + return new NexusOperationExecutionCount(out.getCount(), publicGroups); + } + + /** Lazily fetches pages from the interceptor and flattens them into a single iteration. */ + private static final class ListPageIterator implements Iterator { + private final NexusClientCallsInterceptor invoker; + private final @Nullable String query; + private final int pageSize; + private Iterator current = + java.util.Collections.emptyIterator(); + private @Nullable ByteString nextPageToken = null; + private boolean exhausted = false; + + ListPageIterator(NexusClientCallsInterceptor invoker, @Nullable String query, int pageSize) { + this.invoker = invoker; + this.query = query; + this.pageSize = pageSize; + } + + @Override + public boolean hasNext() { + while (!current.hasNext() && !exhausted) { + fetchNextPage(); + } + return current.hasNext(); + } + + @Override + public NexusOperationExecutionMetadata next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return current.next(); + } + + private void fetchNextPage() { + ListNexusOperationExecutionsOutput page = + invoker.listNexusOperationExecutions( + new ListNexusOperationExecutionsInput(query, pageSize, nextPageToken)); + current = + page.getOperations().stream() + .map(NexusOperationExecutionMetadata::fromListInfo) + .iterator(); + ByteString token = page.getNextPageToken(); + if (token == null || token.isEmpty()) { + exhausted = true; + nextPageToken = null; + } else { + nextPageToken = token; + } + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java new file mode 100644 index 000000000..fa1ec0ca4 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java @@ -0,0 +1,26 @@ +package io.temporal.client; + +import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse; +import io.temporal.common.Experimental; + +/** Snapshot of a standalone Nexus operation execution returned by describe/poll calls. */ +@Experimental +public final class NexusClientOperationExecutionDescription { + + private final DescribeNexusOperationExecutionResponse response; + + public NexusClientOperationExecutionDescription( + DescribeNexusOperationExecutionResponse response) { + this.response = response; + } + + /** Run ID of the operation described. */ + public String getRunId() { + return response.getRunId(); + } + + /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ + public DescribeNexusOperationExecutionResponse getRawResponse() { + return response; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java new file mode 100644 index 000000000..1e1e0e983 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java @@ -0,0 +1,155 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.common.interceptors.NexusClientInterceptor; +import java.lang.management.ManagementFactory; +import java.util.Collections; +import java.util.List; + +/** + * Options that configure a {@link NexusClient} (and the service-bound clients it produces). + * + *

Carries only client-wide settings (namespace, data converter, interceptors). Per-call settings + * — operation ID, timeouts, search attributes, summary, id-reuse/conflict policies — belong on + * {@link StartNexusOperationOptions}. + * + *

Obtain a builder via {@link #newBuilder()} or copy an existing instance via {@link + * #newBuilder(NexusClientOptions)}. The default instance ({@link #getDefaultInstance()}) is + * suitable when only the namespace is required and the {@link GlobalDataConverter} is appropriate. + * + *

{@code
+ * NexusClientOptions options =
+ *     NexusClientOptions.newBuilder()
+ *         .setNamespace("default")
+ *         .setDataConverter(myDataConverter)
+ *         .build();
+ * }
+ */ +@Experimental +public class NexusClientOptions { + + private final String namespace; + private final List interceptors; + private final DataConverter dataConverter; + private final String identity; + + private NexusClientOptions( + String namespace, + List interceptors, + DataConverter dataConverter, + String identity) { + this.namespace = namespace; + this.interceptors = interceptors; + this.dataConverter = dataConverter; + this.identity = identity; + } + + /** Get the namespace this client will operate on. */ + public String getNamespace() { + return namespace; + } + + /** Get the interceptors of this client. */ + public List getInterceptors() { + return interceptors; + } + + /** Get the data converter used to serialize Nexus operation inputs and deserialize results. */ + public DataConverter getDataConverter() { + return dataConverter; + } + + /** + * Human-readable identity of this client. Stamped onto outgoing write requests (start, cancel, + * terminate) so server-side history and audit trails can attribute the action to a caller. + */ + public String getIdentity() { + return identity; + } + + /** Returns a fresh builder. */ + public static NexusClientOptions.Builder newBuilder() { + return new NexusClientOptions.Builder(); + } + + /** Returns a builder seeded with the values from {@code options}. */ + public static NexusClientOptions.Builder newBuilder(NexusClientOptions options) { + return new NexusClientOptions.Builder(options); + } + + private static final NexusClientOptions DEFAULT_INSTANCE; + + /** + * Returns an options instance with all defaults. Note this leaves namespace unset; callers + * usually need to specify a namespace. + */ + public static NexusClientOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + static { + DEFAULT_INSTANCE = NexusClientOptions.newBuilder().build(); + } + + /** Builder for {@link NexusClientOptions}. */ + public static class Builder { + private String namespace; + private List interceptors = Collections.emptyList(); + private DataConverter dataConverter = GlobalDataConverter.get(); + private String identity; + + private Builder() {} + + private Builder(NexusClientOptions options) { + if (options == null) { + return; + } + namespace = options.namespace; + interceptors = options.interceptors; + dataConverter = options.dataConverter; + identity = options.identity; + } + + /** Set the namespace this client will operate on. */ + public NexusClientOptions.Builder setNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** Set the interceptors for this client, but don't allow null lists to happen. */ + public NexusClientOptions.Builder setInterceptors(List interceptors) { + if (interceptors == null) { + this.interceptors = Collections.emptyList(); + } else { + this.interceptors = interceptors; + } + return this; + } + + /** + * Set the data converter used to serialize Nexus operation inputs and deserialize results. + * Defaults to {@link GlobalDataConverter#get()}. + */ + public NexusClientOptions.Builder setDataConverter(DataConverter dataConverter) { + this.dataConverter = dataConverter; + return this; + } + + /** + * Override the human-readable identity stamped on outgoing write requests. Defaults to the JVM + * runtime name (typically {@code pid@host}). + */ + public NexusClientOptions.Builder setIdentity(String identity) { + this.identity = identity; + return this; + } + + public NexusClientOptions build() { + String resolvedIdentity = + identity == null ? ManagementFactory.getRuntimeMXBean().getName() : identity; + return new NexusClientOptions(namespace, interceptors, dataConverter, resolvedIdentity); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java new file mode 100644 index 000000000..271671cf5 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java @@ -0,0 +1,94 @@ +package io.temporal.client; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import io.temporal.internal.common.SearchAttributesUtil; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +/** Result of counting standalone Nexus operation executions. */ +@Experimental +public class NexusOperationExecutionCount { + + /** An individual aggregation group. */ + @Experimental + public static class AggregationGroup { + private final List> groupValues; + private final long count; + + /** Construct from raw payload group values; values are decoded eagerly. */ + public AggregationGroup(long count, List groupValues) { + this.groupValues = + groupValues.stream().map(SearchAttributesUtil::decode).collect(Collectors.toList()); + this.count = count; + } + + /** Values of the group, decoded from search attribute payloads. */ + public List> getGroupValues() { + return groupValues; + } + + /** Count of operations in this group. */ + public long getCount() { + return count; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AggregationGroup that = (AggregationGroup) o; + return count == that.count && Objects.equals(groupValues, that.groupValues); + } + + @Override + public int hashCode() { + return Objects.hash(groupValues, count); + } + + @Override + public String toString() { + return "AggregationGroup{groupValues=" + groupValues + ", count=" + count + '}'; + } + } + + private final long count; + private final List groups; + + public NexusOperationExecutionCount(long count, List groups) { + this.count = count; + this.groups = Collections.unmodifiableList(groups); + } + + /** Total number of operation executions matching the query. */ + public long getCount() { + return count; + } + + /** Aggregation groups returned by the service. Empty if no grouping was requested. */ + @Nonnull + public List getGroups() { + return groups; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NexusOperationExecutionCount that = (NexusOperationExecutionCount) o; + return count == that.count && Objects.equals(groups, that.groups); + } + + @Override + public int hashCode() { + return Objects.hash(count, groups); + } + + @Override + public String toString() { + return "NexusOperationExecutionCount{count=" + count + ", groups=" + groups + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java new file mode 100644 index 000000000..075e2e42f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java @@ -0,0 +1,214 @@ +package io.temporal.client; + +import io.temporal.api.enums.v1.NexusOperationExecutionStatus; +import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo; +import io.temporal.common.Experimental; +import io.temporal.common.SearchAttributes; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.SearchAttributesUtil; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Information about a standalone Nexus operation execution returned by {@link + * NexusClient#listNexusOperationExecutions}. + */ +@Experimental +public class NexusOperationExecutionMetadata { + + private final @Nullable NexusOperationExecutionListInfo rawListInfo; + private final String operationId; + private final @Nullable String runId; + private final String endpoint; + private final String service; + private final String operation; + private final Instant scheduledTime; + private final @Nullable Instant closeTime; + private final NexusOperationExecutionStatus status; + private final SearchAttributes searchAttributes; + private final long stateTransitionCount; + private final @Nullable Duration executionDuration; + + NexusOperationExecutionMetadata( + @Nullable NexusOperationExecutionListInfo rawListInfo, + String operationId, + @Nullable String runId, + String endpoint, + String service, + String operation, + Instant scheduledTime, + @Nullable Instant closeTime, + NexusOperationExecutionStatus status, + SearchAttributes searchAttributes, + long stateTransitionCount, + @Nullable Duration executionDuration) { + this.rawListInfo = rawListInfo; + this.operationId = operationId; + this.runId = runId; + this.endpoint = endpoint; + this.service = service; + this.operation = operation; + this.scheduledTime = scheduledTime; + this.closeTime = closeTime; + this.status = status; + this.searchAttributes = searchAttributes; + this.stateTransitionCount = stateTransitionCount; + this.executionDuration = executionDuration; + } + + public static NexusOperationExecutionMetadata fromListInfo(NexusOperationExecutionListInfo info) { + String runId = info.getRunId(); + return new NexusOperationExecutionMetadata( + info, + info.getOperationId(), + runId.isEmpty() ? null : runId, + info.getEndpoint(), + info.getService(), + info.getOperation(), + ProtobufTimeUtils.toJavaInstant(info.getScheduleTime()), + info.hasCloseTime() ? ProtobufTimeUtils.toJavaInstant(info.getCloseTime()) : null, + info.getStatus(), + SearchAttributesUtil.decodeTyped(info.getSearchAttributes()), + info.getStateTransitionCount(), + info.hasExecutionDuration() + ? ProtobufTimeUtils.toJavaDuration(info.getExecutionDuration()) + : null); + } + + /** + * The raw protobuf list info from the server. Only present when this instance was created via + * {@link #fromListInfo}. + */ + @Nullable + public NexusOperationExecutionListInfo getRawListInfo() { + return rawListInfo; + } + + /** The user-assigned identifier for this operation. */ + @Nonnull + public String getOperationId() { + return operationId; + } + + /** The server-assigned run ID for this operation execution. May be {@code null}. */ + @Nullable + public String getRunId() { + return runId; + } + + /** The Nexus endpoint name this operation targets. */ + @Nonnull + public String getEndpoint() { + return endpoint; + } + + /** The Nexus service name on the endpoint. */ + @Nonnull + public String getService() { + return service; + } + + /** The Nexus operation name within the service. */ + @Nonnull + public String getOperation() { + return operation; + } + + /** Time when the operation was originally scheduled via a {@code StartNexusOperation} request. */ + @Nonnull + public Instant getScheduledTime() { + return scheduledTime; + } + + /** Time the operation transitioned to a terminal status. {@code null} while still running. */ + @Nullable + public Instant getCloseTime() { + return closeTime; + } + + /** General status of the operation execution. */ + @Nonnull + public NexusOperationExecutionStatus getStatus() { + return status; + } + + /** Search attributes attached to this operation execution. */ + @Nonnull + public SearchAttributes getSearchAttributes() { + return searchAttributes; + } + + /** Server-tracked count of state transitions; updated on terminal status. */ + public long getStateTransitionCount() { + return stateTransitionCount; + } + + /** Close time minus scheduled time. {@code null} while still running. */ + @Nullable + public Duration getExecutionDuration() { + return executionDuration; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NexusOperationExecutionMetadata that = (NexusOperationExecutionMetadata) o; + return stateTransitionCount == that.stateTransitionCount + && Objects.equals(operationId, that.operationId) + && Objects.equals(runId, that.runId) + && Objects.equals(endpoint, that.endpoint) + && Objects.equals(service, that.service) + && Objects.equals(operation, that.operation) + && Objects.equals(scheduledTime, that.scheduledTime) + && Objects.equals(closeTime, that.closeTime) + && status == that.status + && Objects.equals(searchAttributes, that.searchAttributes) + && Objects.equals(executionDuration, that.executionDuration); + } + + @Override + public int hashCode() { + return Objects.hash( + operationId, + runId, + endpoint, + service, + operation, + scheduledTime, + closeTime, + status, + searchAttributes, + stateTransitionCount, + executionDuration); + } + + @Override + public String toString() { + return "NexusOperationExecutionMetadata{" + + "operationId='" + + operationId + + "', runId='" + + runId + + "', endpoint='" + + endpoint + + "', service='" + + service + + "', operation='" + + operation + + "', status=" + + status + + ", scheduledTime=" + + scheduledTime + + ", closeTime=" + + closeTime + + ", executionDuration=" + + executionDuration + + ", searchAttributes=" + + searchAttributes + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java new file mode 100644 index 000000000..1208c8145 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -0,0 +1,65 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; + +/** + * Typed client for invoking standalone Nexus operations on a specific service interface {@code T}. + * + *

Operations are dispatched via method references (or {@link BiFunction} lambdas) that target + * methods on {@code T}; the client extracts the operation name from the invocation and delegates to + * {@link NexusClient}. For visibility queries (list/count) across operations, use {@link + * NexusClient} directly. + */ +@Experimental +public interface NexusServiceClient extends UntypedNexusServiceClient { + + static NexusServiceClient newInstance( + Class service, String endpoint, WorkflowServiceStubs stubs) { + return newInstance(service, endpoint, stubs, NexusClientOptions.getDefaultInstance()); + } + + static NexusServiceClient newInstance( + Class service, String endpoint, WorkflowServiceStubs stubs, NexusClientOptions options) { + return NexusServiceClientImpl.newInstance(service, endpoint, stubs, options); + } + + /** + * Execute an operation synchronously. Equivalent to {@link #start(BiFunction, Object)} followed + * by {@link NexusClientHandle#getResult()}. + */ + default R execute(BiFunction operation, U input) { + return start(operation, input).getResult(); + } + + /** Execute an operation synchronously with per-call options. */ + default R execute( + BiFunction operation, U input, StartNexusOperationOptions options) { + return start(operation, input, options).getResult(); + } + + /** Start an operation and return a typed handle to track its execution. */ + default NexusClientHandle start(BiFunction operation, U input) { + return start(operation, input, StartNexusOperationOptions.getDefaultInstance()); + } + + /** Start an operation with per-call options and return a typed handle. */ + NexusClientHandle start( + BiFunction operation, U input, StartNexusOperationOptions options); + + /** + * Async variant of {@link #execute(BiFunction, Object)}. Returns a {@link CompletableFuture} that + * completes with the typed result, or completes exceptionally if the operation fails. + */ + default CompletableFuture executeAsync(BiFunction operation, U input) { + return start(operation, input).getResultAsync(); + } + + /** Async variant of {@link #execute(BiFunction, Object, StartNexusOperationOptions)}. */ + default CompletableFuture executeAsync( + BiFunction operation, U input, StartNexusOperationOptions options) { + return start(operation, input, options).getResultAsync(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java new file mode 100644 index 000000000..2b58cc061 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -0,0 +1,59 @@ +package io.temporal.client; + +import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread; + +import io.nexusrpc.ServiceDefinition; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.internal.WorkflowThreadMarker; +import io.temporal.internal.util.MethodExtractor; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.workflow.Functions; +import java.lang.reflect.Method; +import java.util.function.BiFunction; + +/** + * Typed Nexus service client. Extracts the operation name from a {@link BiFunction} that targets a + * method on the service interface (via a {@link Proxy} of {@code T}) and delegates the start RPC to + * the interceptor chain inherited from the underlying {@link NexusClient}. + */ +@Experimental +class NexusServiceClientImpl extends UntypedNexusServiceClientImpl + implements NexusServiceClient { + + private final Class serviceInterface; + + static NexusServiceClient newInstance( + Class service, String endpoint, WorkflowServiceStubs stubs, NexusClientOptions options) { + enforceNonWorkflowThread(); + // Build the underlying NexusClient impl directly (bypassing the wrapped factory) so we can + // hand its interceptor chain to the service client. The outer service-client proxy below + // still enforces the non-workflow-thread check at every call. + NexusClientImpl rawClient = new NexusClientImpl(stubs, options); + return WorkflowThreadMarker.protectFromWorkflowThread( + new NexusServiceClientImpl<>( + rawClient.getNexusClientCallsInvoker(), service, endpoint, options), + NexusServiceClient.class); + } + + NexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + Class serviceInterface, + String endpoint, + NexusClientOptions options) { + super(invoker, endpoint, ServiceDefinition.fromClass(serviceInterface).getName(), options); + this.serviceInterface = serviceInterface; + } + + @Override + public NexusClientHandle start( + BiFunction operation, U input, StartNexusOperationOptions options) { + Method method = + MethodExtractor.extract(serviceInterface, (Functions.Func2) operation::apply); + String operationName = MethodExtractor.nexusOperationName(method); + @SuppressWarnings("unchecked") + Class resultClass = (Class) method.getReturnType(); + UntypedNexusClientHandle untyped = start(operationName, options, input); + return NexusClientHandle.fromUntyped(untyped, resultClass, method.getGenericReturnType()); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java new file mode 100644 index 000000000..9638a63bf --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -0,0 +1,248 @@ +package io.temporal.client; + +import io.temporal.api.enums.v1.NexusOperationIdConflictPolicy; +import io.temporal.api.enums.v1.NexusOperationIdReusePolicy; +import io.temporal.common.Experimental; +import io.temporal.common.SearchAttributes; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Per-call options for starting a standalone Nexus operation via {@link + * UntypedNexusServiceClient#start} (or its typed counterpart). + */ +@Experimental +public final class StartNexusOperationOptions { + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(StartNexusOperationOptions options) { + return new Builder(options); + } + + private static final StartNexusOperationOptions DEFAULT_INSTANCE = newBuilder().build(); + + /** Returns an options instance with no per-call fields set. */ + public static StartNexusOperationOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + public static final class Builder { + private @Nullable String id; + private @Nullable Duration scheduleToCloseTimeout; + private @Nullable Duration scheduleToStartTimeout; + private @Nullable Duration startToCloseTimeout; + private @Nullable SearchAttributes typedSearchAttributes; + private Map nexusHeader = Collections.emptyMap(); + private @Nullable String summary; + private @Nullable NexusOperationIdReusePolicy idReusePolicy; + private @Nullable NexusOperationIdConflictPolicy idConflictPolicy; + + private Builder() {} + + private Builder(StartNexusOperationOptions options) { + if (options == null) { + return; + } + this.id = options.id; + this.scheduleToCloseTimeout = options.scheduleToCloseTimeout; + this.scheduleToStartTimeout = options.scheduleToStartTimeout; + this.startToCloseTimeout = options.startToCloseTimeout; + this.typedSearchAttributes = options.typedSearchAttributes; + this.nexusHeader = options.nexusHeader; + this.summary = options.summary; + this.idReusePolicy = options.idReusePolicy; + this.idConflictPolicy = options.idConflictPolicy; + } + + /** + * Optional. Unique identifier for this operation within its namespace. If unset, the SDK + * generates a random UUID. + */ + public Builder setId(@Nullable String id) { + this.id = id; + return this; + } + + /** Total time the caller is willing to wait for the operation to complete. */ + public Builder setScheduleToCloseTimeout(@Nullable Duration scheduleToCloseTimeout) { + this.scheduleToCloseTimeout = scheduleToCloseTimeout; + return this; + } + + /** Time the operation may wait in the queue before a handler picks it up. */ + public Builder setScheduleToStartTimeout(@Nullable Duration scheduleToStartTimeout) { + this.scheduleToStartTimeout = scheduleToStartTimeout; + return this; + } + + /** Maximum time for a single attempt. */ + public Builder setStartToCloseTimeout(@Nullable Duration startToCloseTimeout) { + this.startToCloseTimeout = startToCloseTimeout; + return this; + } + + /** Typed search attributes to attach to this operation execution. */ + public Builder setTypedSearchAttributes(@Nullable SearchAttributes typedSearchAttributes) { + this.typedSearchAttributes = typedSearchAttributes; + return this; + } + + /** Nexus protocol headers forwarded to the handler. */ + public Builder setNexusHeader(@Nullable Map nexusHeader) { + this.nexusHeader = nexusHeader == null ? Collections.emptyMap() : nexusHeader; + return this; + } + + /** Short summary for UI display. */ + public Builder setSummary(@Nullable String summary) { + this.summary = summary; + return this; + } + + /** Controls behavior when an operation with the same ID was previously run and is closed. */ + public Builder setIdReusePolicy(@Nullable NexusOperationIdReusePolicy idReusePolicy) { + this.idReusePolicy = idReusePolicy; + return this; + } + + /** Controls behavior when an operation with the same ID is currently running. */ + public Builder setIdConflictPolicy(@Nullable NexusOperationIdConflictPolicy idConflictPolicy) { + this.idConflictPolicy = idConflictPolicy; + return this; + } + + public StartNexusOperationOptions build() { + return new StartNexusOperationOptions(this); + } + } + + private final @Nullable String id; + private final @Nullable Duration scheduleToCloseTimeout; + private final @Nullable Duration scheduleToStartTimeout; + private final @Nullable Duration startToCloseTimeout; + private final @Nullable SearchAttributes typedSearchAttributes; + private final Map nexusHeader; + private final @Nullable String summary; + private final @Nullable NexusOperationIdReusePolicy idReusePolicy; + private final @Nullable NexusOperationIdConflictPolicy idConflictPolicy; + + private StartNexusOperationOptions(Builder builder) { + this.id = builder.id; + this.scheduleToCloseTimeout = builder.scheduleToCloseTimeout; + this.scheduleToStartTimeout = builder.scheduleToStartTimeout; + this.startToCloseTimeout = builder.startToCloseTimeout; + this.typedSearchAttributes = builder.typedSearchAttributes; + this.nexusHeader = Collections.unmodifiableMap(builder.nexusHeader); + this.summary = builder.summary; + this.idReusePolicy = builder.idReusePolicy; + this.idConflictPolicy = builder.idConflictPolicy; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @Nullable + public String getId() { + return id; + } + + @Nullable + public Duration getScheduleToCloseTimeout() { + return scheduleToCloseTimeout; + } + + @Nullable + public Duration getScheduleToStartTimeout() { + return scheduleToStartTimeout; + } + + @Nullable + public Duration getStartToCloseTimeout() { + return startToCloseTimeout; + } + + @Nullable + public SearchAttributes getTypedSearchAttributes() { + return typedSearchAttributes; + } + + public Map getNexusHeader() { + return nexusHeader; + } + + @Nullable + public String getSummary() { + return summary; + } + + @Nullable + public NexusOperationIdReusePolicy getIdReusePolicy() { + return idReusePolicy; + } + + @Nullable + public NexusOperationIdConflictPolicy getIdConflictPolicy() { + return idConflictPolicy; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + StartNexusOperationOptions that = (StartNexusOperationOptions) o; + return Objects.equals(id, that.id) + && Objects.equals(scheduleToCloseTimeout, that.scheduleToCloseTimeout) + && Objects.equals(scheduleToStartTimeout, that.scheduleToStartTimeout) + && Objects.equals(startToCloseTimeout, that.startToCloseTimeout) + && Objects.equals(typedSearchAttributes, that.typedSearchAttributes) + && Objects.equals(nexusHeader, that.nexusHeader) + && Objects.equals(summary, that.summary) + && idReusePolicy == that.idReusePolicy + && idConflictPolicy == that.idConflictPolicy; + } + + @Override + public int hashCode() { + return Objects.hash( + id, + scheduleToCloseTimeout, + scheduleToStartTimeout, + startToCloseTimeout, + typedSearchAttributes, + nexusHeader, + summary, + idReusePolicy, + idConflictPolicy); + } + + @Override + public String toString() { + return "StartNexusOperationOptions{" + + "id='" + + id + + "', scheduleToCloseTimeout=" + + scheduleToCloseTimeout + + ", scheduleToStartTimeout=" + + scheduleToStartTimeout + + ", startToCloseTimeout=" + + startToCloseTimeout + + ", typedSearchAttributes=" + + typedSearchAttributes + + ", nexusHeader=" + + nexusHeader + + ", summary='" + + summary + + "', idReusePolicy=" + + idReusePolicy + + ", idConflictPolicy=" + + idConflictPolicy + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java new file mode 100644 index 000000000..6dcb65873 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java @@ -0,0 +1,56 @@ +package io.temporal.client; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +public interface UntypedNexusClientHandle { + /** Operation ID this handle was constructed for. Always non-null. */ + String getNexusOperationId(); + + /** + * Present if the handle was returned by `start` or set when calling `getHandle`. Null if + * `getHandle` was called with a null run ID — in that case, use {@link #describe()} to learn the + * current run ID. + */ + @Nullable + String getNexusOperationRunId(); + + R getResult(Class resultClass); + + R getResult(Class resultClass, @Nullable Type resultType); + + /** + * Block up to {@code timeout} for the operation to complete and return the typed result. Throws + * {@link TimeoutException} if the operation has not completed within the deadline. + */ + R getResult(long timeout, TimeUnit unit, Class resultClass) throws TimeoutException; + + R getResult(long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException; + + CompletableFuture getResultAsync(Class resultClass); + + CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType); + + /** + * Returns a future that completes with the typed result, or completes exceptionally with a {@link + * TimeoutException} if {@code timeout} elapses before the operation finishes. + */ + CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class resultClass); + + CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType); + + NexusClientOperationExecutionDescription describe(); + + void cancel(); + + void cancel(@Nullable String reason); + + void terminate(); + + void terminate(@Nullable String reason); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java new file mode 100644 index 000000000..887be7eee --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java @@ -0,0 +1,29 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.lang.reflect.Type; +import javax.annotation.Nullable; + +/** Untyped client for invoking standalone Nexus operations by operation-name string. */ +@Experimental +public interface UntypedNexusServiceClient { + + /** Start an operation by name, returning an untyped handle. */ + UntypedNexusClientHandle start( + String operation, StartNexusOperationOptions options, @Nullable Object arg); + + /** Execute an operation synchronously by name. */ + R execute( + String operation, + Class resultClass, + StartNexusOperationOptions options, + @Nullable Object arg); + + /** Execute an operation synchronously by name with explicit generic-result {@link Type}. */ + R execute( + String operation, + Class resultClass, + Type resultType, + StartNexusOperationOptions options, + @Nullable Object arg); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java new file mode 100644 index 000000000..e3cf1cc92 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -0,0 +1,88 @@ +package io.temporal.client; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput; +import java.lang.reflect.Type; +import javax.annotation.Nullable; + +/** + * Untyped Nexus service client. Holds the {@link NexusClientCallsInterceptor invoker}, target + * endpoint, service name, and data converter, and translates operation-name calls into start RPCs + * routed through the interceptor chain. + */ +@Experimental +class UntypedNexusServiceClientImpl implements UntypedNexusServiceClient { + + private final NexusClientCallsInterceptor invoker; + private final String endpoint; + private final String serviceName; + private final DataConverter dataConverter; + + UntypedNexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + String endpoint, + String serviceName, + NexusClientOptions clientOptions) { + if (invoker == null || endpoint == null || serviceName == null || clientOptions == null) { + throw new IllegalArgumentException( + "invoker, endpoint, serviceName, and clientOptions are all required"); + } + this.invoker = invoker; + this.endpoint = endpoint; + this.serviceName = serviceName; + this.dataConverter = clientOptions.getDataConverter(); + } + + @Override + public UntypedNexusClientHandle start( + String operation, StartNexusOperationOptions options, @Nullable Object arg) { + Payload payload = serializeInput(arg); + StartNexusOperationExecutionInput input = + new StartNexusOperationExecutionInput( + endpoint, + serviceName, + operation, + payload, + options != null ? options : StartNexusOperationOptions.getDefaultInstance()); + StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); + return new NexusClientHandleImpl<>( + invoker, output.getOperationId(), output.getRunId(), dataConverter); + } + + @Override + public R execute( + String operation, + Class resultClass, + StartNexusOperationOptions options, + @Nullable Object arg) { + return execute(operation, resultClass, /* resultType= */ null, options, arg); + } + + @Override + public R execute( + String operation, + Class resultClass, + @Nullable Type resultType, + StartNexusOperationOptions options, + @Nullable Object arg) { + UntypedNexusClientHandle handle = start(operation, options, arg); + return NexusClientHandle.fromUntyped(handle, resultClass, resultType).getResult(); + } + + private @Nullable Payload serializeInput(@Nullable Object arg) { + if (arg == null) { + return null; + } + Class argClass = arg.getClass(); + return dataConverter + .toPayload(arg) + .orElseThrow( + () -> + new IllegalStateException( + "DataConverter returned no payload for input of type " + argClass.getName())); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java new file mode 100644 index 000000000..13bdee4c9 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -0,0 +1,413 @@ +package io.temporal.common.interceptors; + +import com.google.protobuf.ByteString; +import io.grpc.Deadline; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.NexusOperationWaitStage; +import io.temporal.api.failure.v1.Failure; +import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientHandle; +import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.common.Experimental; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Per-call interceptor for {@link NexusClient} and {@link NexusClientHandle} operations on + * standalone Nexus operation executions. + * + *

Implementations are produced by {@link + * NexusClientInterceptor#nexusClientCallsInterceptor(NexusClientCallsInterceptor)} during {@link + * NexusClient} construction. Prefer extending {@link NexusClientCallsInterceptorBase} and + * overriding only the methods you need. + */ +@Experimental +public interface NexusClientCallsInterceptor { + + StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input); + + DescribeNexusOperationExecutionOutput describeNexusOperationExecution( + DescribeNexusOperationExecutionInput input); + + CompletableFuture describeNexusOperationExecutionAsync( + DescribeNexusOperationExecutionInput input); + + PollNexusOperationExecutionOutput pollNexusOperationExecution( + PollNexusOperationExecutionInput input); + + CompletableFuture pollNexusOperationExecutionAsync( + PollNexusOperationExecutionInput input); + + ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input); + + CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input); + + void requestCancelNexusOperationExecution(RequestCancelNexusOperationExecutionInput input); + + void terminateNexusOperationExecution(TerminateNexusOperationExecutionInput input); + + void deleteNexusOperationExecution(DeleteNexusOperationExecutionInput input); + + final class StartNexusOperationExecutionInput { + private final String endpoint; + private final String service; + private final String operation; + private final @Nullable Payload input; + private final StartNexusOperationOptions options; + + public StartNexusOperationExecutionInput( + String endpoint, + String service, + String operation, + @Nullable Payload input, + StartNexusOperationOptions options) { + this.endpoint = endpoint; + this.service = service; + this.operation = operation; + this.input = input; + this.options = options; + } + + public String getEndpoint() { + return endpoint; + } + + public String getService() { + return service; + } + + public String getOperation() { + return operation; + } + + public Optional getInput() { + return Optional.ofNullable(input); + } + + public StartNexusOperationOptions getOptions() { + return options; + } + } + + final class StartNexusOperationExecutionOutput { + private final String operationId; + private final String runId; + private final boolean started; + + public StartNexusOperationExecutionOutput(String operationId, String runId, boolean started) { + this.operationId = operationId; + this.runId = runId; + this.started = started; + } + + public String getOperationId() { + return operationId; + } + + public String getRunId() { + return runId; + } + + public boolean isStarted() { + return started; + } + } + + final class DescribeNexusOperationExecutionInput { + private final String operationId; + private final @Nullable String runId; + private final boolean includeInput; + private final boolean includeOutcome; + private final @Nonnull Deadline deadline; + + public DescribeNexusOperationExecutionInput( + String operationId, + @Nullable String runId, + boolean includeInput, + boolean includeOutcome, + @Nonnull Deadline deadline) { + this.operationId = operationId; + this.runId = runId; + this.includeInput = includeInput; + this.includeOutcome = includeOutcome; + this.deadline = deadline; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + + public boolean isIncludeInput() { + return includeInput; + } + + public boolean isIncludeOutcome() { + return includeOutcome; + } + + public Deadline getDeadline() { + return deadline; + } + } + + final class DescribeNexusOperationExecutionOutput { + private final NexusClientOperationExecutionDescription description; + + public DescribeNexusOperationExecutionOutput( + NexusClientOperationExecutionDescription description) { + this.description = description; + } + + public NexusClientOperationExecutionDescription getDescription() { + return description; + } + } + + final class PollNexusOperationExecutionInput { + private final String operationId; + private final @Nullable String runId; + private final NexusOperationWaitStage waitStage; + private final @Nonnull Deadline deadline; + + public PollNexusOperationExecutionInput( + String operationId, + @Nullable String runId, + NexusOperationWaitStage waitStage, + @Nonnull Deadline deadline) { + this.operationId = operationId; + this.runId = runId; + this.waitStage = waitStage; + this.deadline = deadline; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + + public NexusOperationWaitStage getWaitStage() { + return waitStage; + } + + public Deadline getDeadline() { + return deadline; + } + } + + final class PollNexusOperationExecutionOutput { + private final String runId; + private final NexusOperationWaitStage waitStage; + private final String operationToken; + private final @Nullable Payload result; + private final @Nullable Failure failure; + + public PollNexusOperationExecutionOutput( + String runId, + NexusOperationWaitStage waitStage, + String operationToken, + @Nullable Payload result, + @Nullable Failure failure) { + this.runId = runId; + this.waitStage = waitStage; + this.operationToken = operationToken; + this.result = result; + this.failure = failure; + } + + public String getRunId() { + return runId; + } + + public NexusOperationWaitStage getWaitStage() { + return waitStage; + } + + public String getOperationToken() { + return operationToken; + } + + public Optional getResult() { + return Optional.ofNullable(result); + } + + public Optional getFailure() { + return Optional.ofNullable(failure); + } + } + + final class ListNexusOperationExecutionsInput { + private final @Nullable String query; + private final int pageSize; + private final @Nullable ByteString nextPageToken; + + public ListNexusOperationExecutionsInput( + @Nullable String query, int pageSize, @Nullable ByteString nextPageToken) { + this.query = query; + this.pageSize = pageSize; + this.nextPageToken = nextPageToken; + } + + public Optional getQuery() { + return Optional.ofNullable(query); + } + + public int getPageSize() { + return pageSize; + } + + public Optional getNextPageToken() { + return Optional.ofNullable(nextPageToken); + } + } + + final class ListNexusOperationExecutionsOutput { + private final List operations; + private final ByteString nextPageToken; + + public ListNexusOperationExecutionsOutput( + List operations, ByteString nextPageToken) { + this.operations = Collections.unmodifiableList(operations); + this.nextPageToken = nextPageToken; + } + + public List getOperations() { + return operations; + } + + public ByteString getNextPageToken() { + return nextPageToken; + } + } + + final class CountNexusOperationExecutionsInput { + private final @Nullable String query; + + public CountNexusOperationExecutionsInput(@Nullable String query) { + this.query = query; + } + + public Optional getQuery() { + return Optional.ofNullable(query); + } + } + + final class CountNexusOperationExecutionsOutput { + private final long count; + private final List groups; + + public CountNexusOperationExecutionsOutput(long count, List groups) { + this.count = count; + this.groups = Collections.unmodifiableList(groups); + } + + public long getCount() { + return count; + } + + public List getGroups() { + return groups; + } + + public static final class AggregationGroup { + private final List groupValues; + private final long count; + + public AggregationGroup(List groupValues, long count) { + this.groupValues = Collections.unmodifiableList(groupValues); + this.count = count; + } + + public List getGroupValues() { + return groupValues; + } + + public long getCount() { + return count; + } + } + } + + final class RequestCancelNexusOperationExecutionInput { + private final String operationId; + private final @Nullable String runId; + private final @Nullable String reason; + + public RequestCancelNexusOperationExecutionInput( + String operationId, @Nullable String runId, @Nullable String reason) { + this.operationId = operationId; + this.runId = runId; + this.reason = reason; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + + public Optional getReason() { + return Optional.ofNullable(reason); + } + } + + final class TerminateNexusOperationExecutionInput { + private final String operationId; + private final @Nullable String runId; + private final @Nullable String reason; + + public TerminateNexusOperationExecutionInput( + String operationId, @Nullable String runId, @Nullable String reason) { + this.operationId = operationId; + this.runId = runId; + this.reason = reason; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + + public Optional getReason() { + return Optional.ofNullable(reason); + } + } + + final class DeleteNexusOperationExecutionInput { + private final String operationId; + private final @Nullable String runId; + + public DeleteNexusOperationExecutionInput(String operationId, @Nullable String runId) { + this.operationId = operationId; + this.runId = runId; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java new file mode 100644 index 000000000..a2b13794f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java @@ -0,0 +1,76 @@ +package io.temporal.common.interceptors; + +import io.temporal.common.Experimental; +import java.util.concurrent.CompletableFuture; + +/** + * Convenience base class for {@link NexusClientCallsInterceptor} implementations that need to + * override only a subset of methods. All methods delegate to the wrapped {@code next} interceptor. + */ +@Experimental +public class NexusClientCallsInterceptorBase implements NexusClientCallsInterceptor { + + private final NexusClientCallsInterceptor next; + + public NexusClientCallsInterceptorBase(NexusClientCallsInterceptor next) { + this.next = next; + } + + @Override + public StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input) { + return next.startNexusOperationExecution(input); + } + + @Override + public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( + DescribeNexusOperationExecutionInput input) { + return next.describeNexusOperationExecution(input); + } + + @Override + public CompletableFuture + describeNexusOperationExecutionAsync(DescribeNexusOperationExecutionInput input) { + return next.describeNexusOperationExecutionAsync(input); + } + + @Override + public PollNexusOperationExecutionOutput pollNexusOperationExecution( + PollNexusOperationExecutionInput input) { + return next.pollNexusOperationExecution(input); + } + + @Override + public CompletableFuture pollNexusOperationExecutionAsync( + PollNexusOperationExecutionInput input) { + return next.pollNexusOperationExecutionAsync(input); + } + + @Override + public ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input) { + return next.listNexusOperationExecutions(input); + } + + @Override + public CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input) { + return next.countNexusOperationExecutions(input); + } + + @Override + public void requestCancelNexusOperationExecution( + RequestCancelNexusOperationExecutionInput input) { + next.requestCancelNexusOperationExecution(input); + } + + @Override + public void terminateNexusOperationExecution(TerminateNexusOperationExecutionInput input) { + next.terminateNexusOperationExecution(input); + } + + @Override + public void deleteNexusOperationExecution(DeleteNexusOperationExecutionInput input) { + next.deleteNexusOperationExecution(input); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java new file mode 100644 index 000000000..3af217f3f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java @@ -0,0 +1,24 @@ +package io.temporal.common.interceptors; + +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.common.Experimental; + +/** + * Outer interceptor for {@link NexusClient}. Implementations are registered via {@link + * NexusClientOptions.Builder#setInterceptors(java.util.List)} and consulted once during client + * construction to build the chain of {@link NexusClientCallsInterceptor}s that wraps the root + * invoker. + */ +@Experimental +public interface NexusClientInterceptor { + + /** + * Called once during {@link NexusClient} construction to build the chain of per-call + * interceptors. + * + * @param next next per-call interceptor in the chain + * @return new per-call interceptor that decorates calls to {@code next} + */ + NexusClientCallsInterceptor nexusClientCallsInterceptor(NexusClientCallsInterceptor next); +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java new file mode 100644 index 000000000..b964626fd --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java @@ -0,0 +1,13 @@ +package io.temporal.common.interceptors; + +import io.temporal.common.Experimental; + +/** Convenience base class for {@link NexusClientInterceptor} implementations. */ +@Experimental +public class NexusClientInterceptorBase implements NexusClientInterceptor { + + @Override + public NexusClientCallsInterceptor nexusClientCallsInterceptor(NexusClientCallsInterceptor next) { + return next; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java new file mode 100644 index 000000000..addb378ad --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -0,0 +1,243 @@ +package io.temporal.internal.client; + +import io.temporal.api.sdk.v1.UserMetadata; +import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsRequest; +import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsResponse; +import io.temporal.api.workflowservice.v1.DeleteNexusOperationExecutionRequest; +import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionRequest; +import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse; +import io.temporal.api.workflowservice.v1.ListNexusOperationExecutionsRequest; +import io.temporal.api.workflowservice.v1.ListNexusOperationExecutionsResponse; +import io.temporal.api.workflowservice.v1.PollNexusOperationExecutionRequest; +import io.temporal.api.workflowservice.v1.PollNexusOperationExecutionResponse; +import io.temporal.api.workflowservice.v1.RequestCancelNexusOperationExecutionRequest; +import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest; +import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionResponse; +import io.temporal.api.workflowservice.v1.TerminateNexusOperationExecutionRequest; +import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.internal.client.external.GenericWorkflowClient; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.WorkflowExecutionUtils; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +/** + * Root implementation of {@link NexusClientCallsInterceptor} that converts the SDK's Java DTOs into + * proto requests and delegates the actual gRPC calls to {@link GenericWorkflowClient}. + */ +@Experimental +public class RootNexusClientInvoker implements NexusClientCallsInterceptor { + + private final GenericWorkflowClient genericClient; + private final NexusClientOptions clientOptions; + + public RootNexusClientInvoker( + GenericWorkflowClient genericClient, NexusClientOptions clientOptions) { + this.genericClient = genericClient; + this.clientOptions = clientOptions; + } + + @Override + public StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input) { + StartNexusOperationOptions options = input.getOptions(); + String operationId = options.getId() != null ? options.getId() : UUID.randomUUID().toString(); + StartNexusOperationExecutionRequest.Builder request = + StartNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setOperationId(operationId) + .setEndpoint(input.getEndpoint()) + .setService(input.getService()) + .setOperation(input.getOperation()) + .putAllNexusHeader(options.getNexusHeader()); + + if (options.getScheduleToCloseTimeout() != null) { + request.setScheduleToCloseTimeout( + ProtobufTimeUtils.toProtoDuration(options.getScheduleToCloseTimeout())); + } + if (options.getScheduleToStartTimeout() != null) { + request.setScheduleToStartTimeout( + ProtobufTimeUtils.toProtoDuration(options.getScheduleToStartTimeout())); + } + if (options.getStartToCloseTimeout() != null) { + request.setStartToCloseTimeout( + ProtobufTimeUtils.toProtoDuration(options.getStartToCloseTimeout())); + } + input.getInput().ifPresent(request::setInput); + if (options.getTypedSearchAttributes() != null) { + request.setSearchAttributes( + io.temporal.internal.common.SearchAttributesUtil.encodeTyped( + options.getTypedSearchAttributes())); + } + if (options.getIdReusePolicy() != null) { + request.setIdReusePolicy(options.getIdReusePolicy()); + } + if (options.getIdConflictPolicy() != null) { + request.setIdConflictPolicy(options.getIdConflictPolicy()); + } + if (options.getSummary() != null) { + UserMetadata metadata = + WorkflowExecutionUtils.makeUserMetaData( + options.getSummary(), /* details= */ null, clientOptions.getDataConverter()); + if (metadata != null) { + request.setUserMetadata(metadata); + } + } + + StartNexusOperationExecutionResponse response = + genericClient.startNexusOperationExecution(request.build()); + return new StartNexusOperationExecutionOutput( + operationId, response.getRunId(), response.getStarted()); + } + + @Override + public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( + DescribeNexusOperationExecutionInput input) { + DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input); + DescribeNexusOperationExecutionResponse response = + genericClient.describeNexusOperationExecution(request, input.getDeadline()); + return new DescribeNexusOperationExecutionOutput( + new NexusClientOperationExecutionDescription(response)); + } + + @Override + public CompletableFuture + describeNexusOperationExecutionAsync(DescribeNexusOperationExecutionInput input) { + DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input); + return genericClient + .describeNexusOperationExecutionAsync(request, input.getDeadline()) + .thenApply( + response -> + new DescribeNexusOperationExecutionOutput( + new NexusClientOperationExecutionDescription(response))); + } + + private DescribeNexusOperationExecutionRequest buildDescribeRequest( + DescribeNexusOperationExecutionInput input) { + DescribeNexusOperationExecutionRequest.Builder request = + DescribeNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setOperationId(input.getOperationId()) + .setIncludeInput(input.isIncludeInput()) + .setIncludeOutcome(input.isIncludeOutcome()); + input.getRunId().ifPresent(request::setRunId); + return request.build(); + } + + @Override + public PollNexusOperationExecutionOutput pollNexusOperationExecution( + PollNexusOperationExecutionInput input) { + PollNexusOperationExecutionResponse response = + genericClient.pollNexusOperationExecution(buildPollRequest(input), input.getDeadline()); + return toPollOutput(response); + } + + @Override + public CompletableFuture pollNexusOperationExecutionAsync( + PollNexusOperationExecutionInput input) { + return genericClient + .pollNexusOperationExecutionAsync(buildPollRequest(input), input.getDeadline()) + .thenApply(this::toPollOutput); + } + + private PollNexusOperationExecutionRequest buildPollRequest( + PollNexusOperationExecutionInput input) { + PollNexusOperationExecutionRequest.Builder request = + PollNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setOperationId(input.getOperationId()) + .setWaitStage(input.getWaitStage()); + input.getRunId().ifPresent(request::setRunId); + return request.build(); + } + + private PollNexusOperationExecutionOutput toPollOutput( + PollNexusOperationExecutionResponse response) { + return new PollNexusOperationExecutionOutput( + response.getRunId(), + response.getWaitStage(), + response.getOperationToken(), + response.hasResult() ? response.getResult() : null, + response.hasFailure() ? response.getFailure() : null); + } + + @Override + public ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input) { + ListNexusOperationExecutionsRequest.Builder request = + ListNexusOperationExecutionsRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setPageSize(input.getPageSize()); + input.getQuery().ifPresent(request::setQuery); + input.getNextPageToken().ifPresent(request::setNextPageToken); + + ListNexusOperationExecutionsResponse response = + genericClient.listNexusOperationExecutions(request.build()); + return new ListNexusOperationExecutionsOutput( + response.getOperationsList(), response.getNextPageToken()); + } + + @Override + public CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input) { + CountNexusOperationExecutionsRequest.Builder request = + CountNexusOperationExecutionsRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()); + input.getQuery().ifPresent(request::setQuery); + + CountNexusOperationExecutionsResponse response = + genericClient.countNexusOperationExecutions(request.build()); + + java.util.List groups = + new java.util.ArrayList<>(response.getGroupsCount()); + for (CountNexusOperationExecutionsResponse.AggregationGroup g : response.getGroupsList()) { + groups.add( + new CountNexusOperationExecutionsOutput.AggregationGroup( + g.getGroupValuesList(), g.getCount())); + } + return new CountNexusOperationExecutionsOutput(response.getCount(), groups); + } + + @Override + public void requestCancelNexusOperationExecution( + RequestCancelNexusOperationExecutionInput input) { + RequestCancelNexusOperationExecutionRequest.Builder request = + RequestCancelNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setOperationId(input.getOperationId()); + input.getRunId().ifPresent(request::setRunId); + input.getReason().ifPresent(request::setReason); + genericClient.requestCancelNexusOperationExecution(request.build()); + } + + @Override + public void terminateNexusOperationExecution(TerminateNexusOperationExecutionInput input) { + TerminateNexusOperationExecutionRequest.Builder request = + TerminateNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setOperationId(input.getOperationId()); + input.getRunId().ifPresent(request::setRunId); + input.getReason().ifPresent(request::setReason); + genericClient.terminateNexusOperationExecution(request.build()); + } + + @Override + public void deleteNexusOperationExecution(DeleteNexusOperationExecutionInput input) { + DeleteNexusOperationExecutionRequest.Builder request = + DeleteNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setOperationId(input.getOperationId()); + input.getRunId().ifPresent(request::setRunId); + genericClient.deleteNexusOperationExecution(request.build()); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java index 317c2300b..dabf5a8e7 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java @@ -61,6 +61,36 @@ CompletableFuture listWorkflowExecutionsAsync( DescribeWorkflowExecutionResponse describeWorkflowExecution( DescribeWorkflowExecutionRequest request); + StartNexusOperationExecutionResponse startNexusOperationExecution( + @Nonnull StartNexusOperationExecutionRequest request); + + DescribeNexusOperationExecutionResponse describeNexusOperationExecution( + @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + + CompletableFuture describeNexusOperationExecutionAsync( + @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + + PollNexusOperationExecutionResponse pollNexusOperationExecution( + @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + + CompletableFuture pollNexusOperationExecutionAsync( + @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + + ListNexusOperationExecutionsResponse listNexusOperationExecutions( + @Nonnull ListNexusOperationExecutionsRequest request); + + CountNexusOperationExecutionsResponse countNexusOperationExecutions( + @Nonnull CountNexusOperationExecutionsRequest request); + + RequestCancelNexusOperationExecutionResponse requestCancelNexusOperationExecution( + @Nonnull RequestCancelNexusOperationExecutionRequest request); + + TerminateNexusOperationExecutionResponse terminateNexusOperationExecution( + @Nonnull TerminateNexusOperationExecutionRequest request); + + DeleteNexusOperationExecutionResponse deleteNexusOperationExecution( + @Nonnull DeleteNexusOperationExecutionRequest request); + @Experimental @Deprecated UpdateWorkerBuildIdCompatibilityResponse updateWorkerBuildIdCompatability( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java index 58ad1e8f1..f115cc2b5 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java @@ -309,6 +309,141 @@ public DescribeWorkflowExecutionResponse describeWorkflowExecution( grpcRetryerOptions); } + // TODO -- EVAN -- START + @Override + public StartNexusOperationExecutionResponse startNexusOperationExecution( + @Nonnull StartNexusOperationExecutionRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .startNexusOperationExecution(request), + grpcRetryerOptions); + } + + @Override + public DescribeNexusOperationExecutionResponse describeNexusOperationExecution( + @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) + .withDeadline(deadline) + .describeNexusOperationExecution(request), + new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline)); + } + + @Override + public CompletableFuture + describeNexusOperationExecutionAsync( + @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline) { + return grpcRetryer.retryWithResultAsync( + asyncThrottlerExecutor, + () -> + toCompletableFuture( + service + .futureStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) + .withDeadline(deadline) + .describeNexusOperationExecution(request)), + new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline)); + } + + @Override + public PollNexusOperationExecutionResponse pollNexusOperationExecution( + @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) + .withDeadline(deadline) + .pollNexusOperationExecution(request), + new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline)); + } + + @Override + public CompletableFuture pollNexusOperationExecutionAsync( + @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline) { + return grpcRetryer.retryWithResultAsync( + asyncThrottlerExecutor, + () -> + toCompletableFuture( + service + .futureStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) + .withDeadline(deadline) + .pollNexusOperationExecution(request)), + new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline)); + } + + @Override + public ListNexusOperationExecutionsResponse listNexusOperationExecutions( + @Nonnull ListNexusOperationExecutionsRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .listNexusOperationExecutions(request), + grpcRetryerOptions); + } + + @Override + public CountNexusOperationExecutionsResponse countNexusOperationExecutions( + @Nonnull CountNexusOperationExecutionsRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .countNexusOperationExecutions(request), + grpcRetryerOptions); + } + + @Override + public RequestCancelNexusOperationExecutionResponse requestCancelNexusOperationExecution( + @Nonnull RequestCancelNexusOperationExecutionRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .requestCancelNexusOperationExecution(request), + grpcRetryerOptions); + } + + @Override + public TerminateNexusOperationExecutionResponse terminateNexusOperationExecution( + @Nonnull TerminateNexusOperationExecutionRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .terminateNexusOperationExecution(request), + grpcRetryerOptions); + } + + @Override + public DeleteNexusOperationExecutionResponse deleteNexusOperationExecution( + @Nonnull DeleteNexusOperationExecutionRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .deleteNexusOperationExecution(request), + grpcRetryerOptions); + } + + // TODO -- EVAN -- END private static CompletableFuture toCompletableFuture( ListenableFuture listenableFuture) { CompletableFuture result = new CompletableFuture<>(); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java b/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java index 87490879b..1e8e4a19d 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java @@ -1,6 +1,7 @@ package io.temporal.internal.util; import com.google.common.base.Defaults; +import io.nexusrpc.Operation; import io.temporal.common.metadata.POJOActivityInterfaceMetadata; import io.temporal.workflow.Functions; import java.lang.reflect.Method; @@ -37,6 +38,15 @@ public static String activityTypeName(Class interfac, Method method) { .getActivityTypeName(); } + /** + * Resolves the Nexus operation name for {@code method}: returns the explicit name from the {@link + * Operation} annotation when set, otherwise the Java method name. + */ + public static String nexusOperationName(Method method) { + Operation op = method.getAnnotation(Operation.class); + return op != null && !op.name().isEmpty() ? op.name() : method.getName(); + } + // --- Proc overloads --- public static Method extract(Class interfac, Functions.Proc1 m) { diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java new file mode 100644 index 000000000..08e4061db --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java @@ -0,0 +1,357 @@ +package io.temporal.client.nexus; + +import com.google.protobuf.ByteString; +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.api.nexus.v1.EndpointSpec; +import io.temporal.api.nexus.v1.EndpointTarget; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; +import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientImpl; +import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusClientHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests for {@link UntypedNexusClientHandle} per-execution lifecycle methods returned by {@link + * NexusClient#getHandle(String)}: {@code describe()}, {@code cancel()}/{@code cancel(reason)}, and + * {@code terminate()}/{@code terminate(reason)}. + */ +public class NexusClientHandleTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + // Default is 10s; standalone Nexus dispatch + worker poll can take longer. + .setTestTimeoutSeconds(120) + .build(); + + private NexusClient createNexusClient() { + return NexusClientImpl.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + } + + @Test + public void describeReturnsDescriptionForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + NexusClientOperationExecutionDescription description = handle.describe(); + + Assert.assertNotNull(description); + Assert.assertNotNull(description.getRunId()); + Assert.assertEquals(started.runId, description.getRunId()); + Assert.assertNotNull(description.getRawResponse()); + } finally { + cleanup(started); + } + } + + @Test + public void describeWithoutRunIdTargetsLatest() { + StartedOperation started = startOperation(); + try { + // Handle with no pinned run ID — server should resolve to the latest run. + UntypedNexusClientHandle handle = started.client.getHandle(started.operationId); + + NexusClientOperationExecutionDescription description = handle.describe(); + + Assert.assertNotNull(description); + Assert.assertEquals(started.runId, description.getRunId()); + } finally { + cleanup(started); + } + } + + @Test + public void cancelSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.cancel(); + // No exception — server accepted the cancel request. + } finally { + cleanup(started); + } + } + + @Test + public void cancelWithReasonSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.cancel("test-cancel-reason"); + } finally { + cleanup(started); + } + } + + @Test + public void cancelWithNullReasonSucceeds() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.cancel(null); + } finally { + cleanup(started); + } + } + + @Test + public void terminateSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.terminate(); + } finally { + cleanup(started); + } + } + + @Test + public void terminateWithReasonSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.terminate("test-terminate-reason"); + } finally { + cleanup(started); + } + } + + @Test + public void terminateWithNullReasonSucceeds() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.terminate(null); + } finally { + cleanup(started); + } + } + + @Test + public void getResultReturnsTypedResultForSyncOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle untyped = + started.client.getHandle(started.operationId, started.runId); + + String result = + io.temporal.client.NexusClientHandle.fromUntyped(untyped, String.class).getResult(); + + Assert.assertNotNull(result); + Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); + } finally { + cleanup(started); + } + } + + @Test + public void getResultUntypedReturnsResultForSyncOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + String result = handle.getResult(String.class); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); + } finally { + cleanup(started); + } + } + + @Test + public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception { + StartedOperation started = startOperation(); + try { + UntypedNexusClientHandle untyped = + started.client.getHandle(started.operationId, started.runId); + + String result = + io.temporal.client.NexusClientHandle.fromUntyped(untyped, String.class) + .getResultAsync() + .get(60, java.util.concurrent.TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); + } finally { + cleanup(started); + } + } + + /** Holder for state used to drive a single test against one started operation. */ + private static final class StartedOperation { + final NexusClient client; + final Endpoint endpoint; + final String operationId; + final String runId; + + StartedOperation(NexusClient client, Endpoint endpoint, String operationId, String runId) { + this.client = client; + this.endpoint = endpoint; + this.operationId = operationId; + this.runId = runId; + } + } + + private StartedOperation startOperation() { + return startOperation(null); + } + + private StartedOperation startOperation(@javax.annotation.Nullable String inputOverride) { + NexusClient client = createNexusClient(); + Endpoint endpoint = createEndpoint("test-endpoint-" + testWorkflowRule.getTaskQueue()); + String inputValue = + inputOverride != null ? inputOverride : "ping-handle-test-" + UUID.randomUUID(); + + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusClientHandle handle = svcClient.start("operation", opts, inputValue); + + Assert.assertNotNull("expected start to return a run ID", handle.getNexusOperationRunId()); + return new StartedOperation( + client, endpoint, handle.getNexusOperationId(), handle.getNexusOperationRunId()); + } + + private void cleanup(StartedOperation started) { + deleteEndpoint(started.endpoint); + } + + private Endpoint createEndpoint(String name) { + EndpointSpec spec = + EndpointSpec.newBuilder() + .setName(name) + .setDescription( + Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) + .setTarget( + EndpointTarget.newBuilder() + .setWorker( + EndpointTarget.Worker.newBuilder() + .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) + .setTaskQueue(testWorkflowRule.getTaskQueue()))) + .build(); + CreateNexusEndpointResponse resp = + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); + return resp.getEndpoint(); + } + + private void deleteEndpoint(Endpoint endpoint) { + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .deleteNexusEndpoint( + DeleteNexusEndpointRequest.newBuilder() + .setId(endpoint.getId()) + .setVersion(endpoint.getVersion()) + .build()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class TestNexusServiceImpl { + /** Inputs starting with this prefix make the handler throw, exercising the failure path. */ + static final String FAIL_PREFIX = "FAIL:"; + + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (context, details, input) -> { + if (input != null && input.startsWith(FAIL_PREFIX)) { + // OperationException.failed = definitive failure (no retries) so the caller's + // getResult surfaces the failure instead of timing out. + throw OperationException.failed("intentional failure: " + input); + } + return "echo:" + (input == null ? "" : input); + }); + } + } + + @Test + public void getResultPropagatesOperationFailure() { + StartedOperation started = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); + try { + UntypedNexusClientHandle handle = + started.client.getHandle(started.operationId, started.runId); + + try { + handle.getResult(String.class); + Assert.fail("expected getResult to throw because the operation handler failed"); + } catch (RuntimeException e) { + // The DataConverter wraps the proto Failure into a Java exception. Either the message + // carries the handler's reason, or one of the cause links does. + String combined = collectMessages(e); + Assert.assertTrue( + "expected exception chain to mention the handler failure, got: " + combined, + combined.contains("intentional failure")); + } + } finally { + cleanup(started); + } + } + + private static String collectMessages(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null; c = c.getCause()) { + sb.append(c.getClass().getSimpleName()).append(":").append(c.getMessage()).append(" | "); + if (c.getCause() == c) { + break; + } + } + return sb.toString(); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java new file mode 100644 index 000000000..11f95fae6 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -0,0 +1,108 @@ +package io.temporal.client.nexus; + +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientImpl; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptorBase; +import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestWorkflows; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies that user-registered {@link NexusClientInterceptor}s are wrapped around the root invoker + * in registration order (last registered = outermost), and that every per-call operation passes + * through every interceptor. + */ +public class NexusClientInterceptorChainTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setTestTimeoutSeconds(60) + .build(); + + @Test + public void registeredInterceptorsAreCalledInOrder() { + List calls = Collections.synchronizedList(new ArrayList<>()); + NexusClientInterceptor first = next -> new RecordingCallsInterceptor("first", next, calls); + NexusClientInterceptor second = next -> new RecordingCallsInterceptor("second", next, calls); + + NexusClient client = + NexusClientImpl.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setInterceptors(Arrays.asList(first, second)) + .build()); + + // Stream is lazy; consume it to force a single page fetch through the interceptor chain. + long ignoredListCount = client.listNexusOperationExecutions(null).count(); + NexusOperationExecutionCount ignoredCount = client.countNexusOperationExecutions(null); + Assert.assertNotNull(ignoredCount); + Assert.assertTrue(ignoredListCount >= 0); + + // [first, second] -> second wraps first wraps root. + // A call enters second, descends to first, then root, returns through first then second. + Assert.assertEquals( + Arrays.asList( + "second:list:before", + "first:list:before", + "first:list:after", + "second:list:after", + "second:count:before", + "first:count:before", + "first:count:after", + "second:count:after"), + calls); + } + + static class RecordingCallsInterceptor extends NexusClientCallsInterceptorBase { + private final String name; + private final List calls; + + RecordingCallsInterceptor(String name, NexusClientCallsInterceptor next, List calls) { + super(next); + this.name = name; + this.calls = calls; + } + + @Override + public ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input) { + calls.add(name + ":list:before"); + try { + return super.listNexusOperationExecutions(input); + } finally { + calls.add(name + ":list:after"); + } + } + + @Override + public CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input) { + calls.add(name + ":count:before"); + try { + return super.countNexusOperationExecutions(input); + } finally { + calls.add(name + ":count:after"); + } + } + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java new file mode 100644 index 000000000..47af89bee --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -0,0 +1,215 @@ +package io.temporal.client.nexus; + +import com.google.protobuf.ByteString; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.api.nexus.v1.EndpointSpec; +import io.temporal.api.nexus.v1.EndpointTarget; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; +import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientImpl; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.client.NexusOperationExecutionMetadata; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusClientHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class NexusClientTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(NexusClientTest.PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + private NexusClient createNexusClient() { + return NexusClientImpl.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + } + + @Test + public void listNexusOperationExecutions() { + NexusClient client = createNexusClient(); + + // Materialize the lazy stream to force at least one page fetch and ensure no exceptions. + long visited = client.listNexusOperationExecutions(null).count(); + + Assert.assertTrue("expected a non-negative count of listed operations", visited >= 0); + } + + @Test + public void countNexusOperationExecutions() { + // Just run a basic test to see if it works + countNexusOperations(); + } + + public long countNexusOperations() { + NexusClient client = createNexusClient(); + + NexusOperationExecutionCount output = client.countNexusOperationExecutions(null); + + Assert.assertNotNull(output); + Assert.assertTrue(output.getCount() >= 0); + Assert.assertNotNull(output.getGroups()); + + return output.getCount(); + } + + @Test + public void runStandaloneNexusOperation() throws Exception { + TestNexusServiceImpl.received = new java.util.concurrent.CompletableFuture<>(); + TestNexusServiceImpl.invocationCount.set(0); + + Endpoint endpoint = createEndpoint("test-endpoint-" + testWorkflowRule.getTaskQueue()); + String inputValue = "ping-" + UUID.randomUUID(); + NexusClient client = createNexusClient(); + + try { + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusClientHandle handle = svcClient.start("operation", opts, inputValue); + String operationId = handle.getNexusOperationId(); + + // Sync handler: wait for the input to land in the test side-channel; that's how we + // know the operation actually completed on the worker. + String observed; + try { + observed = TestNexusServiceImpl.received.get(60, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + Assert.fail( + "Nexus handler was never invoked within 60s. invocationCount=" + + TestNexusServiceImpl.invocationCount.get()); + throw new AssertionError("unreachable"); + } + Assert.assertEquals( + "expected the Nexus handler to receive the same input we sent", inputValue, observed); + + // Poll the list until our operationId appears. This also tests that the list operation + // works correctly. + NexusOperationExecutionMetadata listed = + waitForListedOperation(client, operationId, Duration.ofSeconds(15)); + Assert.assertNotNull( + "expected operationId " + operationId + " to appear in listNexusOperationExecutions", + listed); + Assert.assertEquals(operationId, listed.getOperationId()); + Assert.assertEquals(endpoint.getSpec().getName(), listed.getEndpoint()); + Assert.assertEquals( + TestNexusServices.TestNexusService1.class.getSimpleName(), listed.getService()); + Assert.assertEquals("operation", listed.getOperation()); + + // We know count should be at least 1 until we clean up + // Due to race conditions with other tests running, we don't know what it actually should be + // though - + // but this is a chance to assert that it at least returns a non-zero value when appropriate + Assert.assertTrue(countNexusOperations() >= 1); + } finally { + deleteEndpoint(endpoint); + } + } + + private NexusOperationExecutionMetadata waitForListedOperation( + NexusClient client, String operationId, Duration timeout) throws InterruptedException { + long deadlineNanos = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadlineNanos) { + NexusOperationExecutionMetadata match = + client + .listNexusOperationExecutions(null) + .filter(m -> operationId.equals(m.getOperationId())) + .findFirst() + .orElse(null); + if (match != null) { + return match; + } + Thread.sleep(500); + } + return null; + } + + private Endpoint createEndpoint(String name) { + EndpointSpec spec = + EndpointSpec.newBuilder() + .setName(name) + .setDescription( + Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) + .setTarget( + EndpointTarget.newBuilder() + .setWorker( + EndpointTarget.Worker.newBuilder() + .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) + .setTaskQueue(testWorkflowRule.getTaskQueue()))) + .build(); + CreateNexusEndpointResponse resp = + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); + return resp.getEndpoint(); + } + + private void deleteEndpoint(Endpoint endpoint) { + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .deleteNexusEndpoint( + DeleteNexusEndpointRequest.newBuilder() + .setId(endpoint.getId()) + .setVersion(endpoint.getVersion()) + .build()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class TestNexusServiceImpl { + // CompletableFuture (not BlockingQueue) so we can record a null input — the worker may + // legitimately deliver a null payload, and we want a clean assertion failure instead of a + // NullPointerException-driven retry storm. Reassigned per test in a @Before-style reset. + static volatile java.util.concurrent.CompletableFuture received = + new java.util.concurrent.CompletableFuture<>(); + static final java.util.concurrent.atomic.AtomicInteger invocationCount = + new java.util.concurrent.atomic.AtomicInteger(); + + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (context, details, input) -> { + invocationCount.incrementAndGet(); + // complete() ignores subsequent calls, so the first delivered input wins. + received.complete(input); + return "echo:" + (input == null ? "" : input); + }); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java new file mode 100644 index 000000000..218630eaa --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -0,0 +1,226 @@ +package io.temporal.client.nexus; + +import com.google.protobuf.ByteString; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.api.nexus.v1.EndpointSpec; +import io.temporal.api.nexus.v1.EndpointTarget; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; +import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; +import io.temporal.client.NexusClientHandle; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusServiceClient; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.common.SearchAttributeKey; +import io.temporal.common.SearchAttributes; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptorBase; +import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** + * End-to-end tests for {@link NexusServiceClient}: typed start/execute via {@link + * java.util.function.BiFunction} method references. + */ +public class NexusServiceClientTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setTestTimeoutSeconds(120) + .build(); + + @Test + public void executeReturnsTypedResult() { + Endpoint endpoint = createEndpoint("svc-execute-" + testWorkflowRule.getTaskQueue()); + try { + NexusServiceClient client = buildServiceClient(endpoint); + + String result = client.execute(TestNexusServices.TestNexusService1::operation, "hello"); + + Assert.assertEquals("echo:hello", result); + } finally { + deleteEndpoint(endpoint); + } + } + + @Test + public void startReturnsTypedHandleAndPollsResult() { + Endpoint endpoint = createEndpoint("svc-start-" + testWorkflowRule.getTaskQueue()); + try { + NexusServiceClient client = buildServiceClient(endpoint); + + NexusClientHandle handle = + client.start(TestNexusServices.TestNexusService1::operation, "world"); + + Assert.assertNotNull(handle.getNexusOperationId()); + Assert.assertEquals("echo:world", handle.getResult()); + } finally { + deleteEndpoint(endpoint); + } + } + + @Test + public void clientSummaryIsForwardedIntoStartInput() { + AtomicReference captured = new AtomicReference<>(); + RuntimeException sentinel = new RuntimeException("captured-by-test"); + + NexusClientInterceptor recordingFactory = + next -> + new NexusClientCallsInterceptorBase(next) { + @Override + public StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input) { + captured.set(input); + throw sentinel; + } + }; + + NexusServiceClient client = + NexusServiceClient.newInstance( + TestNexusServices.TestNexusService1.class, + "summary-test-endpoint", + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setInterceptors(Collections.singletonList(recordingFactory)) + .build()); + + StartNexusOperationOptions startOptions = + StartNexusOperationOptions.newBuilder().setSummary("per-call-summary").build(); + try { + client.start(TestNexusServices.TestNexusService1::operation, "ignored", startOptions); + Assert.fail("expected sentinel to be thrown by recording interceptor"); + } catch (RuntimeException e) { + Assert.assertSame(sentinel, e); + } + + StartNexusOperationExecutionInput input = captured.get(); + Assert.assertNotNull("interceptor should have captured a start input", input); + Assert.assertEquals( + "expected summary to be forwarded to the start input", + "per-call-summary", + input.getOptions().getSummary()); + } + + @Test + public void clientSearchAttributesAreEncodedIntoStartInput() { + SearchAttributeKey customKey = SearchAttributeKey.forKeyword("CustomNexusTestKey"); + SearchAttributes attrs = SearchAttributes.newBuilder().set(customKey, "expected-value").build(); + + AtomicReference captured = new AtomicReference<>(); + RuntimeException sentinel = new RuntimeException("captured-by-test"); + + NexusClientInterceptor recordingFactory = + next -> + new NexusClientCallsInterceptorBase(next) { + @Override + public StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input) { + captured.set(input); + throw sentinel; + } + }; + + NexusServiceClient client = + NexusServiceClient.newInstance( + TestNexusServices.TestNexusService1.class, + "search-attrs-test-endpoint", + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setInterceptors(Collections.singletonList(recordingFactory)) + .build()); + + StartNexusOperationOptions startOptions = + StartNexusOperationOptions.newBuilder().setTypedSearchAttributes(attrs).build(); + try { + client.start(TestNexusServices.TestNexusService1::operation, "ignored", startOptions); + Assert.fail("expected sentinel to be thrown by recording interceptor"); + } catch (RuntimeException e) { + Assert.assertSame(sentinel, e); + } + + StartNexusOperationExecutionInput input = captured.get(); + Assert.assertNotNull("interceptor should have captured a start input", input); + SearchAttributes capturedAttrs = input.getOptions().getTypedSearchAttributes(); + Assert.assertNotNull("expected search attributes to be forwarded", capturedAttrs); + Assert.assertTrue( + "expected the custom keyword to be present", capturedAttrs.containsKey(customKey)); + Assert.assertEquals("expected-value", capturedAttrs.get(customKey)); + } + + private NexusServiceClient buildServiceClient( + Endpoint endpoint) { + return NexusServiceClient.newInstance( + TestNexusServices.TestNexusService1.class, + endpoint.getSpec().getName(), + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + } + + private Endpoint createEndpoint(String name) { + EndpointSpec spec = + EndpointSpec.newBuilder() + .setName(name) + .setDescription( + Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) + .setTarget( + EndpointTarget.newBuilder() + .setWorker( + EndpointTarget.Worker.newBuilder() + .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) + .setTaskQueue(testWorkflowRule.getTaskQueue()))) + .build(); + CreateNexusEndpointResponse resp = + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); + return resp.getEndpoint(); + } + + private void deleteEndpoint(Endpoint endpoint) { + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .deleteNexusEndpoint( + DeleteNexusEndpointRequest.newBuilder() + .setId(endpoint.getId()) + .setVersion(endpoint.getVersion()) + .build()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (context, details, input) -> "echo:" + (input == null ? "" : input)); + } + } +} diff --git a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java index f28075db6..abea5b467 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java @@ -622,7 +622,7 @@ public void completeWorkflowTask( public void applyOnConflictOptions(@Nonnull StartWorkflowExecutionRequest request) { update( ctx -> { - OnConflictOptions options = request.getOnConflictOptions(); + io.temporal.api.workflow.v1.OnConflictOptions options = request.getOnConflictOptions(); String requestId = null; List completionCallbacks = null; List links = null; From ba629e467b58fb8b04607e800e6d17b626d2b313 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 19 May 2026 16:06:50 -0700 Subject: [PATCH 02/33] Renaming handle classes --- .../java/io/temporal/client/NexusClient.java | 16 +- .../io/temporal/client/NexusClientImpl.java | 12 +- .../temporal/client/NexusOperationHandle.java | 45 +++ .../client/NexusOperationHandleImpl.java | 343 +++++++++++++++++ .../temporal/client/NexusServiceClient.java | 6 +- .../client/NexusServiceClientImpl.java | 6 +- .../client/UntypedNexusOperationHandle.java | 56 +++ .../client/UntypedNexusServiceClient.java | 2 +- .../client/UntypedNexusServiceClientImpl.java | 8 +- .../NexusClientCallsInterceptor.java | 4 +- .../client/nexus/NexusClientTest.java | 4 +- .../nexus/NexusOperationHandleTest.java | 357 ++++++++++++++++++ .../client/nexus/NexusServiceClientTest.java | 4 +- 13 files changed, 832 insertions(+), 31 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java index 6e87dfa81..4de519bc9 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java @@ -29,14 +29,14 @@ * // Untyped: dispatch by operation name string. * UntypedNexusServiceClient untyped = * client.newUntypedNexusServiceClient("my-endpoint", "MyService"); - * UntypedNexusClientHandle handle = untyped.start("greet", null, "world"); + * UntypedNexusOperationHandle handle = untyped.start("greet", null, "world"); * } * *

To act on an existing operation (describe, cancel, terminate, get result), obtain a handle via * {@link #getHandle}: * *

{@code
- * NexusClientHandle handle = client.getHandle(operationId, runId, String.class);
+ * NexusOperationHandle handle = client.getHandle(operationId, runId, String.class);
  * String result = handle.getResult();
  * handle.cancel("user requested");
  * }
@@ -46,7 +46,7 @@ * * @see NexusServiceClient * @see UntypedNexusServiceClient - * @see NexusClientHandle + * @see NexusOperationHandle */ @Experimental public interface NexusClient { @@ -76,12 +76,12 @@ static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions /** * Returns an untyped handle to an existing operation execution, targeting the latest run. To bind - * a result type, wrap the handle with {@link NexusClientHandle#fromUntyped}. + * a result type, wrap the handle with {@link NexusOperationHandle#fromUntyped}. * * @param operationId the user-assigned operation ID * @return an untyped handle */ - UntypedNexusClientHandle getHandle(String operationId); + UntypedNexusOperationHandle getHandle(String operationId); /** * Returns an untyped handle to an existing operation execution, optionally pinned to a specific @@ -91,7 +91,7 @@ static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions * @param runId the server-assigned run ID, or {@code null} to target the latest run * @return an untyped handle */ - UntypedNexusClientHandle getHandle(String operationId, @Nullable String runId); + UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId); /** * Returns a typed handle to an existing operation execution, bound to {@code resultClass}. @@ -101,7 +101,7 @@ static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions * @param resultClass expected result type * @param result type */ - NexusClientHandle getHandle( + NexusOperationHandle getHandle( String operationId, @Nullable String runId, Class resultClass); /** @@ -115,7 +115,7 @@ NexusClientHandle getHandle( * @param resultType generic type for deserialization; may be {@code null} * @param result type */ - NexusClientHandle getHandle( + NexusOperationHandle getHandle( String operationId, @Nullable String runId, Class resultClass, @Nullable Type resultType); /** diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java index c1754ff7b..42d0ca529 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -91,29 +91,29 @@ public WorkflowServiceStubs getWorkflowServiceStubs() { } @Override - public UntypedNexusClientHandle getHandle(String operationId) { + public UntypedNexusOperationHandle getHandle(String operationId) { return getHandle(operationId, null); } @Override - public UntypedNexusClientHandle getHandle(String operationId, @Nullable String runId) { - return new NexusClientHandleImpl<>( + public UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId) { + return new NexusOperationHandleImpl<>( nexusClientCallsInvoker, operationId, runId, options.getDataConverter()); } @Override - public NexusClientHandle getHandle( + public NexusOperationHandle getHandle( String operationId, @Nullable String runId, Class resultClass) { return getHandle(operationId, runId, resultClass, null); } @Override - public NexusClientHandle getHandle( + public NexusOperationHandle getHandle( String operationId, @Nullable String runId, Class resultClass, @Nullable java.lang.reflect.Type resultType) { - return new NexusClientHandleImpl<>( + return new NexusOperationHandleImpl<>( nexusClientCallsInvoker, operationId, runId, diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java new file mode 100644 index 000000000..65788e53e --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java @@ -0,0 +1,45 @@ +package io.temporal.client; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; + +/** + * Typed handle for interacting with an existing standalone Nexus operation execution. Add a result + * type binding to an {@link UntypedNexusOperationHandle} (returned by {@link + * NexusClient#getHandle(String)}) by calling one of the {@link #fromUntyped} factories. + */ +public interface NexusOperationHandle extends UntypedNexusOperationHandle { + + /** Wrap an {@link UntypedNexusOperationHandle} as a typed handle bound to {@code resultClass}. */ + static NexusOperationHandle fromUntyped( + UntypedNexusOperationHandle handle, Class resultClass) { + return fromUntyped(handle, resultClass, null); + } + + /** + * Wrap an {@link UntypedNexusOperationHandle} as a typed handle bound to {@code resultClass} and + * {@code resultType}. Pass a non-null {@code resultType} when the result is a generic type whose + * parameters cannot be captured by {@link Class} alone (e.g. {@code List}). + */ + static NexusOperationHandle fromUntyped( + UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) { + return NexusOperationHandleImpl.fromUntyped(handle, resultClass, resultType); + } + + /** Block until the operation completes and return the typed result. */ + R getResult(); + + /** Block up to {@code timeout} for the operation to complete and return the typed result. */ + R getResult(long timeout, java.util.concurrent.TimeUnit unit) + throws java.util.concurrent.TimeoutException; + + /** Returns a future that completes with the typed result when the operation finishes. */ + CompletableFuture getResultAsync(); + + /** + * Returns a future that completes with the typed result, or completes exceptionally with a {@link + * java.util.concurrent.TimeoutException} if {@code timeout} elapses first. + */ + CompletableFuture getResultAsync(long timeout, java.util.concurrent.TimeUnit unit); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java new file mode 100644 index 000000000..c74cd2c41 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java @@ -0,0 +1,343 @@ +package io.temporal.client; + +import io.grpc.Deadline; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.NexusOperationWaitStage; +import io.temporal.api.failure.v1.Failure; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput; +import java.lang.reflect.Type; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +/** + * Single implementation of {@link NexusOperationHandle}/{@link UntypedNexusOperationHandle}. + * Constructed untyped by {@link NexusClient#getHandle(String)} and bound to a result type via + * {@link NexusOperationHandle#fromUntyped}. + */ +public class NexusOperationHandleImpl implements NexusOperationHandle { + + /** Default deadline applied to per-handle non-poll RPCs (e.g. {@code describe}). */ + private static final long DEFAULT_DEADLINE_SECONDS = 30; + + /** + * Per-poll deadline used by {@link #getResult} and {@link #getResultAsync}. The server holds the + * request up to this long waiting for completion; if the operation hasn't finished, we re-poll. + */ + private static final long POLL_DEADLINE_SECONDS = 60; + + final NexusClientCallsInterceptor interceptor; + final String operationId; + final @Nullable String runId; + final DataConverter dataConverter; + final @Nullable Class resultClass; + final @Nullable Type resultType; + + /** Construct an untyped handle. Used by {@link NexusClientImpl#getHandle}. */ + public NexusOperationHandleImpl( + NexusClientCallsInterceptor interceptor, + String operationId, + @Nullable String runId, + DataConverter dataConverter) { + this(interceptor, operationId, runId, dataConverter, null, null); + } + + /** + * Implementation of {@link NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class, + * Type)}. Lives here so the interface doesn't reach into impl-private state. + */ + static NexusOperationHandle fromUntyped( + UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) { + if (!(handle instanceof NexusOperationHandleImpl)) { + throw new IllegalArgumentException( + "Unsupported handle implementation: " + handle.getClass().getName()); + } + NexusOperationHandleImpl source = (NexusOperationHandleImpl) handle; + return new NexusOperationHandleImpl<>( + source.interceptor, + source.operationId, + source.runId, + source.dataConverter, + resultClass, + resultType); + } + + /** Construct a typed handle. Use {@link NexusOperationHandle#fromUntyped} from caller code. */ + NexusOperationHandleImpl( + NexusClientCallsInterceptor interceptor, + String operationId, + @Nullable String runId, + DataConverter dataConverter, + @Nullable Class resultClass, + @Nullable Type resultType) { + if (interceptor == null) { + throw new IllegalArgumentException("interceptor is required"); + } + if (operationId == null) { + throw new IllegalArgumentException("operationId is required"); + } + if (dataConverter == null) { + throw new IllegalArgumentException("dataConverter is required"); + } + this.interceptor = interceptor; + this.operationId = operationId; + this.runId = runId; + this.dataConverter = dataConverter; + this.resultClass = resultClass; + this.resultType = resultType; + } + + @Override + public String getNexusOperationId() { + return operationId; + } + + @Override + public @Nullable String getNexusOperationRunId() { + return runId; + } + + @Override + public NexusClientOperationExecutionDescription describe() { + DescribeNexusOperationExecutionInput input = + new DescribeNexusOperationExecutionInput( + operationId, + runId, + /* includeInput= */ false, + /* includeOutcome= */ true, + Deadline.after(DEFAULT_DEADLINE_SECONDS, TimeUnit.SECONDS)); + DescribeNexusOperationExecutionOutput output = + interceptor.describeNexusOperationExecution(input); + return output.getDescription(); + } + + @Override + public void cancel() { + cancel(null); + } + + @Override + public void cancel(@Nullable String reason) { + interceptor.requestCancelNexusOperationExecution( + new RequestCancelNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public void terminate() { + terminate(null); + } + + @Override + public void terminate(@Nullable String reason) { + interceptor.terminateNexusOperationExecution( + new TerminateNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public X getResult(Class resultClass) { + return getResult(resultClass, null); + } + + @Override + public X getResult(Class resultClass, @Nullable Type resultType) { + PollNexusOperationExecutionOutput out = pollUntilCompleted(); + return extractResult(out, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync(Class resultClass) { + return getResultAsync(resultClass, null); + } + + @Override + public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { + return pollAsyncUntilCompleted().thenApply(out -> extractResult(out, resultClass, resultType)); + } + + @Override + public X getResult(long timeout, TimeUnit unit, Class resultClass) + throws TimeoutException { + return getResult(timeout, unit, resultClass, null); + } + + @Override + public X getResult( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException { + long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); + PollNexusOperationExecutionOutput out = pollSyncUntilCompletedOrDeadline(deadlineNanos); + return extractResult(out, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass) { + return getResultAsync(timeout, unit, resultClass, null); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { + long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); + return pollAsyncUntilCompletedOrDeadline(deadlineNanos) + .thenApply(out -> extractResult(out, resultClass, resultType)); + } + + @Override + public R getResult() { + if (resultClass == null) { + throw new IllegalStateException( + "getResult() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); + } + return getResult(resultClass, resultType); + } + + @Override + public R getResult(long timeout, TimeUnit unit) throws TimeoutException { + if (resultClass == null) { + throw new IllegalStateException( + "getResult() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); + } + return getResult(timeout, unit, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync() { + if (resultClass == null) { + throw new IllegalStateException( + "getResultAsync() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); + } + return getResultAsync(resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { + if (resultClass == null) { + throw new IllegalStateException( + "getResultAsync() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); + } + return getResultAsync(timeout, unit, resultClass, resultType); + } + + /** Long-poll loop: re-poll if the server returns before the operation completes. */ + private PollNexusOperationExecutionOutput pollUntilCompleted() { + while (true) { + PollNexusOperationExecutionOutput out = + interceptor.pollNexusOperationExecution(buildPollInput()); + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return out; + } + } + } + + /** Async long-poll loop using {@code thenCompose} to recurse without blocking a thread. */ + private CompletableFuture pollAsyncUntilCompleted() { + return interceptor + .pollNexusOperationExecutionAsync(buildPollInput()) + .thenCompose( + out -> { + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(out); + } + return pollAsyncUntilCompleted(); + }); + } + + /** Sync poll loop bounded by an absolute nanos deadline. */ + private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(long deadlineNanos) + throws TimeoutException { + while (true) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new TimeoutException("getResult timed out before the operation completed"); + } + long pollDeadlineNanos = + Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); + PollNexusOperationExecutionInput pollInput = + new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); + PollNexusOperationExecutionOutput out; + try { + out = interceptor.pollNexusOperationExecution(pollInput); + } catch (RuntimeException e) { + if (System.nanoTime() >= deadlineNanos) { + TimeoutException timeout = + new TimeoutException("getResult timed out before the operation completed"); + timeout.initCause(e); + throw timeout; + } + throw e; + } + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return out; + } + } + } + + /** Async poll loop bounded by an absolute nanos deadline. */ + private CompletableFuture pollAsyncUntilCompletedOrDeadline( + long deadlineNanos) { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally( + new TimeoutException("getResultAsync timed out before the operation completed")); + return failed; + } + long pollDeadlineNanos = + Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); + PollNexusOperationExecutionInput pollInput = + new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); + return interceptor + .pollNexusOperationExecutionAsync(pollInput) + .thenCompose( + out -> { + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(out); + } + return pollAsyncUntilCompletedOrDeadline(deadlineNanos); + }); + } + + private PollNexusOperationExecutionInput buildPollInput() { + return new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + Deadline.after(POLL_DEADLINE_SECONDS, TimeUnit.SECONDS)); + } + + /** + * Convert a completed poll response into the typed result, throwing the operation's failure as an + * exception if it failed. + */ + private X extractResult( + PollNexusOperationExecutionOutput out, Class resultClass, @Nullable Type resultType) { + Optional failure = out.getFailure(); + if (failure.isPresent()) { + throw dataConverter.failureToException(failure.get()); + } + Optional payload = out.getResult(); + if (!payload.isPresent()) { + return null; + } + return dataConverter.fromPayload( + payload.get(), resultClass, resultType != null ? resultType : resultClass); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java index 1208c8145..e277a4b49 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -28,7 +28,7 @@ static NexusServiceClient newInstance( /** * Execute an operation synchronously. Equivalent to {@link #start(BiFunction, Object)} followed - * by {@link NexusClientHandle#getResult()}. + * by {@link NexusOperationHandle#getResult()}. */ default R execute(BiFunction operation, U input) { return start(operation, input).getResult(); @@ -41,12 +41,12 @@ default R execute( } /** Start an operation and return a typed handle to track its execution. */ - default NexusClientHandle start(BiFunction operation, U input) { + default NexusOperationHandle start(BiFunction operation, U input) { return start(operation, input, StartNexusOperationOptions.getDefaultInstance()); } /** Start an operation with per-call options and return a typed handle. */ - NexusClientHandle start( + NexusOperationHandle start( BiFunction operation, U input, StartNexusOperationOptions options); /** diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java index 2b58cc061..0d80f8034 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -46,14 +46,14 @@ static NexusServiceClient newInstance( } @Override - public NexusClientHandle start( + public NexusOperationHandle start( BiFunction operation, U input, StartNexusOperationOptions options) { Method method = MethodExtractor.extract(serviceInterface, (Functions.Func2) operation::apply); String operationName = MethodExtractor.nexusOperationName(method); @SuppressWarnings("unchecked") Class resultClass = (Class) method.getReturnType(); - UntypedNexusClientHandle untyped = start(operationName, options, input); - return NexusClientHandle.fromUntyped(untyped, resultClass, method.getGenericReturnType()); + UntypedNexusOperationHandle untyped = start(operationName, options, input); + return NexusOperationHandle.fromUntyped(untyped, resultClass, method.getGenericReturnType()); } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java new file mode 100644 index 000000000..389659a9b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java @@ -0,0 +1,56 @@ +package io.temporal.client; + +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +public interface UntypedNexusOperationHandle { + /** Operation ID this handle was constructed for. Always non-null. */ + String getNexusOperationId(); + + /** + * Present if the handle was returned by `start` or set when calling `getHandle`. Null if + * `getHandle` was called with a null run ID — in that case, use {@link #describe()} to learn the + * current run ID. + */ + @Nullable + String getNexusOperationRunId(); + + R getResult(Class resultClass); + + R getResult(Class resultClass, @Nullable Type resultType); + + /** + * Block up to {@code timeout} for the operation to complete and return the typed result. Throws + * {@link TimeoutException} if the operation has not completed within the deadline. + */ + R getResult(long timeout, TimeUnit unit, Class resultClass) throws TimeoutException; + + R getResult(long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException; + + CompletableFuture getResultAsync(Class resultClass); + + CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType); + + /** + * Returns a future that completes with the typed result, or completes exceptionally with a {@link + * TimeoutException} if {@code timeout} elapses before the operation finishes. + */ + CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class resultClass); + + CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType); + + NexusClientOperationExecutionDescription describe(); + + void cancel(); + + void cancel(@Nullable String reason); + + void terminate(); + + void terminate(@Nullable String reason); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java index 887be7eee..a3520147a 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java @@ -9,7 +9,7 @@ public interface UntypedNexusServiceClient { /** Start an operation by name, returning an untyped handle. */ - UntypedNexusClientHandle start( + UntypedNexusOperationHandle start( String operation, StartNexusOperationOptions options, @Nullable Object arg); /** Execute an operation synchronously by name. */ diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java index e3cf1cc92..35aff3944 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -38,7 +38,7 @@ class UntypedNexusServiceClientImpl implements UntypedNexusServiceClient { } @Override - public UntypedNexusClientHandle start( + public UntypedNexusOperationHandle start( String operation, StartNexusOperationOptions options, @Nullable Object arg) { Payload payload = serializeInput(arg); StartNexusOperationExecutionInput input = @@ -49,7 +49,7 @@ public UntypedNexusClientHandle start( payload, options != null ? options : StartNexusOperationOptions.getDefaultInstance()); StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); - return new NexusClientHandleImpl<>( + return new NexusOperationHandleImpl<>( invoker, output.getOperationId(), output.getRunId(), dataConverter); } @@ -69,8 +69,8 @@ public R execute( @Nullable Type resultType, StartNexusOperationOptions options, @Nullable Object arg) { - UntypedNexusClientHandle handle = start(operation, options, arg); - return NexusClientHandle.fromUntyped(handle, resultClass, resultType).getResult(); + UntypedNexusOperationHandle handle = start(operation, options, arg); + return NexusOperationHandle.fromUntyped(handle, resultClass, resultType).getResult(); } private @Nullable Payload serializeInput(@Nullable Object arg) { diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 13bdee4c9..2dcc31099 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -7,8 +7,8 @@ import io.temporal.api.failure.v1.Failure; import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo; import io.temporal.client.NexusClient; -import io.temporal.client.NexusClientHandle; import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusOperationHandle; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; import java.util.Collections; @@ -19,7 +19,7 @@ import javax.annotation.Nullable; /** - * Per-call interceptor for {@link NexusClient} and {@link NexusClientHandle} operations on + * Per-call interceptor for {@link NexusClient} and {@link NexusOperationHandle} operations on * standalone Nexus operation executions. * *

Implementations are produced by {@link diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 47af89bee..4a489d4c4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -17,7 +17,7 @@ import io.temporal.client.NexusOperationExecutionCount; import io.temporal.client.NexusOperationExecutionMetadata; import io.temporal.client.StartNexusOperationOptions; -import io.temporal.client.UntypedNexusClientHandle; +import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.TestNexusServices; @@ -92,7 +92,7 @@ public void runStandaloneNexusOperation() throws Exception { StartNexusOperationOptions.newBuilder() .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(); - UntypedNexusClientHandle handle = svcClient.start("operation", opts, inputValue); + UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); String operationId = handle.getNexusOperationId(); // Sync handler: wait for the input to land in the test side-channel; that's how we diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java new file mode 100644 index 000000000..ebbff6e8b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -0,0 +1,357 @@ +package io.temporal.client.nexus; + +import com.google.protobuf.ByteString; +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.api.nexus.v1.EndpointSpec; +import io.temporal.api.nexus.v1.EndpointTarget; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; +import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; +import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientImpl; +import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationHandle; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests for {@link UntypedNexusOperationHandle} per-execution lifecycle methods returned by {@link + * NexusClient#getHandle(String)}: {@code describe()}, {@code cancel()}/{@code cancel(reason)}, and + * {@code terminate()}/{@code terminate(reason)}. + */ +public class NexusOperationHandleTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + // Default is 10s; standalone Nexus dispatch + worker poll can take longer. + .setTestTimeoutSeconds(120) + .build(); + + private NexusClient createNexusClient() { + return NexusClientImpl.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + } + + @Test + public void describeReturnsDescriptionForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + NexusClientOperationExecutionDescription description = handle.describe(); + + Assert.assertNotNull(description); + Assert.assertNotNull(description.getRunId()); + Assert.assertEquals(started.runId, description.getRunId()); + Assert.assertNotNull(description.getRawResponse()); + } finally { + cleanup(started); + } + } + + @Test + public void describeWithoutRunIdTargetsLatest() { + StartedOperation started = startOperation(); + try { + // Handle with no pinned run ID — server should resolve to the latest run. + UntypedNexusOperationHandle handle = started.client.getHandle(started.operationId); + + NexusClientOperationExecutionDescription description = handle.describe(); + + Assert.assertNotNull(description); + Assert.assertEquals(started.runId, description.getRunId()); + } finally { + cleanup(started); + } + } + + @Test + public void cancelSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.cancel(); + // No exception — server accepted the cancel request. + } finally { + cleanup(started); + } + } + + @Test + public void cancelWithReasonSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.cancel("test-cancel-reason"); + } finally { + cleanup(started); + } + } + + @Test + public void cancelWithNullReasonSucceeds() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.cancel(null); + } finally { + cleanup(started); + } + } + + @Test + public void terminateSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.terminate(); + } finally { + cleanup(started); + } + } + + @Test + public void terminateWithReasonSucceedsForStartedOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.terminate("test-terminate-reason"); + } finally { + cleanup(started); + } + } + + @Test + public void terminateWithNullReasonSucceeds() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + handle.terminate(null); + } finally { + cleanup(started); + } + } + + @Test + public void getResultReturnsTypedResultForSyncOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle untyped = + started.client.getHandle(started.operationId, started.runId); + + String result = NexusOperationHandle.fromUntyped(untyped, String.class).getResult(); + + Assert.assertNotNull(result); + Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); + } finally { + cleanup(started); + } + } + + @Test + public void getResultUntypedReturnsResultForSyncOperation() { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + String result = handle.getResult(String.class); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); + } finally { + cleanup(started); + } + } + + @Test + public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception { + StartedOperation started = startOperation(); + try { + UntypedNexusOperationHandle untyped = + started.client.getHandle(started.operationId, started.runId); + + String result = + NexusOperationHandle.fromUntyped(untyped, String.class) + .getResultAsync() + .get(60, java.util.concurrent.TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); + } finally { + cleanup(started); + } + } + + /** Holder for state used to drive a single test against one started operation. */ + private static final class StartedOperation { + final NexusClient client; + final Endpoint endpoint; + final String operationId; + final String runId; + + StartedOperation(NexusClient client, Endpoint endpoint, String operationId, String runId) { + this.client = client; + this.endpoint = endpoint; + this.operationId = operationId; + this.runId = runId; + } + } + + private StartedOperation startOperation() { + return startOperation(null); + } + + private StartedOperation startOperation(@javax.annotation.Nullable String inputOverride) { + NexusClient client = createNexusClient(); + Endpoint endpoint = createEndpoint("test-endpoint-" + testWorkflowRule.getTaskQueue()); + String inputValue = + inputOverride != null ? inputOverride : "ping-handle-test-" + UUID.randomUUID(); + + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); + + Assert.assertNotNull("expected start to return a run ID", handle.getNexusOperationRunId()); + return new StartedOperation( + client, endpoint, handle.getNexusOperationId(), handle.getNexusOperationRunId()); + } + + private void cleanup(StartedOperation started) { + deleteEndpoint(started.endpoint); + } + + private Endpoint createEndpoint(String name) { + EndpointSpec spec = + EndpointSpec.newBuilder() + .setName(name) + .setDescription( + Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) + .setTarget( + EndpointTarget.newBuilder() + .setWorker( + EndpointTarget.Worker.newBuilder() + .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) + .setTaskQueue(testWorkflowRule.getTaskQueue()))) + .build(); + CreateNexusEndpointResponse resp = + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); + return resp.getEndpoint(); + } + + private void deleteEndpoint(Endpoint endpoint) { + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .deleteNexusEndpoint( + DeleteNexusEndpointRequest.newBuilder() + .setId(endpoint.getId()) + .setVersion(endpoint.getVersion()) + .build()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class TestNexusServiceImpl { + /** Inputs starting with this prefix make the handler throw, exercising the failure path. */ + static final String FAIL_PREFIX = "FAIL:"; + + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (context, details, input) -> { + if (input != null && input.startsWith(FAIL_PREFIX)) { + // OperationException.failed = definitive failure (no retries) so the caller's + // getResult surfaces the failure instead of timing out. + throw OperationException.failed("intentional failure: " + input); + } + return "echo:" + (input == null ? "" : input); + }); + } + } + + @Test + public void getResultPropagatesOperationFailure() { + StartedOperation started = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); + try { + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + + try { + handle.getResult(String.class); + Assert.fail("expected getResult to throw because the operation handler failed"); + } catch (RuntimeException e) { + // The DataConverter wraps the proto Failure into a Java exception. Either the message + // carries the handler's reason, or one of the cause links does. + String combined = collectMessages(e); + Assert.assertTrue( + "expected exception chain to mention the handler failure, got: " + combined, + combined.contains("intentional failure")); + } + } finally { + cleanup(started); + } + } + + private static String collectMessages(Throwable t) { + StringBuilder sb = new StringBuilder(); + for (Throwable c = t; c != null; c = c.getCause()) { + sb.append(c.getClass().getSimpleName()).append(":").append(c.getMessage()).append(" | "); + if (c.getCause() == c) { + break; + } + } + return sb.toString(); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index 218630eaa..1c3a0b558 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -11,8 +11,8 @@ import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; -import io.temporal.client.NexusClientHandle; import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationHandle; import io.temporal.client.NexusServiceClient; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.SearchAttributeKey; @@ -63,7 +63,7 @@ public void startReturnsTypedHandleAndPollsResult() { try { NexusServiceClient client = buildServiceClient(endpoint); - NexusClientHandle handle = + NexusOperationHandle handle = client.start(TestNexusServices.TestNexusService1::operation, "world"); Assert.assertNotNull(handle.getNexusOperationId()); From ba6c27fcf03dcebdcd007f0c21716d561f8d5c01 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 19 May 2026 16:07:11 -0700 Subject: [PATCH 03/33] Removing the renamed classes --- .../io/temporal/client/NexusClientHandle.java | 45 --- .../client/NexusClientHandleImpl.java | 343 ----------------- .../client/UntypedNexusClientHandle.java | 56 --- .../client/nexus/NexusClientHandleTest.java | 357 ------------------ 4 files changed, 801 deletions(-) delete mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java delete mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java delete mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java delete mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java deleted file mode 100644 index 5500eadd8..000000000 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandle.java +++ /dev/null @@ -1,45 +0,0 @@ -package io.temporal.client; - -import java.lang.reflect.Type; -import java.util.concurrent.CompletableFuture; -import javax.annotation.Nullable; - -/** - * Typed handle for interacting with an existing standalone Nexus operation execution. Add a result - * type binding to an {@link UntypedNexusClientHandle} (returned by {@link - * NexusClient#getHandle(String)}) by calling one of the {@link #fromUntyped} factories. - */ -public interface NexusClientHandle extends UntypedNexusClientHandle { - - /** Wrap an {@link UntypedNexusClientHandle} as a typed handle bound to {@code resultClass}. */ - static NexusClientHandle fromUntyped( - UntypedNexusClientHandle handle, Class resultClass) { - return fromUntyped(handle, resultClass, null); - } - - /** - * Wrap an {@link UntypedNexusClientHandle} as a typed handle bound to {@code resultClass} and - * {@code resultType}. Pass a non-null {@code resultType} when the result is a generic type whose - * parameters cannot be captured by {@link Class} alone (e.g. {@code List}). - */ - static NexusClientHandle fromUntyped( - UntypedNexusClientHandle handle, Class resultClass, @Nullable Type resultType) { - return NexusClientHandleImpl.fromUntyped(handle, resultClass, resultType); - } - - /** Block until the operation completes and return the typed result. */ - R getResult(); - - /** Block up to {@code timeout} for the operation to complete and return the typed result. */ - R getResult(long timeout, java.util.concurrent.TimeUnit unit) - throws java.util.concurrent.TimeoutException; - - /** Returns a future that completes with the typed result when the operation finishes. */ - CompletableFuture getResultAsync(); - - /** - * Returns a future that completes with the typed result, or completes exceptionally with a {@link - * java.util.concurrent.TimeoutException} if {@code timeout} elapses first. - */ - CompletableFuture getResultAsync(long timeout, java.util.concurrent.TimeUnit unit); -} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java deleted file mode 100644 index c283635fd..000000000 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientHandleImpl.java +++ /dev/null @@ -1,343 +0,0 @@ -package io.temporal.client; - -import io.grpc.Deadline; -import io.temporal.api.common.v1.Payload; -import io.temporal.api.enums.v1.NexusOperationWaitStage; -import io.temporal.api.failure.v1.Failure; -import io.temporal.common.converter.DataConverter; -import io.temporal.common.interceptors.NexusClientCallsInterceptor; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionOutput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionOutput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput; -import java.lang.reflect.Type; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import javax.annotation.Nullable; - -/** - * Single implementation of {@link NexusClientHandle}/{@link UntypedNexusClientHandle}. Constructed - * untyped by {@link NexusClient#getHandle(String)} and bound to a result type via {@link - * NexusClientHandle#fromUntyped}. - */ -public class NexusClientHandleImpl implements NexusClientHandle { - - /** Default deadline applied to per-handle non-poll RPCs (e.g. {@code describe}). */ - private static final long DEFAULT_DEADLINE_SECONDS = 30; - - /** - * Per-poll deadline used by {@link #getResult} and {@link #getResultAsync}. The server holds the - * request up to this long waiting for completion; if the operation hasn't finished, we re-poll. - */ - private static final long POLL_DEADLINE_SECONDS = 60; - - final NexusClientCallsInterceptor interceptor; - final String operationId; - final @Nullable String runId; - final DataConverter dataConverter; - final @Nullable Class resultClass; - final @Nullable Type resultType; - - /** Construct an untyped handle. Used by {@link NexusClientImpl#getHandle}. */ - public NexusClientHandleImpl( - NexusClientCallsInterceptor interceptor, - String operationId, - @Nullable String runId, - DataConverter dataConverter) { - this(interceptor, operationId, runId, dataConverter, null, null); - } - - /** - * Implementation of {@link NexusClientHandle#fromUntyped(UntypedNexusClientHandle, Class, Type)}. - * Lives here so the interface doesn't reach into impl-private state. - */ - static NexusClientHandle fromUntyped( - UntypedNexusClientHandle handle, Class resultClass, @Nullable Type resultType) { - if (!(handle instanceof NexusClientHandleImpl)) { - throw new IllegalArgumentException( - "Unsupported handle implementation: " + handle.getClass().getName()); - } - NexusClientHandleImpl source = (NexusClientHandleImpl) handle; - return new NexusClientHandleImpl<>( - source.interceptor, - source.operationId, - source.runId, - source.dataConverter, - resultClass, - resultType); - } - - /** Construct a typed handle. Use {@link NexusClientHandle#fromUntyped} from caller code. */ - NexusClientHandleImpl( - NexusClientCallsInterceptor interceptor, - String operationId, - @Nullable String runId, - DataConverter dataConverter, - @Nullable Class resultClass, - @Nullable Type resultType) { - if (interceptor == null) { - throw new IllegalArgumentException("interceptor is required"); - } - if (operationId == null) { - throw new IllegalArgumentException("operationId is required"); - } - if (dataConverter == null) { - throw new IllegalArgumentException("dataConverter is required"); - } - this.interceptor = interceptor; - this.operationId = operationId; - this.runId = runId; - this.dataConverter = dataConverter; - this.resultClass = resultClass; - this.resultType = resultType; - } - - @Override - public String getNexusOperationId() { - return operationId; - } - - @Override - public @Nullable String getNexusOperationRunId() { - return runId; - } - - @Override - public NexusClientOperationExecutionDescription describe() { - DescribeNexusOperationExecutionInput input = - new DescribeNexusOperationExecutionInput( - operationId, - runId, - /* includeInput= */ false, - /* includeOutcome= */ true, - Deadline.after(DEFAULT_DEADLINE_SECONDS, TimeUnit.SECONDS)); - DescribeNexusOperationExecutionOutput output = - interceptor.describeNexusOperationExecution(input); - return output.getDescription(); - } - - @Override - public void cancel() { - cancel(null); - } - - @Override - public void cancel(@Nullable String reason) { - interceptor.requestCancelNexusOperationExecution( - new RequestCancelNexusOperationExecutionInput(operationId, runId, reason)); - } - - @Override - public void terminate() { - terminate(null); - } - - @Override - public void terminate(@Nullable String reason) { - interceptor.terminateNexusOperationExecution( - new TerminateNexusOperationExecutionInput(operationId, runId, reason)); - } - - @Override - public X getResult(Class resultClass) { - return getResult(resultClass, null); - } - - @Override - public X getResult(Class resultClass, @Nullable Type resultType) { - PollNexusOperationExecutionOutput out = pollUntilCompleted(); - return extractResult(out, resultClass, resultType); - } - - @Override - public CompletableFuture getResultAsync(Class resultClass) { - return getResultAsync(resultClass, null); - } - - @Override - public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { - return pollAsyncUntilCompleted().thenApply(out -> extractResult(out, resultClass, resultType)); - } - - @Override - public X getResult(long timeout, TimeUnit unit, Class resultClass) - throws TimeoutException { - return getResult(timeout, unit, resultClass, null); - } - - @Override - public X getResult( - long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) - throws TimeoutException { - long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); - PollNexusOperationExecutionOutput out = pollSyncUntilCompletedOrDeadline(deadlineNanos); - return extractResult(out, resultClass, resultType); - } - - @Override - public CompletableFuture getResultAsync( - long timeout, TimeUnit unit, Class resultClass) { - return getResultAsync(timeout, unit, resultClass, null); - } - - @Override - public CompletableFuture getResultAsync( - long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { - long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); - return pollAsyncUntilCompletedOrDeadline(deadlineNanos) - .thenApply(out -> extractResult(out, resultClass, resultType)); - } - - @Override - public R getResult() { - if (resultClass == null) { - throw new IllegalStateException( - "getResult() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); - } - return getResult(resultClass, resultType); - } - - @Override - public R getResult(long timeout, TimeUnit unit) throws TimeoutException { - if (resultClass == null) { - throw new IllegalStateException( - "getResult() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); - } - return getResult(timeout, unit, resultClass, resultType); - } - - @Override - public CompletableFuture getResultAsync() { - if (resultClass == null) { - throw new IllegalStateException( - "getResultAsync() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); - } - return getResultAsync(resultClass, resultType); - } - - @Override - public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { - if (resultClass == null) { - throw new IllegalStateException( - "getResultAsync() requires a result type binding — wrap this handle with NexusClientHandle.fromUntyped"); - } - return getResultAsync(timeout, unit, resultClass, resultType); - } - - /** Long-poll loop: re-poll if the server returns before the operation completes. */ - private PollNexusOperationExecutionOutput pollUntilCompleted() { - while (true) { - PollNexusOperationExecutionOutput out = - interceptor.pollNexusOperationExecution(buildPollInput()); - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return out; - } - } - } - - /** Async long-poll loop using {@code thenCompose} to recurse without blocking a thread. */ - private CompletableFuture pollAsyncUntilCompleted() { - return interceptor - .pollNexusOperationExecutionAsync(buildPollInput()) - .thenCompose( - out -> { - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return CompletableFuture.completedFuture(out); - } - return pollAsyncUntilCompleted(); - }); - } - - /** Sync poll loop bounded by an absolute nanos deadline. */ - private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(long deadlineNanos) - throws TimeoutException { - while (true) { - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - throw new TimeoutException("getResult timed out before the operation completed"); - } - long pollDeadlineNanos = - Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); - PollNexusOperationExecutionInput pollInput = - new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); - PollNexusOperationExecutionOutput out; - try { - out = interceptor.pollNexusOperationExecution(pollInput); - } catch (RuntimeException e) { - if (System.nanoTime() >= deadlineNanos) { - TimeoutException timeout = - new TimeoutException("getResult timed out before the operation completed"); - timeout.initCause(e); - throw timeout; - } - throw e; - } - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return out; - } - } - } - - /** Async poll loop bounded by an absolute nanos deadline. */ - private CompletableFuture pollAsyncUntilCompletedOrDeadline( - long deadlineNanos) { - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - CompletableFuture failed = new CompletableFuture<>(); - failed.completeExceptionally( - new TimeoutException("getResultAsync timed out before the operation completed")); - return failed; - } - long pollDeadlineNanos = - Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); - PollNexusOperationExecutionInput pollInput = - new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); - return interceptor - .pollNexusOperationExecutionAsync(pollInput) - .thenCompose( - out -> { - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return CompletableFuture.completedFuture(out); - } - return pollAsyncUntilCompletedOrDeadline(deadlineNanos); - }); - } - - private PollNexusOperationExecutionInput buildPollInput() { - return new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - Deadline.after(POLL_DEADLINE_SECONDS, TimeUnit.SECONDS)); - } - - /** - * Convert a completed poll response into the typed result, throwing the operation's failure as an - * exception if it failed. - */ - private X extractResult( - PollNexusOperationExecutionOutput out, Class resultClass, @Nullable Type resultType) { - Optional failure = out.getFailure(); - if (failure.isPresent()) { - throw dataConverter.failureToException(failure.get()); - } - Optional payload = out.getResult(); - if (!payload.isPresent()) { - return null; - } - return dataConverter.fromPayload( - payload.get(), resultClass, resultType != null ? resultType : resultClass); - } -} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java deleted file mode 100644 index 6dcb65873..000000000 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusClientHandle.java +++ /dev/null @@ -1,56 +0,0 @@ -package io.temporal.client; - -import java.lang.reflect.Type; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import javax.annotation.Nullable; - -public interface UntypedNexusClientHandle { - /** Operation ID this handle was constructed for. Always non-null. */ - String getNexusOperationId(); - - /** - * Present if the handle was returned by `start` or set when calling `getHandle`. Null if - * `getHandle` was called with a null run ID — in that case, use {@link #describe()} to learn the - * current run ID. - */ - @Nullable - String getNexusOperationRunId(); - - R getResult(Class resultClass); - - R getResult(Class resultClass, @Nullable Type resultType); - - /** - * Block up to {@code timeout} for the operation to complete and return the typed result. Throws - * {@link TimeoutException} if the operation has not completed within the deadline. - */ - R getResult(long timeout, TimeUnit unit, Class resultClass) throws TimeoutException; - - R getResult(long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) - throws TimeoutException; - - CompletableFuture getResultAsync(Class resultClass); - - CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType); - - /** - * Returns a future that completes with the typed result, or completes exceptionally with a {@link - * TimeoutException} if {@code timeout} elapses before the operation finishes. - */ - CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class resultClass); - - CompletableFuture getResultAsync( - long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType); - - NexusClientOperationExecutionDescription describe(); - - void cancel(); - - void cancel(@Nullable String reason); - - void terminate(); - - void terminate(@Nullable String reason); -} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java deleted file mode 100644 index 08e4061db..000000000 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientHandleTest.java +++ /dev/null @@ -1,357 +0,0 @@ -package io.temporal.client.nexus; - -import com.google.protobuf.ByteString; -import io.nexusrpc.OperationException; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; -import io.nexusrpc.handler.ServiceImpl; -import io.temporal.api.common.v1.Payload; -import io.temporal.api.nexus.v1.Endpoint; -import io.temporal.api.nexus.v1.EndpointSpec; -import io.temporal.api.nexus.v1.EndpointTarget; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; -import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; -import io.temporal.client.NexusClient; -import io.temporal.client.NexusClientImpl; -import io.temporal.client.NexusClientOperationExecutionDescription; -import io.temporal.client.NexusClientOptions; -import io.temporal.client.StartNexusOperationOptions; -import io.temporal.client.UntypedNexusClientHandle; -import io.temporal.client.UntypedNexusServiceClient; -import io.temporal.testing.internal.SDKTestWorkflowRule; -import io.temporal.workflow.shared.TestNexusServices; -import io.temporal.workflow.shared.TestWorkflows; -import java.time.Duration; -import java.util.UUID; -import org.junit.Assert; -import org.junit.Rule; -import org.junit.Test; - -/** - * Tests for {@link UntypedNexusClientHandle} per-execution lifecycle methods returned by {@link - * NexusClient#getHandle(String)}: {@code describe()}, {@code cancel()}/{@code cancel(reason)}, and - * {@code terminate()}/{@code terminate(reason)}. - */ -public class NexusClientHandleTest { - - @Rule - public SDKTestWorkflowRule testWorkflowRule = - SDKTestWorkflowRule.newBuilder() - .setWorkflowTypes(PlaceholderWorkflowImpl.class) - .setNexusServiceImplementation(new TestNexusServiceImpl()) - // Default is 10s; standalone Nexus dispatch + worker poll can take longer. - .setTestTimeoutSeconds(120) - .build(); - - private NexusClient createNexusClient() { - return NexusClientImpl.newInstance( - testWorkflowRule.getWorkflowServiceStubs(), - NexusClientOptions.newBuilder() - .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) - .build()); - } - - @Test - public void describeReturnsDescriptionForStartedOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - NexusClientOperationExecutionDescription description = handle.describe(); - - Assert.assertNotNull(description); - Assert.assertNotNull(description.getRunId()); - Assert.assertEquals(started.runId, description.getRunId()); - Assert.assertNotNull(description.getRawResponse()); - } finally { - cleanup(started); - } - } - - @Test - public void describeWithoutRunIdTargetsLatest() { - StartedOperation started = startOperation(); - try { - // Handle with no pinned run ID — server should resolve to the latest run. - UntypedNexusClientHandle handle = started.client.getHandle(started.operationId); - - NexusClientOperationExecutionDescription description = handle.describe(); - - Assert.assertNotNull(description); - Assert.assertEquals(started.runId, description.getRunId()); - } finally { - cleanup(started); - } - } - - @Test - public void cancelSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.cancel(); - // No exception — server accepted the cancel request. - } finally { - cleanup(started); - } - } - - @Test - public void cancelWithReasonSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.cancel("test-cancel-reason"); - } finally { - cleanup(started); - } - } - - @Test - public void cancelWithNullReasonSucceeds() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.cancel(null); - } finally { - cleanup(started); - } - } - - @Test - public void terminateSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.terminate(); - } finally { - cleanup(started); - } - } - - @Test - public void terminateWithReasonSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.terminate("test-terminate-reason"); - } finally { - cleanup(started); - } - } - - @Test - public void terminateWithNullReasonSucceeds() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.terminate(null); - } finally { - cleanup(started); - } - } - - @Test - public void getResultReturnsTypedResultForSyncOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle untyped = - started.client.getHandle(started.operationId, started.runId); - - String result = - io.temporal.client.NexusClientHandle.fromUntyped(untyped, String.class).getResult(); - - Assert.assertNotNull(result); - Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); - } finally { - cleanup(started); - } - } - - @Test - public void getResultUntypedReturnsResultForSyncOperation() { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - String result = handle.getResult(String.class); - - Assert.assertNotNull(result); - Assert.assertTrue(result.startsWith("echo:ping-")); - } finally { - cleanup(started); - } - } - - @Test - public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception { - StartedOperation started = startOperation(); - try { - UntypedNexusClientHandle untyped = - started.client.getHandle(started.operationId, started.runId); - - String result = - io.temporal.client.NexusClientHandle.fromUntyped(untyped, String.class) - .getResultAsync() - .get(60, java.util.concurrent.TimeUnit.SECONDS); - - Assert.assertNotNull(result); - Assert.assertTrue(result.startsWith("echo:ping-")); - } finally { - cleanup(started); - } - } - - /** Holder for state used to drive a single test against one started operation. */ - private static final class StartedOperation { - final NexusClient client; - final Endpoint endpoint; - final String operationId; - final String runId; - - StartedOperation(NexusClient client, Endpoint endpoint, String operationId, String runId) { - this.client = client; - this.endpoint = endpoint; - this.operationId = operationId; - this.runId = runId; - } - } - - private StartedOperation startOperation() { - return startOperation(null); - } - - private StartedOperation startOperation(@javax.annotation.Nullable String inputOverride) { - NexusClient client = createNexusClient(); - Endpoint endpoint = createEndpoint("test-endpoint-" + testWorkflowRule.getTaskQueue()); - String inputValue = - inputOverride != null ? inputOverride : "ping-handle-test-" + UUID.randomUUID(); - - UntypedNexusServiceClient svcClient = - client.newUntypedNexusServiceClient( - endpoint.getSpec().getName(), - TestNexusServices.TestNexusService1.class.getSimpleName()); - StartNexusOperationOptions opts = - StartNexusOperationOptions.newBuilder() - .setScheduleToCloseTimeout(Duration.ofSeconds(30)) - .build(); - UntypedNexusClientHandle handle = svcClient.start("operation", opts, inputValue); - - Assert.assertNotNull("expected start to return a run ID", handle.getNexusOperationRunId()); - return new StartedOperation( - client, endpoint, handle.getNexusOperationId(), handle.getNexusOperationRunId()); - } - - private void cleanup(StartedOperation started) { - deleteEndpoint(started.endpoint); - } - - private Endpoint createEndpoint(String name) { - EndpointSpec spec = - EndpointSpec.newBuilder() - .setName(name) - .setDescription( - Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) - .setTarget( - EndpointTarget.newBuilder() - .setWorker( - EndpointTarget.Worker.newBuilder() - .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) - .setTaskQueue(testWorkflowRule.getTaskQueue()))) - .build(); - CreateNexusEndpointResponse resp = - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); - return resp.getEndpoint(); - } - - private void deleteEndpoint(Endpoint endpoint) { - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .deleteNexusEndpoint( - DeleteNexusEndpointRequest.newBuilder() - .setId(endpoint.getId()) - .setVersion(endpoint.getVersion()) - .build()); - } - - public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { - @Override - public String execute(String input) { - return input; - } - } - - @ServiceImpl(service = TestNexusServices.TestNexusService1.class) - public static class TestNexusServiceImpl { - /** Inputs starting with this prefix make the handler throw, exercising the failure path. */ - static final String FAIL_PREFIX = "FAIL:"; - - @OperationImpl - public OperationHandler operation() { - return OperationHandler.sync( - (context, details, input) -> { - if (input != null && input.startsWith(FAIL_PREFIX)) { - // OperationException.failed = definitive failure (no retries) so the caller's - // getResult surfaces the failure instead of timing out. - throw OperationException.failed("intentional failure: " + input); - } - return "echo:" + (input == null ? "" : input); - }); - } - } - - @Test - public void getResultPropagatesOperationFailure() { - StartedOperation started = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); - try { - UntypedNexusClientHandle handle = - started.client.getHandle(started.operationId, started.runId); - - try { - handle.getResult(String.class); - Assert.fail("expected getResult to throw because the operation handler failed"); - } catch (RuntimeException e) { - // The DataConverter wraps the proto Failure into a Java exception. Either the message - // carries the handler's reason, or one of the cause links does. - String combined = collectMessages(e); - Assert.assertTrue( - "expected exception chain to mention the handler failure, got: " + combined, - combined.contains("intentional failure")); - } - } finally { - cleanup(started); - } - } - - private static String collectMessages(Throwable t) { - StringBuilder sb = new StringBuilder(); - for (Throwable c = t; c != null; c = c.getCause()) { - sb.append(c.getClass().getSimpleName()).append(":").append(c.getMessage()).append(" | "); - if (c.getCause() == c) { - break; - } - } - return sb.toString(); - } -} From 352569d4d7dd0d944ae30229b737a2bb7dbb2991 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 19 May 2026 16:45:02 -0700 Subject: [PATCH 04/33] Using endpoints from the rule instead of creating them --- .../client/nexus/NexusClientTest.java | 126 +++------- .../nexus/NexusOperationHandleTest.java | 227 +++++------------- .../client/nexus/NexusServiceClientTest.java | 69 +----- 3 files changed, 115 insertions(+), 307 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 4a489d4c4..216e0c036 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -1,16 +1,9 @@ package io.temporal.client.nexus; -import com.google.protobuf.ByteString; import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; -import io.temporal.api.common.v1.Payload; import io.temporal.api.nexus.v1.Endpoint; -import io.temporal.api.nexus.v1.EndpointSpec; -import io.temporal.api.nexus.v1.EndpointTarget; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; -import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; import io.temporal.client.NexusClient; import io.temporal.client.NexusClientImpl; import io.temporal.client.NexusClientOptions; @@ -79,57 +72,50 @@ public void runStandaloneNexusOperation() throws Exception { TestNexusServiceImpl.received = new java.util.concurrent.CompletableFuture<>(); TestNexusServiceImpl.invocationCount.set(0); - Endpoint endpoint = createEndpoint("test-endpoint-" + testWorkflowRule.getTaskQueue()); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); String inputValue = "ping-" + UUID.randomUUID(); NexusClient client = createNexusClient(); + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); + String operationId = handle.getNexusOperationId(); + + // Sync handler: wait for the input to land in the test side-channel; that's how we + // know the operation actually completed on the worker. + String observed; try { - UntypedNexusServiceClient svcClient = - client.newUntypedNexusServiceClient( - endpoint.getSpec().getName(), - TestNexusServices.TestNexusService1.class.getSimpleName()); - StartNexusOperationOptions opts = - StartNexusOperationOptions.newBuilder() - .setScheduleToCloseTimeout(Duration.ofSeconds(30)) - .build(); - UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); - String operationId = handle.getNexusOperationId(); - - // Sync handler: wait for the input to land in the test side-channel; that's how we - // know the operation actually completed on the worker. - String observed; - try { - observed = TestNexusServiceImpl.received.get(60, TimeUnit.SECONDS); - } catch (java.util.concurrent.TimeoutException e) { - Assert.fail( - "Nexus handler was never invoked within 60s. invocationCount=" - + TestNexusServiceImpl.invocationCount.get()); - throw new AssertionError("unreachable"); - } - Assert.assertEquals( - "expected the Nexus handler to receive the same input we sent", inputValue, observed); - - // Poll the list until our operationId appears. This also tests that the list operation - // works correctly. - NexusOperationExecutionMetadata listed = - waitForListedOperation(client, operationId, Duration.ofSeconds(15)); - Assert.assertNotNull( - "expected operationId " + operationId + " to appear in listNexusOperationExecutions", - listed); - Assert.assertEquals(operationId, listed.getOperationId()); - Assert.assertEquals(endpoint.getSpec().getName(), listed.getEndpoint()); - Assert.assertEquals( - TestNexusServices.TestNexusService1.class.getSimpleName(), listed.getService()); - Assert.assertEquals("operation", listed.getOperation()); - - // We know count should be at least 1 until we clean up - // Due to race conditions with other tests running, we don't know what it actually should be - // though - - // but this is a chance to assert that it at least returns a non-zero value when appropriate - Assert.assertTrue(countNexusOperations() >= 1); - } finally { - deleteEndpoint(endpoint); + observed = TestNexusServiceImpl.received.get(60, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + Assert.fail( + "Nexus handler was never invoked within 60s. invocationCount=" + + TestNexusServiceImpl.invocationCount.get()); + throw new AssertionError("unreachable"); } + Assert.assertEquals( + "expected the Nexus handler to receive the same input we sent", inputValue, observed); + + // Poll the list until our operationId appears. This also tests that the list operation + // works correctly. + NexusOperationExecutionMetadata listed = + waitForListedOperation(client, operationId, Duration.ofSeconds(15)); + Assert.assertNotNull( + "expected operationId " + operationId + " to appear in listNexusOperationExecutions", + listed); + Assert.assertEquals(operationId, listed.getOperationId()); + Assert.assertEquals(endpoint.getSpec().getName(), listed.getEndpoint()); + Assert.assertEquals( + TestNexusServices.TestNexusService1.class.getSimpleName(), listed.getService()); + Assert.assertEquals("operation", listed.getOperation()); + + // We know count should be at least 1. + Assert.assertTrue(countNexusOperations() >= 1); } private NexusOperationExecutionMetadata waitForListedOperation( @@ -150,40 +136,6 @@ private NexusOperationExecutionMetadata waitForListedOperation( return null; } - private Endpoint createEndpoint(String name) { - EndpointSpec spec = - EndpointSpec.newBuilder() - .setName(name) - .setDescription( - Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) - .setTarget( - EndpointTarget.newBuilder() - .setWorker( - EndpointTarget.Worker.newBuilder() - .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) - .setTaskQueue(testWorkflowRule.getTaskQueue()))) - .build(); - CreateNexusEndpointResponse resp = - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); - return resp.getEndpoint(); - } - - private void deleteEndpoint(Endpoint endpoint) { - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .deleteNexusEndpoint( - DeleteNexusEndpointRequest.newBuilder() - .setId(endpoint.getId()) - .setVersion(endpoint.getVersion()) - .build()); - } - public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { @Override public String execute(String input) { diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index ebbff6e8b..ec4b93725 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -1,17 +1,10 @@ package io.temporal.client.nexus; -import com.google.protobuf.ByteString; import io.nexusrpc.OperationException; import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; -import io.temporal.api.common.v1.Payload; import io.temporal.api.nexus.v1.Endpoint; -import io.temporal.api.nexus.v1.EndpointSpec; -import io.temporal.api.nexus.v1.EndpointTarget; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; -import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; import io.temporal.client.NexusClient; import io.temporal.client.NexusClientImpl; import io.temporal.client.NexusClientOperationExecutionDescription; @@ -56,177 +49,131 @@ private NexusClient createNexusClient() { @Test public void describeReturnsDescriptionForStartedOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - NexusClientOperationExecutionDescription description = handle.describe(); + NexusClientOperationExecutionDescription description = handle.describe(); - Assert.assertNotNull(description); - Assert.assertNotNull(description.getRunId()); - Assert.assertEquals(started.runId, description.getRunId()); - Assert.assertNotNull(description.getRawResponse()); - } finally { - cleanup(started); - } + Assert.assertNotNull(description); + Assert.assertNotNull(description.getRunId()); + Assert.assertEquals(started.runId, description.getRunId()); + Assert.assertNotNull(description.getRawResponse()); } @Test public void describeWithoutRunIdTargetsLatest() { StartedOperation started = startOperation(); - try { - // Handle with no pinned run ID — server should resolve to the latest run. - UntypedNexusOperationHandle handle = started.client.getHandle(started.operationId); + // Handle with no pinned run ID — server should resolve to the latest run. + UntypedNexusOperationHandle handle = started.client.getHandle(started.operationId); - NexusClientOperationExecutionDescription description = handle.describe(); + NexusClientOperationExecutionDescription description = handle.describe(); - Assert.assertNotNull(description); - Assert.assertEquals(started.runId, description.getRunId()); - } finally { - cleanup(started); - } + Assert.assertNotNull(description); + Assert.assertEquals(started.runId, description.getRunId()); } @Test public void cancelSucceedsForStartedOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - handle.cancel(); - // No exception — server accepted the cancel request. - } finally { - cleanup(started); - } + handle.cancel(); + // No exception — server accepted the cancel request. } @Test public void cancelWithReasonSucceedsForStartedOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - handle.cancel("test-cancel-reason"); - } finally { - cleanup(started); - } + handle.cancel("test-cancel-reason"); } @Test public void cancelWithNullReasonSucceeds() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - handle.cancel(null); - } finally { - cleanup(started); - } + handle.cancel(null); } @Test public void terminateSucceedsForStartedOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - handle.terminate(); - } finally { - cleanup(started); - } + handle.terminate(); } @Test public void terminateWithReasonSucceedsForStartedOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - handle.terminate("test-terminate-reason"); - } finally { - cleanup(started); - } + handle.terminate("test-terminate-reason"); } @Test public void terminateWithNullReasonSucceeds() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - handle.terminate(null); - } finally { - cleanup(started); - } + handle.terminate(null); } @Test public void getResultReturnsTypedResultForSyncOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle untyped = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle untyped = + started.client.getHandle(started.operationId, started.runId); - String result = NexusOperationHandle.fromUntyped(untyped, String.class).getResult(); + String result = NexusOperationHandle.fromUntyped(untyped, String.class).getResult(); - Assert.assertNotNull(result); - Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); - } finally { - cleanup(started); - } + Assert.assertNotNull(result); + Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); } @Test public void getResultUntypedReturnsResultForSyncOperation() { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); - String result = handle.getResult(String.class); + String result = handle.getResult(String.class); - Assert.assertNotNull(result); - Assert.assertTrue(result.startsWith("echo:ping-")); - } finally { - cleanup(started); - } + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); } @Test public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception { StartedOperation started = startOperation(); - try { - UntypedNexusOperationHandle untyped = - started.client.getHandle(started.operationId, started.runId); - - String result = - NexusOperationHandle.fromUntyped(untyped, String.class) - .getResultAsync() - .get(60, java.util.concurrent.TimeUnit.SECONDS); - - Assert.assertNotNull(result); - Assert.assertTrue(result.startsWith("echo:ping-")); - } finally { - cleanup(started); - } + UntypedNexusOperationHandle untyped = + started.client.getHandle(started.operationId, started.runId); + + String result = + NexusOperationHandle.fromUntyped(untyped, String.class) + .getResultAsync() + .get(60, java.util.concurrent.TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); } /** Holder for state used to drive a single test against one started operation. */ private static final class StartedOperation { final NexusClient client; - final Endpoint endpoint; final String operationId; final String runId; - StartedOperation(NexusClient client, Endpoint endpoint, String operationId, String runId) { + StartedOperation(NexusClient client, String operationId, String runId) { this.client = client; - this.endpoint = endpoint; this.operationId = operationId; this.runId = runId; } @@ -238,7 +185,7 @@ private StartedOperation startOperation() { private StartedOperation startOperation(@javax.annotation.Nullable String inputOverride) { NexusClient client = createNexusClient(); - Endpoint endpoint = createEndpoint("test-endpoint-" + testWorkflowRule.getTaskQueue()); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); String inputValue = inputOverride != null ? inputOverride : "ping-handle-test-" + UUID.randomUUID(); @@ -254,45 +201,7 @@ private StartedOperation startOperation(@javax.annotation.Nullable String inputO Assert.assertNotNull("expected start to return a run ID", handle.getNexusOperationRunId()); return new StartedOperation( - client, endpoint, handle.getNexusOperationId(), handle.getNexusOperationRunId()); - } - - private void cleanup(StartedOperation started) { - deleteEndpoint(started.endpoint); - } - - private Endpoint createEndpoint(String name) { - EndpointSpec spec = - EndpointSpec.newBuilder() - .setName(name) - .setDescription( - Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) - .setTarget( - EndpointTarget.newBuilder() - .setWorker( - EndpointTarget.Worker.newBuilder() - .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) - .setTaskQueue(testWorkflowRule.getTaskQueue()))) - .build(); - CreateNexusEndpointResponse resp = - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); - return resp.getEndpoint(); - } - - private void deleteEndpoint(Endpoint endpoint) { - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .deleteNexusEndpoint( - DeleteNexusEndpointRequest.newBuilder() - .setId(endpoint.getId()) - .setVersion(endpoint.getVersion()) - .build()); + client, handle.getNexusOperationId(), handle.getNexusOperationRunId()); } public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { @@ -324,23 +233,19 @@ public OperationHandler operation() { @Test public void getResultPropagatesOperationFailure() { StartedOperation started = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); + UntypedNexusOperationHandle handle = + started.client.getHandle(started.operationId, started.runId); + try { - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - try { - handle.getResult(String.class); - Assert.fail("expected getResult to throw because the operation handler failed"); - } catch (RuntimeException e) { - // The DataConverter wraps the proto Failure into a Java exception. Either the message - // carries the handler's reason, or one of the cause links does. - String combined = collectMessages(e); - Assert.assertTrue( - "expected exception chain to mention the handler failure, got: " + combined, - combined.contains("intentional failure")); - } - } finally { - cleanup(started); + handle.getResult(String.class); + Assert.fail("expected getResult to throw because the operation handler failed"); + } catch (RuntimeException e) { + // The DataConverter wraps the proto Failure into a Java exception. Either the message + // carries the handler's reason, or one of the cause links does. + String combined = collectMessages(e); + Assert.assertTrue( + "expected exception chain to mention the handler failure, got: " + combined, + combined.contains("intentional failure")); } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index 1c3a0b558..798b5f702 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -1,16 +1,9 @@ package io.temporal.client.nexus; -import com.google.protobuf.ByteString; import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; -import io.temporal.api.common.v1.Payload; import io.temporal.api.nexus.v1.Endpoint; -import io.temporal.api.nexus.v1.EndpointSpec; -import io.temporal.api.nexus.v1.EndpointTarget; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; -import io.temporal.api.operatorservice.v1.CreateNexusEndpointResponse; -import io.temporal.api.operatorservice.v1.DeleteNexusEndpointRequest; import io.temporal.client.NexusClientOptions; import io.temporal.client.NexusOperationHandle; import io.temporal.client.NexusServiceClient; @@ -45,32 +38,24 @@ public class NexusServiceClientTest { @Test public void executeReturnsTypedResult() { - Endpoint endpoint = createEndpoint("svc-execute-" + testWorkflowRule.getTaskQueue()); - try { - NexusServiceClient client = buildServiceClient(endpoint); + NexusServiceClient client = + buildServiceClient(testWorkflowRule.getNexusEndpoint()); - String result = client.execute(TestNexusServices.TestNexusService1::operation, "hello"); + String result = client.execute(TestNexusServices.TestNexusService1::operation, "hello"); - Assert.assertEquals("echo:hello", result); - } finally { - deleteEndpoint(endpoint); - } + Assert.assertEquals("echo:hello", result); } @Test public void startReturnsTypedHandleAndPollsResult() { - Endpoint endpoint = createEndpoint("svc-start-" + testWorkflowRule.getTaskQueue()); - try { - NexusServiceClient client = buildServiceClient(endpoint); + NexusServiceClient client = + buildServiceClient(testWorkflowRule.getNexusEndpoint()); - NexusOperationHandle handle = - client.start(TestNexusServices.TestNexusService1::operation, "world"); + NexusOperationHandle handle = + client.start(TestNexusServices.TestNexusService1::operation, "world"); - Assert.assertNotNull(handle.getNexusOperationId()); - Assert.assertEquals("echo:world", handle.getResult()); - } finally { - deleteEndpoint(endpoint); - } + Assert.assertNotNull(handle.getNexusOperationId()); + Assert.assertEquals("echo:world", handle.getResult()); } @Test @@ -174,40 +159,6 @@ private NexusServiceClient buildServiceClie .build()); } - private Endpoint createEndpoint(String name) { - EndpointSpec spec = - EndpointSpec.newBuilder() - .setName(name) - .setDescription( - Payload.newBuilder().setData(ByteString.copyFromUtf8("test endpoint")).build()) - .setTarget( - EndpointTarget.newBuilder() - .setWorker( - EndpointTarget.Worker.newBuilder() - .setNamespace(testWorkflowRule.getTestEnvironment().getNamespace()) - .setTaskQueue(testWorkflowRule.getTaskQueue()))) - .build(); - CreateNexusEndpointResponse resp = - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .createNexusEndpoint(CreateNexusEndpointRequest.newBuilder().setSpec(spec).build()); - return resp.getEndpoint(); - } - - private void deleteEndpoint(Endpoint endpoint) { - testWorkflowRule - .getTestEnvironment() - .getOperatorServiceStubs() - .blockingStub() - .deleteNexusEndpoint( - DeleteNexusEndpointRequest.newBuilder() - .setId(endpoint.getId()) - .setVersion(endpoint.getVersion()) - .build()); - } - public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { @Override public String execute(String input) { From 67e64292dbc712fb0137e20a8be86a9c9dd050c4 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 19 May 2026 16:55:11 -0700 Subject: [PATCH 05/33] Moving common test code to SDKTestWorkflowRule --- .../temporal/client/nexus/NexusClientTest.java | 16 +++------------- .../client/nexus/NexusOperationHandleTest.java | 12 +----------- .../testing/internal/SDKTestWorkflowRule.java | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 216e0c036..053f73207 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -5,8 +5,6 @@ import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; -import io.temporal.client.NexusClientImpl; -import io.temporal.client.NexusClientOptions; import io.temporal.client.NexusOperationExecutionCount; import io.temporal.client.NexusOperationExecutionMetadata; import io.temporal.client.StartNexusOperationOptions; @@ -31,17 +29,9 @@ public class NexusClientTest { .setNexusServiceImplementation(new TestNexusServiceImpl()) .build(); - private NexusClient createNexusClient() { - return NexusClientImpl.newInstance( - testWorkflowRule.getWorkflowServiceStubs(), - NexusClientOptions.newBuilder() - .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) - .build()); - } - @Test public void listNexusOperationExecutions() { - NexusClient client = createNexusClient(); + NexusClient client = testWorkflowRule.getNexusClient(); // Materialize the lazy stream to force at least one page fetch and ensure no exceptions. long visited = client.listNexusOperationExecutions(null).count(); @@ -56,7 +46,7 @@ public void countNexusOperationExecutions() { } public long countNexusOperations() { - NexusClient client = createNexusClient(); + NexusClient client = testWorkflowRule.getNexusClient(); NexusOperationExecutionCount output = client.countNexusOperationExecutions(null); @@ -74,7 +64,7 @@ public void runStandaloneNexusOperation() throws Exception { Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); String inputValue = "ping-" + UUID.randomUUID(); - NexusClient client = createNexusClient(); + NexusClient client = testWorkflowRule.getNexusClient(); UntypedNexusServiceClient svcClient = client.newUntypedNexusServiceClient( diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index ec4b93725..e4d1bd99d 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -6,9 +6,7 @@ import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; -import io.temporal.client.NexusClientImpl; import io.temporal.client.NexusClientOperationExecutionDescription; -import io.temporal.client.NexusClientOptions; import io.temporal.client.NexusOperationHandle; import io.temporal.client.StartNexusOperationOptions; import io.temporal.client.UntypedNexusOperationHandle; @@ -38,14 +36,6 @@ public class NexusOperationHandleTest { .setTestTimeoutSeconds(120) .build(); - private NexusClient createNexusClient() { - return NexusClientImpl.newInstance( - testWorkflowRule.getWorkflowServiceStubs(), - NexusClientOptions.newBuilder() - .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) - .build()); - } - @Test public void describeReturnsDescriptionForStartedOperation() { StartedOperation started = startOperation(); @@ -184,7 +174,7 @@ private StartedOperation startOperation() { } private StartedOperation startOperation(@javax.annotation.Nullable String inputOverride) { - NexusClient client = createNexusClient(); + NexusClient client = testWorkflowRule.getNexusClient(); Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); String inputValue = inputOverride != null ? inputOverride : "ping-handle-test-" + UUID.randomUUID(); diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java index e88a7ec3d..a1de5e0d3 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java @@ -13,6 +13,8 @@ import io.temporal.api.history.v1.History; import io.temporal.api.history.v1.HistoryEvent; import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowQueryException; @@ -260,6 +262,18 @@ public Endpoint getNexusEndpoint() { return testWorkflowRule.getNexusEndpoint(); } + /** + * Returns a {@link NexusClient} bound to this rule's namespace and service stubs. Use for tests + * that exercise the standalone Nexus client surface. + */ + public NexusClient getNexusClient() { + return NexusClient.newInstance( + getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(getWorkflowClient().getOptions().getNamespace()) + .build()); + } + public Worker getWorker() { return testWorkflowRule.getWorker(); } From 9094bdc2d721a877680194b21d796f9ca3c04c89 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 19 May 2026 17:06:39 -0700 Subject: [PATCH 06/33] Ensuring we are using an external server, as the internal one doesn't support SANO yet --- .../client/nexus/NexusClientInterceptorChainTest.java | 11 +++++++++++ .../io/temporal/client/nexus/NexusClientTest.java | 11 +++++++++++ .../client/nexus/NexusOperationHandleTest.java | 11 +++++++++++ .../temporal/client/nexus/NexusServiceClientTest.java | 11 +++++++++++ 4 files changed, 44 insertions(+) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java index 11f95fae6..fc61e0b56 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -1,5 +1,7 @@ package io.temporal.client.nexus; +import static org.junit.Assume.assumeTrue; + import io.temporal.client.NexusClient; import io.temporal.client.NexusClientImpl; import io.temporal.client.NexusClientOptions; @@ -14,6 +16,7 @@ import java.util.Collections; import java.util.List; import org.junit.Assert; +import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -31,6 +34,14 @@ public class NexusClientInterceptorChainTest { .setTestTimeoutSeconds(60) .build(); + @BeforeClass + public static void requireExternalService() { + // The time-skipping test server does not implement standalone Nexus operation RPCs. + assumeTrue( + "standalone Nexus operations require a real server", + SDKTestWorkflowRule.useExternalService); + } + @Test public void registeredInterceptorsAreCalledInOrder() { List calls = Collections.synchronizedList(new ArrayList<>()); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 053f73207..4cda00056 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -1,5 +1,7 @@ package io.temporal.client.nexus; +import static org.junit.Assume.assumeTrue; + import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; @@ -17,6 +19,7 @@ import java.util.UUID; import java.util.concurrent.TimeUnit; import org.junit.Assert; +import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -29,6 +32,14 @@ public class NexusClientTest { .setNexusServiceImplementation(new TestNexusServiceImpl()) .build(); + @BeforeClass + public static void requireExternalService() { + // The time-skipping test server does not implement standalone Nexus operation RPCs. + assumeTrue( + "standalone Nexus operations require a real server", + SDKTestWorkflowRule.useExternalService); + } + @Test public void listNexusOperationExecutions() { NexusClient client = testWorkflowRule.getNexusClient(); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index e4d1bd99d..c8e2d3080 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -1,5 +1,7 @@ package io.temporal.client.nexus; +import static org.junit.Assume.assumeTrue; + import io.nexusrpc.OperationException; import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; @@ -17,6 +19,7 @@ import java.time.Duration; import java.util.UUID; import org.junit.Assert; +import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -36,6 +39,14 @@ public class NexusOperationHandleTest { .setTestTimeoutSeconds(120) .build(); + @BeforeClass + public static void requireExternalService() { + // The time-skipping test server does not implement standalone Nexus operation RPCs. + assumeTrue( + "standalone Nexus operations require a real server", + SDKTestWorkflowRule.useExternalService); + } + @Test public void describeReturnsDescriptionForStartedOperation() { StartedOperation started = startOperation(); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index 798b5f702..309d8945c 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -1,5 +1,7 @@ package io.temporal.client.nexus; +import static org.junit.Assume.assumeTrue; + import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; @@ -19,6 +21,7 @@ import java.util.Collections; import java.util.concurrent.atomic.AtomicReference; import org.junit.Assert; +import org.junit.BeforeClass; import org.junit.Rule; import org.junit.Test; @@ -36,6 +39,14 @@ public class NexusServiceClientTest { .setTestTimeoutSeconds(120) .build(); + @BeforeClass + public static void requireExternalService() { + // The time-skipping test server does not implement standalone Nexus operation RPCs. + assumeTrue( + "standalone Nexus operations require a real server", + SDKTestWorkflowRule.useExternalService); + } + @Test public void executeReturnsTypedResult() { NexusServiceClient client = From 6c5246662bd8114f58a4f2066f1ea5811164d404 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 26 May 2026 17:33:28 -0700 Subject: [PATCH 07/33] PR changes for comments --- .../client/NexusOperationHandleImpl.java | 9 +--- .../NexusClientCallsInterceptor.java | 12 +---- .../NexusClientCallsInterceptorBase.java | 6 --- .../client/RootNexusClientInvoker.java | 14 +----- .../external/GenericWorkflowClient.java | 11 +---- .../external/GenericWorkflowClientImpl.java | 46 +------------------ 6 files changed, 6 insertions(+), 92 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java index c74cd2c41..9d74d4a19 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java @@ -26,9 +26,6 @@ */ public class NexusOperationHandleImpl implements NexusOperationHandle { - /** Default deadline applied to per-handle non-poll RPCs (e.g. {@code describe}). */ - private static final long DEFAULT_DEADLINE_SECONDS = 30; - /** * Per-poll deadline used by {@link #getResult} and {@link #getResultAsync}. The server holds the * request up to this long waiting for completion; if the operation hasn't finished, we re-poll. @@ -110,11 +107,7 @@ public String getNexusOperationId() { public NexusClientOperationExecutionDescription describe() { DescribeNexusOperationExecutionInput input = new DescribeNexusOperationExecutionInput( - operationId, - runId, - /* includeInput= */ false, - /* includeOutcome= */ true, - Deadline.after(DEFAULT_DEADLINE_SECONDS, TimeUnit.SECONDS)); + operationId, runId, /* includeInput= */ false, /* includeOutcome= */ true); DescribeNexusOperationExecutionOutput output = interceptor.describeNexusOperationExecution(input); return output.getDescription(); diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 2dcc31099..2546f36ec 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -36,9 +36,6 @@ StartNexusOperationExecutionOutput startNexusOperationExecution( DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionInput input); - CompletableFuture describeNexusOperationExecutionAsync( - DescribeNexusOperationExecutionInput input); - PollNexusOperationExecutionOutput pollNexusOperationExecution( PollNexusOperationExecutionInput input); @@ -127,19 +124,16 @@ final class DescribeNexusOperationExecutionInput { private final @Nullable String runId; private final boolean includeInput; private final boolean includeOutcome; - private final @Nonnull Deadline deadline; public DescribeNexusOperationExecutionInput( String operationId, @Nullable String runId, boolean includeInput, - boolean includeOutcome, - @Nonnull Deadline deadline) { + boolean includeOutcome) { this.operationId = operationId; this.runId = runId; this.includeInput = includeInput; this.includeOutcome = includeOutcome; - this.deadline = deadline; } public String getOperationId() { @@ -157,10 +151,6 @@ public boolean isIncludeInput() { public boolean isIncludeOutcome() { return includeOutcome; } - - public Deadline getDeadline() { - return deadline; - } } final class DescribeNexusOperationExecutionOutput { diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java index a2b13794f..4bd34c6a3 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java @@ -28,12 +28,6 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( return next.describeNexusOperationExecution(input); } - @Override - public CompletableFuture - describeNexusOperationExecutionAsync(DescribeNexusOperationExecutionInput input) { - return next.describeNexusOperationExecutionAsync(input); - } - @Override public PollNexusOperationExecutionOutput pollNexusOperationExecution( PollNexusOperationExecutionInput input) { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index addb378ad..29a56bac2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -101,23 +101,11 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionInput input) { DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input); DescribeNexusOperationExecutionResponse response = - genericClient.describeNexusOperationExecution(request, input.getDeadline()); + genericClient.describeNexusOperationExecution(request); return new DescribeNexusOperationExecutionOutput( new NexusClientOperationExecutionDescription(response)); } - @Override - public CompletableFuture - describeNexusOperationExecutionAsync(DescribeNexusOperationExecutionInput input) { - DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input); - return genericClient - .describeNexusOperationExecutionAsync(request, input.getDeadline()) - .thenApply( - response -> - new DescribeNexusOperationExecutionOutput( - new NexusClientOperationExecutionDescription(response))); - } - private DescribeNexusOperationExecutionRequest buildDescribeRequest( DescribeNexusOperationExecutionInput input) { DescribeNexusOperationExecutionRequest.Builder request = diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java index dabf5a8e7..648955a1c 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClient.java @@ -65,10 +65,7 @@ StartNexusOperationExecutionResponse startNexusOperationExecution( @Nonnull StartNexusOperationExecutionRequest request); DescribeNexusOperationExecutionResponse describeNexusOperationExecution( - @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline); - - CompletableFuture describeNexusOperationExecutionAsync( - @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + @Nonnull DescribeNexusOperationExecutionRequest request); PollNexusOperationExecutionResponse pollNexusOperationExecution( @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline); @@ -105,9 +102,6 @@ ExecuteMultiOperationResponse executeMultiOperation( @Experimental StartActivityExecutionResponse startActivity(StartActivityExecutionRequest request); - @Experimental - PollActivityExecutionResponse pollActivity(PollActivityExecutionRequest request); - @Experimental PollActivityExecutionResponse pollActivity( PollActivityExecutionRequest request, @Nonnull Deadline deadline); @@ -125,9 +119,6 @@ CompletableFuture pollActivityAsync( @Experimental void terminateActivity(TerminateActivityExecutionRequest request); - @Experimental - ListActivityExecutionsResponse listActivities(ListActivityExecutionsRequest request); - @Experimental CompletableFuture listActivitiesAsync( ListActivityExecutionsRequest request); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java index f115cc2b5..23f6abd98 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java @@ -324,33 +324,14 @@ public StartNexusOperationExecutionResponse startNexusOperationExecution( @Override public DescribeNexusOperationExecutionResponse describeNexusOperationExecution( - @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline) { + @Nonnull DescribeNexusOperationExecutionRequest request) { return grpcRetryer.retryWithResult( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) - .withDeadline(deadline) .describeNexusOperationExecution(request), - new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline)); - } - - @Override - public CompletableFuture - describeNexusOperationExecutionAsync( - @Nonnull DescribeNexusOperationExecutionRequest request, @Nonnull Deadline deadline) { - return grpcRetryer.retryWithResultAsync( - asyncThrottlerExecutor, - () -> - toCompletableFuture( - service - .futureStub() - .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) - .withDeadline(deadline) - .describeNexusOperationExecution(request)), - new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, deadline)); + grpcRetryerOptions); } @Override @@ -575,18 +556,6 @@ public StartActivityExecutionResponse startActivity(StartActivityExecutionReques grpcRetryerOptions); } - @Override - public PollActivityExecutionResponse pollActivity(PollActivityExecutionRequest request) { - return grpcRetryer.retryWithResult( - () -> - service - .blockingStub() - .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .withOption(HISTORY_LONG_POLL_CALL_OPTIONS_KEY, true) - .pollActivityExecution(request), - new GrpcRetryer.GrpcRetryerOptions(DefaultStubLongPollRpcRetryOptions.INSTANCE, null)); - } - @Override public PollActivityExecutionResponse pollActivity( PollActivityExecutionRequest request, @Nonnull Deadline deadline) { @@ -651,17 +620,6 @@ public void terminateActivity(TerminateActivityExecutionRequest request) { grpcRetryerOptions); } - @Override - public ListActivityExecutionsResponse listActivities(ListActivityExecutionsRequest request) { - return grpcRetryer.retryWithResult( - () -> - service - .blockingStub() - .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .listActivityExecutions(request), - grpcRetryerOptions); - } - @Override public CompletableFuture listActivitiesAsync( ListActivityExecutionsRequest request) { From 399a1117d4b79c3d61901e4293b3689658b1ec3a Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 26 May 2026 17:53:26 -0700 Subject: [PATCH 08/33] Changing deadline behavior --- .../client/NexusOperationHandleImpl.java | 81 +++++-------------- .../NexusClientCallsInterceptor.java | 5 +- 2 files changed, 19 insertions(+), 67 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java index 9d74d4a19..c2e7b1974 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java @@ -26,12 +26,6 @@ */ public class NexusOperationHandleImpl implements NexusOperationHandle { - /** - * Per-poll deadline used by {@link #getResult} and {@link #getResultAsync}. The server holds the - * request up to this long waiting for completion; if the operation hasn't finished, we re-poll. - */ - private static final long POLL_DEADLINE_SECONDS = 60; - final NexusClientCallsInterceptor interceptor; final String operationId; final @Nullable String runId; @@ -142,8 +136,11 @@ public X getResult(Class resultClass) { @Override public X getResult(Class resultClass, @Nullable Type resultType) { - PollNexusOperationExecutionOutput out = pollUntilCompleted(); - return extractResult(out, resultClass, resultType); + try { + return getResult(Integer.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); + } catch (TimeoutException e) { + throw new RuntimeException(e); + } } @Override @@ -153,7 +150,7 @@ public CompletableFuture getResultAsync(Class resultClass) { @Override public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { - return pollAsyncUntilCompleted().thenApply(out -> extractResult(out, resultClass, resultType)); + return getResultAsync(Long.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); } @Override @@ -166,8 +163,8 @@ public X getResult(long timeout, TimeUnit unit, Class resultClass) public X getResult( long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) throws TimeoutException { - long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); - PollNexusOperationExecutionOutput out = pollSyncUntilCompletedOrDeadline(deadlineNanos); + PollNexusOperationExecutionOutput out = + pollSyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)); return extractResult(out, resultClass, resultType); } @@ -180,8 +177,7 @@ public CompletableFuture getResultAsync( @Override public CompletableFuture getResultAsync( long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { - long deadlineNanos = System.nanoTime() + unit.toNanos(timeout); - return pollAsyncUntilCompletedOrDeadline(deadlineNanos) + return pollAsyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)) .thenApply(out -> extractResult(out, resultClass, resultType)); } @@ -221,51 +217,21 @@ public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { return getResultAsync(timeout, unit, resultClass, resultType); } - /** Long-poll loop: re-poll if the server returns before the operation completes. */ - private PollNexusOperationExecutionOutput pollUntilCompleted() { - while (true) { - PollNexusOperationExecutionOutput out = - interceptor.pollNexusOperationExecution(buildPollInput()); - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return out; - } - } - } - - /** Async long-poll loop using {@code thenCompose} to recurse without blocking a thread. */ - private CompletableFuture pollAsyncUntilCompleted() { - return interceptor - .pollNexusOperationExecutionAsync(buildPollInput()) - .thenCompose( - out -> { - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return CompletableFuture.completedFuture(out); - } - return pollAsyncUntilCompleted(); - }); - } - - /** Sync poll loop bounded by an absolute nanos deadline. */ - private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(long deadlineNanos) + /** Sync long-poll loop bounded by {@code deadline}. */ + private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadline deadline) throws TimeoutException { while (true) { - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { - throw new TimeoutException("getResult timed out before the operation completed"); - } - long pollDeadlineNanos = - Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); PollNexusOperationExecutionInput pollInput = new PollNexusOperationExecutionInput( operationId, runId, NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); + deadline); PollNexusOperationExecutionOutput out; try { out = interceptor.pollNexusOperationExecution(pollInput); } catch (RuntimeException e) { - if (System.nanoTime() >= deadlineNanos) { + if (deadline.isExpired()) { TimeoutException timeout = new TimeoutException("getResult timed out before the operation completed"); timeout.initCause(e); @@ -279,24 +245,21 @@ private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(long } } - /** Async poll loop bounded by an absolute nanos deadline. */ + /** Async long-poll loop bounded by {@code deadline}, recursing via {@code thenCompose}. */ private CompletableFuture pollAsyncUntilCompletedOrDeadline( - long deadlineNanos) { - long remainingNanos = deadlineNanos - System.nanoTime(); - if (remainingNanos <= 0) { + Deadline deadline) { + if (deadline.isExpired()) { CompletableFuture failed = new CompletableFuture<>(); failed.completeExceptionally( new TimeoutException("getResultAsync timed out before the operation completed")); return failed; } - long pollDeadlineNanos = - Math.min(remainingNanos, TimeUnit.SECONDS.toNanos(POLL_DEADLINE_SECONDS)); PollNexusOperationExecutionInput pollInput = new PollNexusOperationExecutionInput( operationId, runId, NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - Deadline.after(pollDeadlineNanos, TimeUnit.NANOSECONDS)); + deadline); return interceptor .pollNexusOperationExecutionAsync(pollInput) .thenCompose( @@ -304,18 +267,10 @@ private CompletableFuture pollAsyncUntilCompl if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { return CompletableFuture.completedFuture(out); } - return pollAsyncUntilCompletedOrDeadline(deadlineNanos); + return pollAsyncUntilCompletedOrDeadline(deadline); }); } - private PollNexusOperationExecutionInput buildPollInput() { - return new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - Deadline.after(POLL_DEADLINE_SECONDS, TimeUnit.SECONDS)); - } - /** * Convert a completed poll response into the typed result, throwing the operation's failure as an * exception if it failed. diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 2546f36ec..125abf677 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -126,10 +126,7 @@ final class DescribeNexusOperationExecutionInput { private final boolean includeOutcome; public DescribeNexusOperationExecutionInput( - String operationId, - @Nullable String runId, - boolean includeInput, - boolean includeOutcome) { + String operationId, @Nullable String runId, boolean includeInput, boolean includeOutcome) { this.operationId = operationId; this.runId = runId; this.includeInput = includeInput; From 151b86effbdfdd13469746330f876eb2c87c9be4 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Wed, 27 May 2026 12:51:57 -0700 Subject: [PATCH 09/33] Addressing PR comments --- .../io/temporal/client/ActivityHandle.java | 1 + ...usClientOperationExecutionDescription.java | 247 +++++++++++++++++- .../NexusOperationCancellationInfo.java | 98 +++++++ .../temporal/client/NexusOperationHandle.java | 65 ++++- .../temporal/client/NexusServiceClient.java | 72 ++++- .../client/StartNexusOperationOptions.java | 20 -- .../client/UntypedNexusOperationHandle.java | 108 +++++++- .../client/UntypedNexusServiceClient.java | 43 ++- .../client/UntypedNexusServiceClientImpl.java | 4 +- .../NexusClientCallsInterceptor.java | 79 +++++- .../client/RootNexusClientInvoker.java | 8 +- 11 files changed, 681 insertions(+), 64 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java index b6f97febe..2a8f57fbc 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java @@ -30,6 +30,7 @@ public interface ActivityHandle extends UntypedActivityHandle { /** * Blocks until the standalone activity completes and returns the typed result, or throws if the + * * client-side timeout expires first. * * @param timeout maximum time to wait diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java index fa1ec0ca4..7936cbe80 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java @@ -1,26 +1,261 @@ package io.temporal.client; +import io.temporal.api.enums.v1.PendingNexusOperationState; +import io.temporal.api.nexus.v1.NexusOperationExecutionInfo; import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse; import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.SearchAttributesUtil; +import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; -/** Snapshot of a standalone Nexus operation execution returned by describe/poll calls. */ +/** + * Detailed information about a standalone Nexus operation execution, returned by {@link + * UntypedNexusOperationHandle#describe()}. + */ @Experimental -public final class NexusClientOperationExecutionDescription { +public final class NexusClientOperationExecutionDescription extends NexusOperationExecutionMetadata { private final DescribeNexusOperationExecutionResponse response; + private final NexusOperationExecutionInfo info; + private final DataConverter dataConverter; public NexusClientOperationExecutionDescription( - DescribeNexusOperationExecutionResponse response) { + DescribeNexusOperationExecutionResponse response, + DataConverter dataConverter, + String namespace) { + super( + /* rawListInfo= */ null, + response.getInfo().getOperationId(), + nullIfEmpty(response.getInfo().getRunId()), + response.getInfo().getEndpoint(), + response.getInfo().getService(), + response.getInfo().getOperation(), + response.getInfo().hasScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime()) + : Instant.EPOCH, + response.getInfo().hasCloseTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime()) + : null, + response.getInfo().getStatus(), + SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes()), + response.getInfo().getStateTransitionCount(), + response.getInfo().hasExecutionDuration() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getExecutionDuration()) + : null); this.response = response; + this.info = response.getInfo(); + this.dataConverter = dataConverter; } - /** Run ID of the operation described. */ - public String getRunId() { - return response.getRunId(); + private static @Nullable String nullIfEmpty(String s) { + return s == null || s.isEmpty() ? null : s; } /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ + @Nonnull public DescribeNexusOperationExecutionResponse getRawResponse() { return response; } + + /** The raw protobuf info returned by the server for this operation execution. */ + @Nonnull + public NexusOperationExecutionInfo getRawInfo() { + return info; + } + + /** Current attempt number for the start request (starts at 1). */ + public int getAttempt() { + return info.getAttempt(); + } + + /** + * Detailed run state (e.g. scheduled, started, backing off). Only meaningful when {@link + * #getStatus()} is {@code NEXUS_OPERATION_EXECUTION_STATUS_RUNNING}. + */ + @Nonnull + public PendingNexusOperationState getRunState() { + return info.getState(); + } + + /** Total time the caller is willing to wait for the operation to complete, including retries. */ + @Nullable + public Duration getScheduleToCloseTimeout() { + return info.hasScheduleToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToCloseTimeout()) + : null; + } + + /** Maximum time the start request may wait before being delivered to the handler. */ + @Nullable + public Duration getScheduleToStartTimeout() { + return info.hasScheduleToStartTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToStartTimeout()) + : null; + } + + /** Maximum time for a single start-request attempt. */ + @Nullable + public Duration getStartToCloseTimeout() { + return info.hasStartToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getStartToCloseTimeout()) + : null; + } + + /** Scheduled time plus schedule-to-close timeout. */ + @Nullable + public Instant getExpirationTime() { + return info.hasExpirationTime() + ? ProtobufTimeUtils.toJavaInstant(info.getExpirationTime()) + : null; + } + + /** Time the last start-request attempt completed (succeeded or failed). */ + @Nullable + public Instant getLastAttemptCompleteTime() { + return info.hasLastAttemptCompleteTime() + ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) + : null; + } + + /** Failure from the last start-request attempt. {@code null} if no failure has occurred. */ + @Nullable + public Exception getLastAttemptFailure() { + return info.hasLastAttemptFailure() + ? dataConverter.failureToException(info.getLastAttemptFailure()) + : null; + } + + /** Time when the next start-request attempt will be scheduled. */ + @Nullable + public Instant getNextAttemptScheduleTime() { + return info.hasNextAttemptScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) + : null; + } + + /** Cancellation details if cancellation was requested; {@code null} otherwise. */ + @Nullable + public NexusOperationCancellationInfo getCancellationInfo() { + return info.hasCancellationInfo() + ? new NexusOperationCancellationInfo(info.getCancellationInfo(), dataConverter) + : null; + } + + /** + * Additional context for why the operation is blocked. Set only when {@link #getRunState()} is + * {@code BLOCKED}. + */ + @Nullable + public String getBlockedReason() { + String r = info.getBlockedReason(); + return r.isEmpty() ? null : r; + } + + /** + * Server-generated request ID used as an idempotency token when submitting the start request to + * the operation handler. + */ + @Nullable + public String getHandlerRequestId() { + String r = info.getRequestId(); + return r.isEmpty() ? null : r; + } + + /** Operation token returned by the handler; set only for asynchronous operations after start. */ + @Nullable + public String getOperationToken() { + String t = info.getOperationToken(); + return t.isEmpty() ? null : t; + } + + /** Identity of the client that started this operation. */ + @Nullable + public String getIdentity() { + String i = info.getIdentity(); + return i.isEmpty() ? null : i; + } + + /** + * Whether the operation input payload is present on this description. Set only when {@link + * UntypedNexusOperationHandle#describe()} was called with {@code includeInput=true}. + */ + public boolean hasInput() { + return response.hasInput(); + } + + /** + * Deserializes the operation input into the given type. Returns {@link Optional#empty()} if no + * input is present (either the operation was started without one or {@code includeInput} was + * false on the describe call). + * + * @param valueType the class to deserialize the input into + */ + public Optional getInput(Class valueType) { + return getInput(valueType, valueType); + } + + /** + * Deserializes the operation input into the given generic type. Returns {@link Optional#empty()} + * if no input is present. + * + * @param valueType the class to deserialize the input into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getInput(Class valueType, Type genericType) { + if (!response.hasInput()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayload(response.getInput(), valueType, genericType)); + } + + /** + * Whether the operation's success result is present. Set only when {@link + * UntypedNexusOperationHandle#describe()} was called with {@code includeOutcome=true} and the + * operation completed successfully. + */ + public boolean hasResult() { + return response.hasResult(); + } + + /** + * Deserializes the operation's success result. Returns {@link Optional#empty()} if no result is + * present (operation still running, completed with a failure, or {@code includeOutcome} was + * false). + * + * @param valueType the class to deserialize the result into + */ + public Optional getResult(Class valueType) { + return getResult(valueType, valueType); + } + + /** + * Deserializes the operation's success result into the given generic type. Returns {@link + * Optional#empty()} if no result is present. + * + * @param valueType the class to deserialize the result into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getResult(Class valueType, Type genericType) { + if (!response.hasResult()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayload(response.getResult(), valueType, genericType)); + } + + /** + * Operation failure as a thrown-style exception. Returns {@code null} if the operation did not + * complete with a failure or if {@code includeOutcome} was false on the describe call. + */ + @Nullable + public Exception getFailure() { + return response.hasFailure() ? dataConverter.failureToException(response.getFailure()) : null; + } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java new file mode 100644 index 000000000..293e2b396 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java @@ -0,0 +1,98 @@ +package io.temporal.client; + +import io.temporal.api.enums.v1.NexusOperationCancellationState; +import io.temporal.api.nexus.v1.NexusOperationExecutionCancellationInfo; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.internal.common.ProtobufTimeUtils; +import java.time.Instant; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Information about a cancellation request issued against a standalone Nexus operation execution. + * Returned by {@link NexusClientOperationExecutionDescription#getCancellationInfo()}. + */ +@Experimental +public final class NexusOperationCancellationInfo { + + private final NexusOperationExecutionCancellationInfo info; + private final DataConverter dataConverter; + + NexusOperationCancellationInfo( + NexusOperationExecutionCancellationInfo info, DataConverter dataConverter) { + this.info = info; + this.dataConverter = dataConverter; + } + + /** The raw protobuf info returned by the server. */ + @Nonnull + public NexusOperationExecutionCancellationInfo getRawInfo() { + return info; + } + + /** Time when cancellation was originally requested. */ + @Nullable + public Instant getRequestedTime() { + return info.hasRequestedTime() + ? ProtobufTimeUtils.toJavaInstant(info.getRequestedTime()) + : null; + } + + /** Current state of cancellation-request delivery to the operation handler. */ + @Nonnull + public NexusOperationCancellationState getState() { + return info.getState(); + } + + /** + * Current attempt number for delivering the cancel request to the handler. Represents a minimum + * bound — the value is incremented after the attempt completes. + */ + public int getAttempt() { + return info.getAttempt(); + } + + /** Time the last cancel-delivery attempt completed. */ + @Nullable + public Instant getLastAttemptCompleteTime() { + return info.hasLastAttemptCompleteTime() + ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) + : null; + } + + /** + * Failure from the last cancel-delivery attempt. {@code null} if no failure has occurred yet. + */ + @Nullable + public Exception getLastAttemptFailure() { + return info.hasLastAttemptFailure() + ? dataConverter.failureToException(info.getLastAttemptFailure()) + : null; + } + + /** Time when the next cancel-delivery attempt is scheduled. */ + @Nullable + public Instant getNextAttemptScheduleTime() { + return info.hasNextAttemptScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) + : null; + } + + /** + * Additional context for why cancel delivery is blocked. Set only when {@link #getState()} + * indicates a blocked state. + */ + @Nullable + public String getBlockedReason() { + String r = info.getBlockedReason(); + return r.isEmpty() ? null : r; + } + + /** The human-readable reason supplied with the original cancel request, if any. */ + @Nullable + public String getReason() { + String r = info.getReason(); + return r.isEmpty() ? null : r; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java index 65788e53e..c53093407 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java @@ -1,45 +1,82 @@ package io.temporal.client; +import io.temporal.common.Experimental; import java.lang.reflect.Type; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; /** - * Typed handle for interacting with an existing standalone Nexus operation execution. Add a result - * type binding to an {@link UntypedNexusOperationHandle} (returned by {@link - * NexusClient#getHandle(String)}) by calling one of the {@link #fromUntyped} factories. + * A typed handle to a standalone Nexus operation execution. Extends {@link + * UntypedNexusOperationHandle} with typed result methods bound to a known result type. + * + *

Obtain an instance via {@link NexusServiceClient} or by wrapping an {@link + * UntypedNexusOperationHandle} (returned by {@link NexusClient#getHandle(String)}) with {@link + * #fromUntyped(UntypedNexusOperationHandle, Class)}. + * + * @param the result type of the Nexus operation + * @see UntypedNexusOperationHandle + * @see NexusServiceClient + * @see NexusClient */ +@Experimental public interface NexusOperationHandle extends UntypedNexusOperationHandle { - /** Wrap an {@link UntypedNexusOperationHandle} as a typed handle bound to {@code resultClass}. */ + /** + * Wraps an {@link UntypedNexusOperationHandle} with a known result type. + * + * @param handle the untyped handle to wrap + * @param resultClass the class to deserialize the result into + * @return a typed handle + */ static NexusOperationHandle fromUntyped( UntypedNexusOperationHandle handle, Class resultClass) { return fromUntyped(handle, resultClass, null); } /** - * Wrap an {@link UntypedNexusOperationHandle} as a typed handle bound to {@code resultClass} and - * {@code resultType}. Pass a non-null {@code resultType} when the result is a generic type whose - * parameters cannot be captured by {@link Class} alone (e.g. {@code List}). + * Wraps an {@link UntypedNexusOperationHandle} with a known result type for generic types. Pass a + * non-null {@code resultType} when the result is a generic type whose parameters cannot be + * captured by {@link Class} alone (e.g. {@code List}). + * + * @param handle the untyped handle to wrap + * @param resultClass the class to deserialize the result into + * @param resultType the generic type; may be {@code null} + * @return a typed handle */ static NexusOperationHandle fromUntyped( UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) { return NexusOperationHandleImpl.fromUntyped(handle, resultClass, resultType); } - /** Block until the operation completes and return the typed result. */ + /** + * Blocks until the Nexus operation completes and returns the typed result. + * + * @throws RuntimeException if the operation failed, timed out, or was cancelled + */ R getResult(); - /** Block up to {@code timeout} for the operation to complete and return the typed result. */ - R getResult(long timeout, java.util.concurrent.TimeUnit unit) - throws java.util.concurrent.TimeoutException; + /** + * Blocks until the Nexus operation completes and returns the typed result, or throws if the + * client-side timeout expires first. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @throws RuntimeException if the operation failed, timed out on the server, or was cancelled + * @throws TimeoutException if {@code timeout} expires before the operation completes + */ + R getResult(long timeout, TimeUnit unit) throws TimeoutException; - /** Returns a future that completes with the typed result when the operation finishes. */ + /** Returns a future that completes when the Nexus operation completes with the typed result. */ CompletableFuture getResultAsync(); /** * Returns a future that completes with the typed result, or completes exceptionally with a {@link - * java.util.concurrent.TimeoutException} if {@code timeout} elapses first. + * TimeoutException} if {@code timeout} elapses before the operation completes. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} */ - CompletableFuture getResultAsync(long timeout, java.util.concurrent.TimeUnit unit); + CompletableFuture getResultAsync(long timeout, TimeUnit unit); } diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java index e277a4b49..600761855 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -9,55 +9,113 @@ * Typed client for invoking standalone Nexus operations on a specific service interface {@code T}. * *

Operations are dispatched via method references (or {@link BiFunction} lambdas) that target - * methods on {@code T}; the client extracts the operation name from the invocation and delegates to - * {@link NexusClient}. For visibility queries (list/count) across operations, use {@link + * methods on {@code T}; the client extracts the operation name from the invocation and delegates + * to {@link NexusClient}. For visibility queries (list/count) across operations, use {@link * NexusClient} directly. + * + * @param the Nexus service interface this client is bound to + * @see NexusClient + * @see UntypedNexusServiceClient */ @Experimental public interface NexusServiceClient extends UntypedNexusServiceClient { + /** + * Creates a client bound to {@code service} that dispatches calls via {@code endpoint} using + * default client options. + * + * @param service the Nexus service interface class + * @param endpoint the Nexus endpoint name as configured on the server + * @param stubs gRPC stubs for talking to the Temporal service + * @return a new typed client + */ static NexusServiceClient newInstance( Class service, String endpoint, WorkflowServiceStubs stubs) { return newInstance(service, endpoint, stubs, NexusClientOptions.getDefaultInstance()); } + /** + * Creates a client bound to {@code service} that dispatches calls via {@code endpoint} using the + * supplied client options. + * + * @param service the Nexus service interface class + * @param endpoint the Nexus endpoint name as configured on the server + * @param stubs gRPC stubs for talking to the Temporal service + * @param options client-wide options (namespace, identity, interceptors, etc.) + * @return a new typed client + */ static NexusServiceClient newInstance( Class service, String endpoint, WorkflowServiceStubs stubs, NexusClientOptions options) { return NexusServiceClientImpl.newInstance(service, endpoint, stubs, options); } /** - * Execute an operation synchronously. Equivalent to {@link #start(BiFunction, Object)} followed + * Executes an operation synchronously. Equivalent to {@link #start(BiFunction, Object)} followed * by {@link NexusOperationHandle#getResult()}. + * + * @param operation a method reference on {@code T} identifying the operation + * @param input the operation input + * @return the operation result + * @throws RuntimeException if the operation failed, timed out, or was cancelled */ default R execute(BiFunction operation, U input) { return start(operation, input).getResult(); } - /** Execute an operation synchronously with per-call options. */ + /** + * Executes an operation synchronously with per-call options. + * + * @param operation a method reference on {@code T} identifying the operation + * @param input the operation input + * @param options per-call options controlling timeouts, search attributes, etc. + * @return the operation result + * @throws RuntimeException if the operation failed, timed out, or was cancelled + */ default R execute( BiFunction operation, U input, StartNexusOperationOptions options) { return start(operation, input, options).getResult(); } - /** Start an operation and return a typed handle to track its execution. */ + /** + * Starts an operation and returns a typed handle for tracking its execution. + * + * @param operation a method reference on {@code T} identifying the operation + * @param input the operation input + * @return a typed handle bound to the started operation + */ default NexusOperationHandle start(BiFunction operation, U input) { return start(operation, input, StartNexusOperationOptions.getDefaultInstance()); } - /** Start an operation with per-call options and return a typed handle. */ + /** + * Starts an operation with per-call options and returns a typed handle. + * + * @param operation a method reference on {@code T} identifying the operation + * @param input the operation input + * @param options per-call options controlling timeouts, search attributes, etc. + * @return a typed handle bound to the started operation + */ NexusOperationHandle start( BiFunction operation, U input, StartNexusOperationOptions options); /** * Async variant of {@link #execute(BiFunction, Object)}. Returns a {@link CompletableFuture} that * completes with the typed result, or completes exceptionally if the operation fails. + * + * @param operation a method reference on {@code T} identifying the operation + * @param input the operation input */ default CompletableFuture executeAsync(BiFunction operation, U input) { return start(operation, input).getResultAsync(); } - /** Async variant of {@link #execute(BiFunction, Object, StartNexusOperationOptions)}. */ + /** + * Async variant of {@link #execute(BiFunction, Object, StartNexusOperationOptions)}. + * + * @param operation a method reference on {@code T} identifying the operation + * @param input the operation input + * @param options per-call options controlling timeouts, search attributes, etc. + */ default CompletableFuture executeAsync( BiFunction operation, U input, StartNexusOperationOptions options) { return start(operation, input, options).getResultAsync(); diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java index 9638a63bf..cb3dea059 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -5,8 +5,6 @@ import io.temporal.common.Experimental; import io.temporal.common.SearchAttributes; import java.time.Duration; -import java.util.Collections; -import java.util.Map; import java.util.Objects; import javax.annotation.Nullable; @@ -38,7 +36,6 @@ public static final class Builder { private @Nullable Duration scheduleToStartTimeout; private @Nullable Duration startToCloseTimeout; private @Nullable SearchAttributes typedSearchAttributes; - private Map nexusHeader = Collections.emptyMap(); private @Nullable String summary; private @Nullable NexusOperationIdReusePolicy idReusePolicy; private @Nullable NexusOperationIdConflictPolicy idConflictPolicy; @@ -54,7 +51,6 @@ private Builder(StartNexusOperationOptions options) { this.scheduleToStartTimeout = options.scheduleToStartTimeout; this.startToCloseTimeout = options.startToCloseTimeout; this.typedSearchAttributes = options.typedSearchAttributes; - this.nexusHeader = options.nexusHeader; this.summary = options.summary; this.idReusePolicy = options.idReusePolicy; this.idConflictPolicy = options.idConflictPolicy; @@ -93,12 +89,6 @@ public Builder setTypedSearchAttributes(@Nullable SearchAttributes typedSearchAt return this; } - /** Nexus protocol headers forwarded to the handler. */ - public Builder setNexusHeader(@Nullable Map nexusHeader) { - this.nexusHeader = nexusHeader == null ? Collections.emptyMap() : nexusHeader; - return this; - } - /** Short summary for UI display. */ public Builder setSummary(@Nullable String summary) { this.summary = summary; @@ -127,7 +117,6 @@ public StartNexusOperationOptions build() { private final @Nullable Duration scheduleToStartTimeout; private final @Nullable Duration startToCloseTimeout; private final @Nullable SearchAttributes typedSearchAttributes; - private final Map nexusHeader; private final @Nullable String summary; private final @Nullable NexusOperationIdReusePolicy idReusePolicy; private final @Nullable NexusOperationIdConflictPolicy idConflictPolicy; @@ -138,7 +127,6 @@ private StartNexusOperationOptions(Builder builder) { this.scheduleToStartTimeout = builder.scheduleToStartTimeout; this.startToCloseTimeout = builder.startToCloseTimeout; this.typedSearchAttributes = builder.typedSearchAttributes; - this.nexusHeader = Collections.unmodifiableMap(builder.nexusHeader); this.summary = builder.summary; this.idReusePolicy = builder.idReusePolicy; this.idConflictPolicy = builder.idConflictPolicy; @@ -173,10 +161,6 @@ public SearchAttributes getTypedSearchAttributes() { return typedSearchAttributes; } - public Map getNexusHeader() { - return nexusHeader; - } - @Nullable public String getSummary() { return summary; @@ -202,7 +186,6 @@ public boolean equals(Object o) { && Objects.equals(scheduleToStartTimeout, that.scheduleToStartTimeout) && Objects.equals(startToCloseTimeout, that.startToCloseTimeout) && Objects.equals(typedSearchAttributes, that.typedSearchAttributes) - && Objects.equals(nexusHeader, that.nexusHeader) && Objects.equals(summary, that.summary) && idReusePolicy == that.idReusePolicy && idConflictPolicy == that.idConflictPolicy; @@ -216,7 +199,6 @@ public int hashCode() { scheduleToStartTimeout, startToCloseTimeout, typedSearchAttributes, - nexusHeader, summary, idReusePolicy, idConflictPolicy); @@ -235,8 +217,6 @@ public String toString() { + startToCloseTimeout + ", typedSearchAttributes=" + typedSearchAttributes - + ", nexusHeader=" - + nexusHeader + ", summary='" + summary + "', idReusePolicy=" diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java index 389659a9b..f506bfb4b 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java @@ -1,56 +1,148 @@ package io.temporal.client; +import io.temporal.common.Experimental; import java.lang.reflect.Type; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; +/** + * An untyped handle to a standalone Nexus operation execution. Use this to get the result, + * describe, cancel, or terminate the operation when the result type is not known at compile time. + * + *

Obtain an instance via {@link NexusClient#getHandle(String)} or as the untyped projection of a + * handle returned by {@link NexusServiceClient}. + * + * @see NexusOperationHandle + * @see NexusClient + */ +@Experimental public interface UntypedNexusOperationHandle { - /** Operation ID this handle was constructed for. Always non-null. */ + + /** The caller-assigned operation ID for this execution. Always non-null. */ String getNexusOperationId(); /** - * Present if the handle was returned by `start` or set when calling `getHandle`. Null if - * `getHandle` was called with a null run ID — in that case, use {@link #describe()} to learn the - * current run ID. + * The server-assigned run ID for this operation execution. Present when the handle was returned + * by {@code start} or when {@link NexusClient#getHandle(String, String)} was called with an + * explicit run ID. May be {@code null} when obtained via {@link NexusClient#getHandle(String)} + * without a run ID — call {@link #describe()} to retrieve the current run ID. */ @Nullable String getNexusOperationRunId(); + /** + * Blocks until the standalone Nexus operation completes and returns the typed result. Polls the + * server via long-polling. + * + * @param resultClass the class to deserialize the result into + * @throws RuntimeException if the operation failed, timed out, or was cancelled; the specific + * exception type reflects the underlying failure + */ R getResult(Class resultClass); + /** + * Blocks until the standalone Nexus operation completes and returns the typed result. Use this + * overload for generic return types (e.g. {@code List}). + * + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + * @throws RuntimeException if the operation failed, timed out, or was cancelled; the specific + * exception type reflects the underlying failure + */ R getResult(Class resultClass, @Nullable Type resultType); /** - * Block up to {@code timeout} for the operation to complete and return the typed result. Throws - * {@link TimeoutException} if the operation has not completed within the deadline. + * Blocks until the standalone Nexus operation completes and returns the typed result, or throws + * if the client-side timeout expires before the operation completes. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + * @throws RuntimeException if the operation failed, timed out on the server, or was cancelled + * @throws TimeoutException if the client-side {@code timeout} expires before the operation + * completes */ R getResult(long timeout, TimeUnit unit, Class resultClass) throws TimeoutException; + /** + * Blocks until the standalone Nexus operation completes and returns the typed result, or throws + * if the client-side timeout expires. Use this overload for generic return types (e.g. {@code + * List}). + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + * @throws RuntimeException if the operation failed, timed out on the server, or was cancelled + * @throws TimeoutException if the client-side {@code timeout} expires before the operation + * completes + */ R getResult(long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) throws TimeoutException; + /** + * Returns a future that completes when the operation completes and resolves to the typed result. + * + * @param resultClass the class to deserialize the result into + */ CompletableFuture getResultAsync(Class resultClass); + /** + * Returns a future that completes when the operation completes and resolves to the typed result. + * Use this overload for generic return types (e.g. {@code List}). + * + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + */ CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType); /** - * Returns a future that completes with the typed result, or completes exceptionally with a {@link - * TimeoutException} if {@code timeout} elapses before the operation finishes. + * Returns a future that completes when the operation completes, or fails with {@link + * TimeoutException} if the operation does not complete within the specified timeout. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into */ CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class resultClass); + /** + * Returns a future for generic return types with a timeout. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + */ CompletableFuture getResultAsync( long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType); + /** + * Describes the current state of the Nexus operation execution. + * + * @return detailed information about the operation + */ NexusClientOperationExecutionDescription describe(); + /** Requests cancellation of the Nexus operation. */ void cancel(); + /** + * Requests cancellation of the Nexus operation with an optional reason. + * + * @param reason human-readable reason for cancellation, may be {@code null} + */ void cancel(@Nullable String reason); + /** Terminates the Nexus operation immediately, regardless of its current state. */ void terminate(); + /** + * Terminates the Nexus operation immediately with a reason. + * + * @param reason human-readable reason for termination, may be {@code null} + */ void terminate(@Nullable String reason); } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java index a3520147a..e2343f714 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java @@ -4,22 +4,57 @@ import java.lang.reflect.Type; import javax.annotation.Nullable; -/** Untyped client for invoking standalone Nexus operations by operation-name string. */ +/** + * Untyped client for invoking standalone Nexus operations by operation-name string. Use this when + * the operation contract is not available as a Java service interface at compile time. For a typed + * variant, see {@link NexusServiceClient}. + * + * @see NexusServiceClient + * @see NexusClient + */ @Experimental public interface UntypedNexusServiceClient { - /** Start an operation by name, returning an untyped handle. */ + /** + * Starts a Nexus operation by name and returns an untyped handle for tracking its execution. + * + * @param operation the operation name as registered on the service + * @param options per-call options controlling timeouts, search attributes, etc. + * @param arg the operation input; may be {@code null} + * @return an untyped handle bound to the started operation + */ UntypedNexusOperationHandle start( String operation, StartNexusOperationOptions options, @Nullable Object arg); - /** Execute an operation synchronously by name. */ + /** + * Executes a Nexus operation synchronously by name, blocking until it completes. + * + * @param operation the operation name as registered on the service + * @param resultClass the class to deserialize the result into + * @param options per-call options controlling timeouts, search attributes, etc. + * @param arg the operation input; may be {@code null} + * @return the deserialized operation result + * @throws RuntimeException if the operation failed, timed out, or was cancelled + */ R execute( String operation, Class resultClass, StartNexusOperationOptions options, @Nullable Object arg); - /** Execute an operation synchronously by name with explicit generic-result {@link Type}. */ + /** + * Executes a Nexus operation synchronously by name with an explicit generic-result {@link Type}. + * Use this overload when the result is a generic type whose parameters cannot be captured by + * {@link Class} alone (e.g. {@code List}). + * + * @param operation the operation name as registered on the service + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization + * @param options per-call options controlling timeouts, search attributes, etc. + * @param arg the operation input; may be {@code null} + * @return the deserialized operation result + * @throws RuntimeException if the operation failed, timed out, or was cancelled + */ R execute( String operation, Class resultClass, diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java index 35aff3944..fa3976406 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -7,6 +7,7 @@ import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput; import java.lang.reflect.Type; +import java.util.Collections; import javax.annotation.Nullable; /** @@ -47,7 +48,8 @@ public UntypedNexusOperationHandle start( serviceName, operation, payload, - options != null ? options : StartNexusOperationOptions.getDefaultInstance()); + options != null ? options : StartNexusOperationOptions.getDefaultInstance(), + Collections.emptyMap()); StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); return new NexusOperationHandleImpl<>( invoker, output.getOperationId(), output.getRunId(), dataConverter); diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 125abf677..9a426cbb4 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -13,6 +13,7 @@ import io.temporal.common.Experimental; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; import javax.annotation.Nonnull; @@ -30,28 +31,93 @@ @Experimental public interface NexusClientCallsInterceptor { + /** + * Starts a standalone Nexus operation. The endpoint, service, operation name, input, and + * scheduling options are carried in {@code input}. + * + * @param input endpoint, service name, operation name, encoded input, and start options + * @return output containing the operation ID, server-assigned run ID, and whether the operation + * was started by this call (vs. de-duplicated to an existing one) + */ StartNexusOperationExecutionOutput startNexusOperationExecution( StartNexusOperationExecutionInput input); + /** + * Returns a point-in-time snapshot of a standalone Nexus operation execution. + * + * @param input operation ID, optional run ID, and flags controlling whether to include input and + * outcome payloads + * @return output wrapping the {@link io.temporal.client.NexusClientOperationExecutionDescription} + */ DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionInput input); + /** + * Synchronously long-polls the server until the Nexus operation reaches the wait stage requested + * in {@code input}, then returns the outcome. Blocks the calling thread for the duration. + * + * @param input operation ID, optional run ID, target wait stage, and the deadline bounding the + * poll + * @return output containing the run ID, wait stage reached, operation token, and either the + * result payload or failure (when the operation has reached a terminal stage) + */ PollNexusOperationExecutionOutput pollNexusOperationExecution( PollNexusOperationExecutionInput input); + /** + * Asynchronous variant of {@link #pollNexusOperationExecution} that returns a future without + * blocking the calling thread. + * + * @param input operation ID, optional run ID, target wait stage, and the deadline bounding the + * poll + * @return a future that completes with the poll output, or completes exceptionally if the poll + * fails or the deadline expires + */ CompletableFuture pollNexusOperationExecutionAsync( PollNexusOperationExecutionInput input); + /** + * Lists standalone Nexus operation executions matching a Visibility query, with paging support. + * + * @param input Visibility query string, page size, and optional next-page token from a prior call + * @return output wrapping the matching operations and the next-page token (empty when the result + * set is exhausted) + */ ListNexusOperationExecutionsOutput listNexusOperationExecutions( ListNexusOperationExecutionsInput input); + /** + * Returns the count of standalone Nexus operation executions matching a Visibility query, + * optionally grouped by attribute. + * + * @param input Visibility query string + * @return output wrapping the total count and any aggregation groups + */ CountNexusOperationExecutionsOutput countNexusOperationExecutions( CountNexusOperationExecutionsInput input); + /** + * Requests cancellation of a running standalone Nexus operation. The server forwards the cancel + * request to the operation handler, which may honour or ignore it. + * + * @param input operation ID, optional run ID, and optional human-readable cancellation reason + */ void requestCancelNexusOperationExecution(RequestCancelNexusOperationExecutionInput input); + /** + * Forcefully terminates a standalone Nexus operation. Unlike cancellation, termination is + * immediate and cannot be intercepted by the operation handler. + * + * @param input operation ID, optional run ID, and optional human-readable termination reason + */ void terminateNexusOperationExecution(TerminateNexusOperationExecutionInput input); + /** + * Deletes a closed standalone Nexus operation execution from the server's visibility store. The + * operation must already be in a terminal state. + * + * @param input operation ID and optional run ID + */ void deleteNexusOperationExecution(DeleteNexusOperationExecutionInput input); final class StartNexusOperationExecutionInput { @@ -60,18 +126,21 @@ final class StartNexusOperationExecutionInput { private final String operation; private final @Nullable Payload input; private final StartNexusOperationOptions options; + private final Map headers; public StartNexusOperationExecutionInput( String endpoint, String service, String operation, @Nullable Payload input, - StartNexusOperationOptions options) { + StartNexusOperationOptions options, + Map headers) { this.endpoint = endpoint; this.service = service; this.operation = operation; this.input = input; this.options = options; + this.headers = headers == null ? Collections.emptyMap() : headers; } public String getEndpoint() { @@ -93,6 +162,14 @@ public Optional getInput() { public StartNexusOperationOptions getOptions() { return options; } + + /** + * Nexus protocol headers to forward to the handler. Interceptors implementing context + * propagation (tracing, baggage, etc.) populate this map by wrapping the call chain. + */ + public Map getHeaders() { + return headers; + } } final class StartNexusOperationExecutionOutput { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 29a56bac2..e76766e25 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -54,8 +54,9 @@ public StartNexusOperationExecutionOutput startNexusOperationExecution( .setOperationId(operationId) .setEndpoint(input.getEndpoint()) .setService(input.getService()) - .setOperation(input.getOperation()) - .putAllNexusHeader(options.getNexusHeader()); + .setOperation(input.getOperation()); + // Ensure that the headers are lowercase. + input.getHeaders().forEach((k, v) -> request.putNexusHeader(k.toLowerCase(), v)); if (options.getScheduleToCloseTimeout() != null) { request.setScheduleToCloseTimeout( @@ -103,7 +104,8 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionResponse response = genericClient.describeNexusOperationExecution(request); return new DescribeNexusOperationExecutionOutput( - new NexusClientOperationExecutionDescription(response)); + new NexusClientOperationExecutionDescription( + response, clientOptions.getDataConverter(), clientOptions.getNamespace())); } private DescribeNexusOperationExecutionRequest buildDescribeRequest( From 467e2ae4033dd5b072da4d8df0f94af3b99bb73c Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Wed, 27 May 2026 14:08:53 -0700 Subject: [PATCH 10/33] Responding to PR comments --- .../NexusOperationCancellationInfo.java | 2 +- .../NexusOperationExecutionDescription.java | 261 ++++++++++++++++++ .../client/NexusOperationHandleImpl.java | 2 +- .../client/StartNexusOperationOptions.java | 2 +- .../client/UntypedNexusOperationHandle.java | 2 +- .../NexusClientCallsInterceptor.java | 10 +- .../client/RootNexusClientInvoker.java | 4 +- .../nexus/NexusOperationHandleTest.java | 6 +- 8 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java index 293e2b396..796bd72a9 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java @@ -11,7 +11,7 @@ /** * Information about a cancellation request issued against a standalone Nexus operation execution. - * Returned by {@link NexusClientOperationExecutionDescription#getCancellationInfo()}. + * Returned by {@link NexusOperationExecutionDescription#getCancellationInfo()}. */ @Experimental public final class NexusOperationCancellationInfo { diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java new file mode 100644 index 000000000..1c4670126 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java @@ -0,0 +1,261 @@ +package io.temporal.client; + +import io.temporal.api.enums.v1.PendingNexusOperationState; +import io.temporal.api.nexus.v1.NexusOperationExecutionInfo; +import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.SearchAttributesUtil; +import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Detailed information about a standalone Nexus operation execution, returned by {@link + * UntypedNexusOperationHandle#describe()}. + */ +@Experimental +public final class NexusOperationExecutionDescription extends NexusOperationExecutionMetadata { + + private final DescribeNexusOperationExecutionResponse response; + private final NexusOperationExecutionInfo info; + private final DataConverter dataConverter; + + public NexusOperationExecutionDescription( + DescribeNexusOperationExecutionResponse response, + DataConverter dataConverter, + String namespace) { + super( + /* rawListInfo= */ null, + response.getInfo().getOperationId(), + nullIfEmpty(response.getInfo().getRunId()), + response.getInfo().getEndpoint(), + response.getInfo().getService(), + response.getInfo().getOperation(), + response.getInfo().hasScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime()) + : Instant.EPOCH, + response.getInfo().hasCloseTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime()) + : null, + response.getInfo().getStatus(), + SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes()), + response.getInfo().getStateTransitionCount(), + response.getInfo().hasExecutionDuration() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getExecutionDuration()) + : null); + this.response = response; + this.info = response.getInfo(); + this.dataConverter = dataConverter; + } + + private static @Nullable String nullIfEmpty(String s) { + return s == null || s.isEmpty() ? null : s; + } + + /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ + @Nonnull + public DescribeNexusOperationExecutionResponse getRawResponse() { + return response; + } + + /** The raw protobuf info returned by the server for this operation execution. */ + @Nonnull + public NexusOperationExecutionInfo getRawInfo() { + return info; + } + + /** Current attempt number for the start request (starts at 1). */ + public int getAttempt() { + return info.getAttempt(); + } + + /** + * Detailed run state (e.g. scheduled, started, backing off). Only meaningful when {@link + * #getStatus()} is {@code NEXUS_OPERATION_EXECUTION_STATUS_RUNNING}. + */ + @Nonnull + public PendingNexusOperationState getRunState() { + return info.getState(); + } + + /** Total time the caller is willing to wait for the operation to complete, including retries. */ + @Nullable + public Duration getScheduleToCloseTimeout() { + return info.hasScheduleToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToCloseTimeout()) + : null; + } + + /** Maximum time the start request may wait before being delivered to the handler. */ + @Nullable + public Duration getScheduleToStartTimeout() { + return info.hasScheduleToStartTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToStartTimeout()) + : null; + } + + /** Maximum time for a single start-request attempt. */ + @Nullable + public Duration getStartToCloseTimeout() { + return info.hasStartToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getStartToCloseTimeout()) + : null; + } + + /** Scheduled time plus schedule-to-close timeout. */ + @Nullable + public Instant getExpirationTime() { + return info.hasExpirationTime() + ? ProtobufTimeUtils.toJavaInstant(info.getExpirationTime()) + : null; + } + + /** Time the last start-request attempt completed (succeeded or failed). */ + @Nullable + public Instant getLastAttemptCompleteTime() { + return info.hasLastAttemptCompleteTime() + ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) + : null; + } + + /** Failure from the last start-request attempt. {@code null} if no failure has occurred. */ + @Nullable + public Exception getLastAttemptFailure() { + return info.hasLastAttemptFailure() + ? dataConverter.failureToException(info.getLastAttemptFailure()) + : null; + } + + /** Time when the next start-request attempt will be scheduled. */ + @Nullable + public Instant getNextAttemptScheduleTime() { + return info.hasNextAttemptScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) + : null; + } + + /** Cancellation details if cancellation was requested; {@code null} otherwise. */ + @Nullable + public NexusOperationCancellationInfo getCancellationInfo() { + return info.hasCancellationInfo() + ? new NexusOperationCancellationInfo(info.getCancellationInfo(), dataConverter) + : null; + } + + /** + * Additional context for why the operation is blocked. Set only when {@link #getRunState()} is + * {@code BLOCKED}. + */ + @Nullable + public String getBlockedReason() { + String r = info.getBlockedReason(); + return r.isEmpty() ? null : r; + } + + /** + * Server-generated request ID used as an idempotency token when submitting the start request to + * the operation handler. + */ + @Nullable + public String getHandlerRequestId() { + String r = info.getRequestId(); + return r.isEmpty() ? null : r; + } + + /** Operation token returned by the handler; set only for asynchronous operations after start. */ + @Nullable + public String getOperationToken() { + String t = info.getOperationToken(); + return t.isEmpty() ? null : t; + } + + /** Identity of the client that started this operation. */ + @Nullable + public String getIdentity() { + String i = info.getIdentity(); + return i.isEmpty() ? null : i; + } + + /** + * Whether the operation input payload is present on this description. Set only when {@link + * UntypedNexusOperationHandle#describe()} was called with {@code includeInput=true}. + */ + public boolean hasInput() { + return response.hasInput(); + } + + /** + * Deserializes the operation input into the given type. Returns {@link Optional#empty()} if no + * input is present (either the operation was started without one or {@code includeInput} was + * false on the describe call). + * + * @param valueType the class to deserialize the input into + */ + public Optional getInput(Class valueType) { + return getInput(valueType, valueType); + } + + /** + * Deserializes the operation input into the given generic type. Returns {@link Optional#empty()} + * if no input is present. + * + * @param valueType the class to deserialize the input into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getInput(Class valueType, Type genericType) { + if (!response.hasInput()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayload(response.getInput(), valueType, genericType)); + } + + /** + * Whether the operation's success result is present. Set only when {@link + * UntypedNexusOperationHandle#describe()} was called with {@code includeOutcome=true} and the + * operation completed successfully. + */ + public boolean hasResult() { + return response.hasResult(); + } + + /** + * Deserializes the operation's success result. Returns {@link Optional#empty()} if no result is + * present (operation still running, completed with a failure, or {@code includeOutcome} was + * false). + * + * @param valueType the class to deserialize the result into + */ + public Optional getResult(Class valueType) { + return getResult(valueType, valueType); + } + + /** + * Deserializes the operation's success result into the given generic type. Returns {@link + * Optional#empty()} if no result is present. + * + * @param valueType the class to deserialize the result into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getResult(Class valueType, Type genericType) { + if (!response.hasResult()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayload(response.getResult(), valueType, genericType)); + } + + /** + * Operation failure as a thrown-style exception. Returns {@code null} if the operation did not + * complete with a failure or if {@code includeOutcome} was false on the describe call. + */ + @Nullable + public Exception getFailure() { + return response.hasFailure() ? dataConverter.failureToException(response.getFailure()) : null; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java index c2e7b1974..ccd51c344 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java @@ -98,7 +98,7 @@ public String getNexusOperationId() { } @Override - public NexusClientOperationExecutionDescription describe() { + public NexusOperationExecutionDescription describe() { DescribeNexusOperationExecutionInput input = new DescribeNexusOperationExecutionInput( operationId, runId, /* includeInput= */ false, /* includeOutcome= */ true); diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java index cb3dea059..b0e55774b 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -57,7 +57,7 @@ private Builder(StartNexusOperationOptions options) { } /** - * Optional. Unique identifier for this operation within its namespace. If unset, the SDK + * Required. Unique identifier for this operation within its namespace. If left null, the SDK * generates a random UUID. */ public Builder setId(@Nullable String id) { diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java index f506bfb4b..d9f337ccf 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java @@ -124,7 +124,7 @@ CompletableFuture getResultAsync( * * @return detailed information about the operation */ - NexusClientOperationExecutionDescription describe(); + NexusOperationExecutionDescription describe(); /** Requests cancellation of the Nexus operation. */ void cancel(); diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 9a426cbb4..65e07ff7d 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -7,7 +7,7 @@ import io.temporal.api.failure.v1.Failure; import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo; import io.temporal.client.NexusClient; -import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusOperationHandle; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; @@ -47,7 +47,7 @@ StartNexusOperationExecutionOutput startNexusOperationExecution( * * @param input operation ID, optional run ID, and flags controlling whether to include input and * outcome payloads - * @return output wrapping the {@link io.temporal.client.NexusClientOperationExecutionDescription} + * @return output wrapping the {@link NexusOperationExecutionDescription} */ DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionInput input); @@ -228,14 +228,14 @@ public boolean isIncludeOutcome() { } final class DescribeNexusOperationExecutionOutput { - private final NexusClientOperationExecutionDescription description; + private final NexusOperationExecutionDescription description; public DescribeNexusOperationExecutionOutput( - NexusClientOperationExecutionDescription description) { + NexusOperationExecutionDescription description) { this.description = description; } - public NexusClientOperationExecutionDescription getDescription() { + public NexusOperationExecutionDescription getDescription() { return description; } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index e76766e25..d51d1d506 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -14,7 +14,7 @@ import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest; import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionResponse; import io.temporal.api.workflowservice.v1.TerminateNexusOperationExecutionRequest; -import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusClientOptions; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; @@ -104,7 +104,7 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionResponse response = genericClient.describeNexusOperationExecution(request); return new DescribeNexusOperationExecutionOutput( - new NexusClientOperationExecutionDescription( + new NexusOperationExecutionDescription( response, clientOptions.getDataConverter(), clientOptions.getNamespace())); } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index c8e2d3080..95eb9e4ca 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -8,7 +8,7 @@ import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; -import io.temporal.client.NexusClientOperationExecutionDescription; +import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusOperationHandle; import io.temporal.client.StartNexusOperationOptions; import io.temporal.client.UntypedNexusOperationHandle; @@ -53,7 +53,7 @@ public void describeReturnsDescriptionForStartedOperation() { UntypedNexusOperationHandle handle = started.client.getHandle(started.operationId, started.runId); - NexusClientOperationExecutionDescription description = handle.describe(); + NexusOperationExecutionDescription description = handle.describe(); Assert.assertNotNull(description); Assert.assertNotNull(description.getRunId()); @@ -67,7 +67,7 @@ public void describeWithoutRunIdTargetsLatest() { // Handle with no pinned run ID — server should resolve to the latest run. UntypedNexusOperationHandle handle = started.client.getHandle(started.operationId); - NexusClientOperationExecutionDescription description = handle.describe(); + NexusOperationExecutionDescription description = handle.describe(); Assert.assertNotNull(description); Assert.assertEquals(started.runId, description.getRunId()); From e73b44adcb62b11e32cde2fa3b78f1caeef0b090 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Wed, 27 May 2026 14:09:06 -0700 Subject: [PATCH 11/33] Removed a file --- ...usClientOperationExecutionDescription.java | 261 ------------------ 1 file changed, 261 deletions(-) delete mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java deleted file mode 100644 index 7936cbe80..000000000 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationExecutionDescription.java +++ /dev/null @@ -1,261 +0,0 @@ -package io.temporal.client; - -import io.temporal.api.enums.v1.PendingNexusOperationState; -import io.temporal.api.nexus.v1.NexusOperationExecutionInfo; -import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse; -import io.temporal.common.Experimental; -import io.temporal.common.converter.DataConverter; -import io.temporal.internal.common.ProtobufTimeUtils; -import io.temporal.internal.common.SearchAttributesUtil; -import java.lang.reflect.Type; -import java.time.Duration; -import java.time.Instant; -import java.util.Optional; -import javax.annotation.Nonnull; -import javax.annotation.Nullable; - -/** - * Detailed information about a standalone Nexus operation execution, returned by {@link - * UntypedNexusOperationHandle#describe()}. - */ -@Experimental -public final class NexusClientOperationExecutionDescription extends NexusOperationExecutionMetadata { - - private final DescribeNexusOperationExecutionResponse response; - private final NexusOperationExecutionInfo info; - private final DataConverter dataConverter; - - public NexusClientOperationExecutionDescription( - DescribeNexusOperationExecutionResponse response, - DataConverter dataConverter, - String namespace) { - super( - /* rawListInfo= */ null, - response.getInfo().getOperationId(), - nullIfEmpty(response.getInfo().getRunId()), - response.getInfo().getEndpoint(), - response.getInfo().getService(), - response.getInfo().getOperation(), - response.getInfo().hasScheduleTime() - ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime()) - : Instant.EPOCH, - response.getInfo().hasCloseTime() - ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime()) - : null, - response.getInfo().getStatus(), - SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes()), - response.getInfo().getStateTransitionCount(), - response.getInfo().hasExecutionDuration() - ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getExecutionDuration()) - : null); - this.response = response; - this.info = response.getInfo(); - this.dataConverter = dataConverter; - } - - private static @Nullable String nullIfEmpty(String s) { - return s == null || s.isEmpty() ? null : s; - } - - /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ - @Nonnull - public DescribeNexusOperationExecutionResponse getRawResponse() { - return response; - } - - /** The raw protobuf info returned by the server for this operation execution. */ - @Nonnull - public NexusOperationExecutionInfo getRawInfo() { - return info; - } - - /** Current attempt number for the start request (starts at 1). */ - public int getAttempt() { - return info.getAttempt(); - } - - /** - * Detailed run state (e.g. scheduled, started, backing off). Only meaningful when {@link - * #getStatus()} is {@code NEXUS_OPERATION_EXECUTION_STATUS_RUNNING}. - */ - @Nonnull - public PendingNexusOperationState getRunState() { - return info.getState(); - } - - /** Total time the caller is willing to wait for the operation to complete, including retries. */ - @Nullable - public Duration getScheduleToCloseTimeout() { - return info.hasScheduleToCloseTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToCloseTimeout()) - : null; - } - - /** Maximum time the start request may wait before being delivered to the handler. */ - @Nullable - public Duration getScheduleToStartTimeout() { - return info.hasScheduleToStartTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToStartTimeout()) - : null; - } - - /** Maximum time for a single start-request attempt. */ - @Nullable - public Duration getStartToCloseTimeout() { - return info.hasStartToCloseTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getStartToCloseTimeout()) - : null; - } - - /** Scheduled time plus schedule-to-close timeout. */ - @Nullable - public Instant getExpirationTime() { - return info.hasExpirationTime() - ? ProtobufTimeUtils.toJavaInstant(info.getExpirationTime()) - : null; - } - - /** Time the last start-request attempt completed (succeeded or failed). */ - @Nullable - public Instant getLastAttemptCompleteTime() { - return info.hasLastAttemptCompleteTime() - ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) - : null; - } - - /** Failure from the last start-request attempt. {@code null} if no failure has occurred. */ - @Nullable - public Exception getLastAttemptFailure() { - return info.hasLastAttemptFailure() - ? dataConverter.failureToException(info.getLastAttemptFailure()) - : null; - } - - /** Time when the next start-request attempt will be scheduled. */ - @Nullable - public Instant getNextAttemptScheduleTime() { - return info.hasNextAttemptScheduleTime() - ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) - : null; - } - - /** Cancellation details if cancellation was requested; {@code null} otherwise. */ - @Nullable - public NexusOperationCancellationInfo getCancellationInfo() { - return info.hasCancellationInfo() - ? new NexusOperationCancellationInfo(info.getCancellationInfo(), dataConverter) - : null; - } - - /** - * Additional context for why the operation is blocked. Set only when {@link #getRunState()} is - * {@code BLOCKED}. - */ - @Nullable - public String getBlockedReason() { - String r = info.getBlockedReason(); - return r.isEmpty() ? null : r; - } - - /** - * Server-generated request ID used as an idempotency token when submitting the start request to - * the operation handler. - */ - @Nullable - public String getHandlerRequestId() { - String r = info.getRequestId(); - return r.isEmpty() ? null : r; - } - - /** Operation token returned by the handler; set only for asynchronous operations after start. */ - @Nullable - public String getOperationToken() { - String t = info.getOperationToken(); - return t.isEmpty() ? null : t; - } - - /** Identity of the client that started this operation. */ - @Nullable - public String getIdentity() { - String i = info.getIdentity(); - return i.isEmpty() ? null : i; - } - - /** - * Whether the operation input payload is present on this description. Set only when {@link - * UntypedNexusOperationHandle#describe()} was called with {@code includeInput=true}. - */ - public boolean hasInput() { - return response.hasInput(); - } - - /** - * Deserializes the operation input into the given type. Returns {@link Optional#empty()} if no - * input is present (either the operation was started without one or {@code includeInput} was - * false on the describe call). - * - * @param valueType the class to deserialize the input into - */ - public Optional getInput(Class valueType) { - return getInput(valueType, valueType); - } - - /** - * Deserializes the operation input into the given generic type. Returns {@link Optional#empty()} - * if no input is present. - * - * @param valueType the class to deserialize the input into - * @param genericType the generic type for deserialization; may equal {@code valueType} - */ - public Optional getInput(Class valueType, Type genericType) { - if (!response.hasInput()) { - return Optional.empty(); - } - return Optional.ofNullable( - dataConverter.fromPayload(response.getInput(), valueType, genericType)); - } - - /** - * Whether the operation's success result is present. Set only when {@link - * UntypedNexusOperationHandle#describe()} was called with {@code includeOutcome=true} and the - * operation completed successfully. - */ - public boolean hasResult() { - return response.hasResult(); - } - - /** - * Deserializes the operation's success result. Returns {@link Optional#empty()} if no result is - * present (operation still running, completed with a failure, or {@code includeOutcome} was - * false). - * - * @param valueType the class to deserialize the result into - */ - public Optional getResult(Class valueType) { - return getResult(valueType, valueType); - } - - /** - * Deserializes the operation's success result into the given generic type. Returns {@link - * Optional#empty()} if no result is present. - * - * @param valueType the class to deserialize the result into - * @param genericType the generic type for deserialization; may equal {@code valueType} - */ - public Optional getResult(Class valueType, Type genericType) { - if (!response.hasResult()) { - return Optional.empty(); - } - return Optional.ofNullable( - dataConverter.fromPayload(response.getResult(), valueType, genericType)); - } - - /** - * Operation failure as a thrown-style exception. Returns {@code null} if the operation did not - * complete with a failure or if {@code includeOutcome} was false on the describe call. - */ - @Nullable - public Exception getFailure() { - return response.hasFailure() ? dataConverter.failureToException(response.getFailure()) : null; - } -} From da11cb9cbc9610f3820748d71b14dc8f196d8a3c Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Wed, 27 May 2026 14:46:42 -0700 Subject: [PATCH 12/33] PR comments --- .../io/temporal/client/ActivityHandle.java | 2 +- .../io/temporal/client/NexusClientImpl.java | 13 +- .../NexusOperationCancellationInfo.java | 4 +- .../temporal/client/NexusOperationHandle.java | 2 +- .../client/NexusOperationHandleImpl.java | 267 ++++-------------- .../temporal/client/NexusServiceClient.java | 4 +- .../client/NexusServiceClientImpl.java | 28 +- .../client/UntypedNexusServiceClientImpl.java | 5 +- .../NexusClientCallsInterceptor.java | 3 +- .../client/NexusOperationHandleImpl.java | 214 ++++++++++++++ .../client/RootNexusClientInvoker.java | 2 +- .../internal/util/MethodExtractor.java | 10 - 12 files changed, 303 insertions(+), 251 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java index 2a8f57fbc..71788efcc 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java @@ -31,7 +31,7 @@ public interface ActivityHandle extends UntypedActivityHandle { /** * Blocks until the standalone activity completes and returns the typed result, or throws if the * - * client-side timeout expires first. + *

client-side timeout expires first. * * @param timeout maximum time to wait * @param unit unit of {@code timeout} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java index 42d0ca529..4976a15f0 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -13,6 +13,7 @@ import io.temporal.common.interceptors.NexusClientInterceptor; import io.temporal.internal.WorkflowThreadMarker; import io.temporal.internal.client.NamespaceInjectWorkflowServiceStubs; +import io.temporal.internal.client.NexusOperationHandleImpl; import io.temporal.internal.client.RootNexusClientInvoker; import io.temporal.internal.client.external.GenericWorkflowClient; import io.temporal.internal.client.external.GenericWorkflowClientImpl; @@ -97,8 +98,8 @@ public UntypedNexusOperationHandle getHandle(String operationId) { @Override public UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId) { - return new NexusOperationHandleImpl<>( - nexusClientCallsInvoker, operationId, runId, options.getDataConverter()); + return new NexusOperationHandleImpl( + operationId, runId, nexusClientCallsInvoker, options.getDataConverter()); } @Override @@ -113,13 +114,7 @@ public NexusOperationHandle getHandle( @Nullable String runId, Class resultClass, @Nullable java.lang.reflect.Type resultType) { - return new NexusOperationHandleImpl<>( - nexusClientCallsInvoker, - operationId, - runId, - options.getDataConverter(), - resultClass, - resultType); + return NexusOperationHandle.fromUntyped(getHandle(operationId, runId), resultClass, resultType); } @Override diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java index 796bd72a9..54b99a5b7 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java @@ -61,9 +61,7 @@ public Instant getLastAttemptCompleteTime() { : null; } - /** - * Failure from the last cancel-delivery attempt. {@code null} if no failure has occurred yet. - */ + /** Failure from the last cancel-delivery attempt. {@code null} if no failure has occurred yet. */ @Nullable public Exception getLastAttemptFailure() { return info.hasLastAttemptFailure() diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java index c53093407..2445c492f 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java @@ -47,7 +47,7 @@ static NexusOperationHandle fromUntyped( */ static NexusOperationHandle fromUntyped( UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) { - return NexusOperationHandleImpl.fromUntyped(handle, resultClass, resultType); + return new NexusOperationHandleImpl<>(handle, resultClass, resultType); } /** diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java index ccd51c344..4c886fd18 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java @@ -1,291 +1,124 @@ package io.temporal.client; -import io.grpc.Deadline; -import io.temporal.api.common.v1.Payload; -import io.temporal.api.enums.v1.NexusOperationWaitStage; -import io.temporal.api.failure.v1.Failure; -import io.temporal.common.converter.DataConverter; -import io.temporal.common.interceptors.NexusClientCallsInterceptor; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionOutput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionOutput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput; import java.lang.reflect.Type; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; /** - * Single implementation of {@link NexusOperationHandle}/{@link UntypedNexusOperationHandle}. - * Constructed untyped by {@link NexusClient#getHandle(String)} and bound to a result type via - * {@link NexusOperationHandle#fromUntyped}. + * Package-private wrapper that adds typed result methods to an {@link UntypedNexusOperationHandle}, + * implementing {@link NexusOperationHandle}{@code }. Created via {@link + * NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class)} or {@link + * NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class, Type)}. */ -public class NexusOperationHandleImpl implements NexusOperationHandle { +final class NexusOperationHandleImpl implements NexusOperationHandle { - final NexusClientCallsInterceptor interceptor; - final String operationId; - final @Nullable String runId; - final DataConverter dataConverter; - final @Nullable Class resultClass; - final @Nullable Type resultType; + private final UntypedNexusOperationHandle delegate; + private final Class resultClass; + private final @Nullable Type resultType; - /** Construct an untyped handle. Used by {@link NexusClientImpl#getHandle}. */ - public NexusOperationHandleImpl( - NexusClientCallsInterceptor interceptor, - String operationId, - @Nullable String runId, - DataConverter dataConverter) { - this(interceptor, operationId, runId, dataConverter, null, null); - } - - /** - * Implementation of {@link NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class, - * Type)}. Lives here so the interface doesn't reach into impl-private state. - */ - static NexusOperationHandle fromUntyped( - UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) { - if (!(handle instanceof NexusOperationHandleImpl)) { - throw new IllegalArgumentException( - "Unsupported handle implementation: " + handle.getClass().getName()); - } - NexusOperationHandleImpl source = (NexusOperationHandleImpl) handle; - return new NexusOperationHandleImpl<>( - source.interceptor, - source.operationId, - source.runId, - source.dataConverter, - resultClass, - resultType); - } - - /** Construct a typed handle. Use {@link NexusOperationHandle#fromUntyped} from caller code. */ NexusOperationHandleImpl( - NexusClientCallsInterceptor interceptor, - String operationId, - @Nullable String runId, - DataConverter dataConverter, - @Nullable Class resultClass, - @Nullable Type resultType) { - if (interceptor == null) { - throw new IllegalArgumentException("interceptor is required"); - } - if (operationId == null) { - throw new IllegalArgumentException("operationId is required"); - } - if (dataConverter == null) { - throw new IllegalArgumentException("dataConverter is required"); - } - this.interceptor = interceptor; - this.operationId = operationId; - this.runId = runId; - this.dataConverter = dataConverter; + UntypedNexusOperationHandle delegate, Class resultClass, @Nullable Type resultType) { + this.delegate = delegate; this.resultClass = resultClass; this.resultType = resultType; } @Override - public String getNexusOperationId() { - return operationId; + public R getResult() { + return delegate.getResult(resultClass, resultType); } @Override - public @Nullable String getNexusOperationRunId() { - return runId; + public R getResult(long timeout, TimeUnit unit) throws TimeoutException { + return delegate.getResult(timeout, unit, resultClass, resultType); } @Override - public NexusOperationExecutionDescription describe() { - DescribeNexusOperationExecutionInput input = - new DescribeNexusOperationExecutionInput( - operationId, runId, /* includeInput= */ false, /* includeOutcome= */ true); - DescribeNexusOperationExecutionOutput output = - interceptor.describeNexusOperationExecution(input); - return output.getDescription(); + public CompletableFuture getResultAsync() { + return delegate.getResultAsync(resultClass, resultType); } @Override - public void cancel() { - cancel(null); + public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { + return delegate.getResultAsync(timeout, unit, resultClass, resultType); } @Override - public void cancel(@Nullable String reason) { - interceptor.requestCancelNexusOperationExecution( - new RequestCancelNexusOperationExecutionInput(operationId, runId, reason)); + public String getNexusOperationId() { + return delegate.getNexusOperationId(); } @Override - public void terminate() { - terminate(null); + public @Nullable String getNexusOperationRunId() { + return delegate.getNexusOperationRunId(); } @Override - public void terminate(@Nullable String reason) { - interceptor.terminateNexusOperationExecution( - new TerminateNexusOperationExecutionInput(operationId, runId, reason)); + public T getResult(Class clazz) { + return delegate.getResult(clazz); } @Override - public X getResult(Class resultClass) { - return getResult(resultClass, null); + public T getResult(Class clazz, @Nullable Type type) { + return delegate.getResult(clazz, type); } @Override - public X getResult(Class resultClass, @Nullable Type resultType) { - try { - return getResult(Integer.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); - } catch (TimeoutException e) { - throw new RuntimeException(e); - } + public T getResult(long timeout, TimeUnit unit, Class clazz) throws TimeoutException { + return delegate.getResult(timeout, unit, clazz, null); } @Override - public CompletableFuture getResultAsync(Class resultClass) { - return getResultAsync(resultClass, null); + public T getResult(long timeout, TimeUnit unit, Class clazz, @Nullable Type type) + throws TimeoutException { + return delegate.getResult(timeout, unit, clazz, type); } @Override - public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { - return getResultAsync(Long.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); + public CompletableFuture getResultAsync(Class clazz) { + return delegate.getResultAsync(clazz); } @Override - public X getResult(long timeout, TimeUnit unit, Class resultClass) - throws TimeoutException { - return getResult(timeout, unit, resultClass, null); + public CompletableFuture getResultAsync(Class clazz, @Nullable Type type) { + return delegate.getResultAsync(clazz, type); } @Override - public X getResult( - long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) - throws TimeoutException { - PollNexusOperationExecutionOutput out = - pollSyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)); - return extractResult(out, resultClass, resultType); + public CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class clazz) { + return delegate.getResultAsync(timeout, unit, clazz, null); } @Override - public CompletableFuture getResultAsync( - long timeout, TimeUnit unit, Class resultClass) { - return getResultAsync(timeout, unit, resultClass, null); + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class clazz, @Nullable Type type) { + return delegate.getResultAsync(timeout, unit, clazz, type); } @Override - public CompletableFuture getResultAsync( - long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { - return pollAsyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)) - .thenApply(out -> extractResult(out, resultClass, resultType)); + public NexusOperationExecutionDescription describe() { + return delegate.describe(); } @Override - public R getResult() { - if (resultClass == null) { - throw new IllegalStateException( - "getResult() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); - } - return getResult(resultClass, resultType); + public void cancel() { + delegate.cancel(); } @Override - public R getResult(long timeout, TimeUnit unit) throws TimeoutException { - if (resultClass == null) { - throw new IllegalStateException( - "getResult() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); - } - return getResult(timeout, unit, resultClass, resultType); + public void cancel(@Nullable String reason) { + delegate.cancel(reason); } @Override - public CompletableFuture getResultAsync() { - if (resultClass == null) { - throw new IllegalStateException( - "getResultAsync() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); - } - return getResultAsync(resultClass, resultType); + public void terminate() { + delegate.terminate(); } @Override - public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { - if (resultClass == null) { - throw new IllegalStateException( - "getResultAsync() requires a result type binding — wrap this handle with NexusOperationHandle.fromUntyped"); - } - return getResultAsync(timeout, unit, resultClass, resultType); - } - - /** Sync long-poll loop bounded by {@code deadline}. */ - private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadline deadline) - throws TimeoutException { - while (true) { - PollNexusOperationExecutionInput pollInput = - new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - deadline); - PollNexusOperationExecutionOutput out; - try { - out = interceptor.pollNexusOperationExecution(pollInput); - } catch (RuntimeException e) { - if (deadline.isExpired()) { - TimeoutException timeout = - new TimeoutException("getResult timed out before the operation completed"); - timeout.initCause(e); - throw timeout; - } - throw e; - } - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return out; - } - } - } - - /** Async long-poll loop bounded by {@code deadline}, recursing via {@code thenCompose}. */ - private CompletableFuture pollAsyncUntilCompletedOrDeadline( - Deadline deadline) { - if (deadline.isExpired()) { - CompletableFuture failed = new CompletableFuture<>(); - failed.completeExceptionally( - new TimeoutException("getResultAsync timed out before the operation completed")); - return failed; - } - PollNexusOperationExecutionInput pollInput = - new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - deadline); - return interceptor - .pollNexusOperationExecutionAsync(pollInput) - .thenCompose( - out -> { - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return CompletableFuture.completedFuture(out); - } - return pollAsyncUntilCompletedOrDeadline(deadline); - }); - } - - /** - * Convert a completed poll response into the typed result, throwing the operation's failure as an - * exception if it failed. - */ - private X extractResult( - PollNexusOperationExecutionOutput out, Class resultClass, @Nullable Type resultType) { - Optional failure = out.getFailure(); - if (failure.isPresent()) { - throw dataConverter.failureToException(failure.get()); - } - Optional payload = out.getResult(); - if (!payload.isPresent()) { - return null; - } - return dataConverter.fromPayload( - payload.get(), resultClass, resultType != null ? resultType : resultClass); + public void terminate(@Nullable String reason) { + delegate.terminate(reason); } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java index 600761855..acebd48af 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -9,8 +9,8 @@ * Typed client for invoking standalone Nexus operations on a specific service interface {@code T}. * *

Operations are dispatched via method references (or {@link BiFunction} lambdas) that target - * methods on {@code T}; the client extracts the operation name from the invocation and delegates - * to {@link NexusClient}. For visibility queries (list/count) across operations, use {@link + * methods on {@code T}; the client extracts the operation name from the invocation and delegates to + * {@link NexusClient}. For visibility queries (list/count) across operations, use {@link * NexusClient} directly. * * @param the Nexus service interface this client is bound to diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java index 0d80f8034..84fbab39e 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -2,6 +2,7 @@ import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread; +import io.nexusrpc.OperationDefinition; import io.nexusrpc.ServiceDefinition; import io.temporal.common.Experimental; import io.temporal.common.interceptors.NexusClientCallsInterceptor; @@ -22,6 +23,7 @@ class NexusServiceClientImpl extends UntypedNexusServiceClientImpl implements NexusServiceClient { private final Class serviceInterface; + private final ServiceDefinition serviceDef; static NexusServiceClient newInstance( Class service, String endpoint, WorkflowServiceStubs stubs, NexusClientOptions options) { @@ -41,8 +43,18 @@ static NexusServiceClient newInstance( Class serviceInterface, String endpoint, NexusClientOptions options) { - super(invoker, endpoint, ServiceDefinition.fromClass(serviceInterface).getName(), options); + this(invoker, serviceInterface, ServiceDefinition.fromClass(serviceInterface), endpoint, options); + } + + private NexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + Class serviceInterface, + ServiceDefinition serviceDef, + String endpoint, + NexusClientOptions options) { + super(invoker, endpoint, serviceDef.getName(), options); this.serviceInterface = serviceInterface; + this.serviceDef = serviceDef; } @Override @@ -50,10 +62,20 @@ public NexusOperationHandle start( BiFunction operation, U input, StartNexusOperationOptions options) { Method method = MethodExtractor.extract(serviceInterface, (Functions.Func2) operation::apply); - String operationName = MethodExtractor.nexusOperationName(method); + OperationDefinition opDef = + serviceDef.getOperations().values().stream() + .filter(o -> method.getName().equals(o.getMethodName())) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Method " + + method.getName() + + " is not a Nexus operation on " + + serviceInterface.getName())); @SuppressWarnings("unchecked") Class resultClass = (Class) method.getReturnType(); - UntypedNexusOperationHandle untyped = start(operationName, options, input); + UntypedNexusOperationHandle untyped = start(opDef.getName(), options, input); return NexusOperationHandle.fromUntyped(untyped, resultClass, method.getGenericReturnType()); } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java index fa3976406..30de99aa6 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -6,6 +6,7 @@ import io.temporal.common.interceptors.NexusClientCallsInterceptor; import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput; +import io.temporal.internal.client.NexusOperationHandleImpl; import java.lang.reflect.Type; import java.util.Collections; import javax.annotation.Nullable; @@ -51,8 +52,8 @@ public UntypedNexusOperationHandle start( options != null ? options : StartNexusOperationOptions.getDefaultInstance(), Collections.emptyMap()); StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); - return new NexusOperationHandleImpl<>( - invoker, output.getOperationId(), output.getRunId(), dataConverter); + return new NexusOperationHandleImpl( + output.getOperationId(), output.getRunId(), invoker, dataConverter); } @Override diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 65e07ff7d..ff146c19f 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -230,8 +230,7 @@ public boolean isIncludeOutcome() { final class DescribeNexusOperationExecutionOutput { private final NexusOperationExecutionDescription description; - public DescribeNexusOperationExecutionOutput( - NexusOperationExecutionDescription description) { + public DescribeNexusOperationExecutionOutput(NexusOperationExecutionDescription description) { this.description = description; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java new file mode 100644 index 000000000..e2028257b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java @@ -0,0 +1,214 @@ +package io.temporal.internal.client; + +import io.grpc.Deadline; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.NexusOperationWaitStage; +import io.temporal.api.failure.v1.Failure; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.DescribeNexusOperationExecutionOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.PollNexusOperationExecutionOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput; +import java.lang.reflect.Type; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +/** + * Implementation of {@link UntypedNexusOperationHandle} that delegates lifecycle operations through + * the interceptor chain. + */ +public final class NexusOperationHandleImpl implements UntypedNexusOperationHandle { + + private final String operationId; + private final @Nullable String runId; + private final NexusClientCallsInterceptor interceptor; + private final DataConverter dataConverter; + + public NexusOperationHandleImpl( + String operationId, + @Nullable String runId, + NexusClientCallsInterceptor interceptor, + DataConverter dataConverter) { + if (operationId == null) { + throw new IllegalArgumentException("operationId is required"); + } + if (interceptor == null) { + throw new IllegalArgumentException("interceptor is required"); + } + if (dataConverter == null) { + throw new IllegalArgumentException("dataConverter is required"); + } + this.operationId = operationId; + this.runId = runId; + this.interceptor = interceptor; + this.dataConverter = dataConverter; + } + + @Override + public String getNexusOperationId() { + return operationId; + } + + @Override + public @Nullable String getNexusOperationRunId() { + return runId; + } + + @Override + public NexusOperationExecutionDescription describe() { + DescribeNexusOperationExecutionInput input = + new DescribeNexusOperationExecutionInput( + operationId, runId, /* includeInput= */ false, /* includeOutcome= */ true); + DescribeNexusOperationExecutionOutput output = + interceptor.describeNexusOperationExecution(input); + return output.getDescription(); + } + + @Override + public void cancel() { + cancel(null); + } + + @Override + public void cancel(@Nullable String reason) { + interceptor.requestCancelNexusOperationExecution( + new RequestCancelNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public void terminate() { + terminate(null); + } + + @Override + public void terminate(@Nullable String reason) { + interceptor.terminateNexusOperationExecution( + new TerminateNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public R getResult(Class resultClass) { + return getResult(resultClass, null); + } + + @Override + public R getResult(Class resultClass, @Nullable Type resultType) { + try { + return getResult(Integer.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); + } catch (TimeoutException e) { + throw new RuntimeException(e); + } + } + + @Override + public CompletableFuture getResultAsync(Class resultClass) { + return getResultAsync(resultClass, null); + } + + @Override + public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { + return getResultAsync(Long.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); + } + + @Override + public R getResult(long timeout, TimeUnit unit, Class resultClass) + throws TimeoutException { + return getResult(timeout, unit, resultClass, null); + } + + @Override + public R getResult( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException { + PollNexusOperationExecutionOutput out = + pollSyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)); + return extractResult(out, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass) { + return getResultAsync(timeout, unit, resultClass, null); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { + return pollAsyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)) + .thenApply(out -> extractResult(out, resultClass, resultType)); + } + + private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadline deadline) + throws TimeoutException { + while (true) { + PollNexusOperationExecutionInput pollInput = + new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + deadline); + PollNexusOperationExecutionOutput out; + try { + out = interceptor.pollNexusOperationExecution(pollInput); + } catch (RuntimeException e) { + if (deadline.isExpired()) { + TimeoutException timeout = + new TimeoutException("getResult timed out before the operation completed"); + timeout.initCause(e); + throw timeout; + } + throw e; + } + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return out; + } + } + } + + private CompletableFuture pollAsyncUntilCompletedOrDeadline( + Deadline deadline) { + if (deadline.isExpired()) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally( + new TimeoutException("getResultAsync timed out before the operation completed")); + return failed; + } + PollNexusOperationExecutionInput pollInput = + new PollNexusOperationExecutionInput( + operationId, + runId, + NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, + deadline); + return interceptor + .pollNexusOperationExecutionAsync(pollInput) + .thenCompose( + out -> { + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(out); + } + return pollAsyncUntilCompletedOrDeadline(deadline); + }); + } + + private R extractResult( + PollNexusOperationExecutionOutput out, Class resultClass, @Nullable Type resultType) { + Optional failure = out.getFailure(); + if (failure.isPresent()) { + throw dataConverter.failureToException(failure.get()); + } + Optional payload = out.getResult(); + if (!payload.isPresent()) { + return null; + } + return dataConverter.fromPayload( + payload.get(), resultClass, resultType != null ? resultType : resultClass); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index d51d1d506..262fd368f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -14,8 +14,8 @@ import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionRequest; import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionResponse; import io.temporal.api.workflowservice.v1.TerminateNexusOperationExecutionRequest; -import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; import io.temporal.common.interceptors.NexusClientCallsInterceptor; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java b/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java index 1e8e4a19d..87490879b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/util/MethodExtractor.java @@ -1,7 +1,6 @@ package io.temporal.internal.util; import com.google.common.base.Defaults; -import io.nexusrpc.Operation; import io.temporal.common.metadata.POJOActivityInterfaceMetadata; import io.temporal.workflow.Functions; import java.lang.reflect.Method; @@ -38,15 +37,6 @@ public static String activityTypeName(Class interfac, Method method) { .getActivityTypeName(); } - /** - * Resolves the Nexus operation name for {@code method}: returns the explicit name from the {@link - * Operation} annotation when set, otherwise the Java method name. - */ - public static String nexusOperationName(Method method) { - Operation op = method.getAnnotation(Operation.class); - return op != null && !op.name().isEmpty() ? op.name() : method.getName(); - } - // --- Proc overloads --- public static Method extract(Class interfac, Functions.Proc1 m) { From b76efbae7a4d04a7d7e7ee8c5b1e70850288f8ef Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Wed, 27 May 2026 17:48:01 -0700 Subject: [PATCH 13/33] PR responses --- .../client/NexusOperationHandleImpl.java | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java index e2028257b..875d9e0ca 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java @@ -1,6 +1,8 @@ package io.temporal.internal.client; import io.grpc.Deadline; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; import io.temporal.api.common.v1.Payload; import io.temporal.api.enums.v1.NexusOperationWaitStage; import io.temporal.api.failure.v1.Failure; @@ -17,6 +19,7 @@ import java.lang.reflect.Type; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import javax.annotation.Nullable; @@ -142,8 +145,15 @@ public CompletableFuture getResultAsync( @Override public CompletableFuture getResultAsync( long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { - return pollAsyncUntilCompletedOrDeadline(Deadline.after(timeout, unit)) - .thenApply(out -> extractResult(out, resultClass, resultType)); + Deadline deadline = Deadline.after(timeout, unit); + return pollAsyncUntilCompletedOrDeadline(deadline) + .handle( + (out, e) -> { + if (e == null) { + return extractResult(out, resultClass, resultType); + } + throw mapAsyncException(e, deadline); + }); } private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadline deadline) @@ -158,12 +168,9 @@ private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadl PollNexusOperationExecutionOutput out; try { out = interceptor.pollNexusOperationExecution(pollInput); - } catch (RuntimeException e) { - if (deadline.isExpired()) { - TimeoutException timeout = - new TimeoutException("getResult timed out before the operation completed"); - timeout.initCause(e); - throw timeout; + } catch (StatusRuntimeException e) { + if (deadline.isExpired() && Status.Code.DEADLINE_EXCEEDED.equals(e.getStatus().getCode())) { + throw new TimeoutException("getResult timed out before the operation completed"); } throw e; } @@ -175,27 +182,38 @@ private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadl private CompletableFuture pollAsyncUntilCompletedOrDeadline( Deadline deadline) { - if (deadline.isExpired()) { - CompletableFuture failed = new CompletableFuture<>(); - failed.completeExceptionally( - new TimeoutException("getResultAsync timed out before the operation completed")); - return failed; - } PollNexusOperationExecutionInput pollInput = new PollNexusOperationExecutionInput( operationId, runId, NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, deadline); - return interceptor - .pollNexusOperationExecutionAsync(pollInput) - .thenCompose( - out -> { - if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { - return CompletableFuture.completedFuture(out); - } - return pollAsyncUntilCompletedOrDeadline(deadline); - }); + CompletableFuture pollFuture; + try { + pollFuture = interceptor.pollNexusOperationExecutionAsync(pollInput); + } catch (Throwable t) { + pollFuture = new CompletableFuture<>(); + pollFuture.completeExceptionally(t); + } + return pollFuture.thenCompose( + out -> { + if (out.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(out); + } + return pollAsyncUntilCompletedOrDeadline(deadline); + }); + } + + private static CompletionException mapAsyncException(Throwable e, Deadline deadline) { + Throwable cause = e instanceof CompletionException ? e.getCause() : e; + if (deadline.isExpired() + && cause instanceof StatusRuntimeException + && Status.Code.DEADLINE_EXCEEDED.equals( + ((StatusRuntimeException) cause).getStatus().getCode())) { + return new CompletionException( + new TimeoutException("getResultAsync timed out before the operation completed")); + } + return e instanceof CompletionException ? (CompletionException) e : new CompletionException(e); } private R extractResult( From 4d08d5ef265bc83f956cb17e8543f42323d7763d Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 10:53:47 -0700 Subject: [PATCH 14/33] Updated exception handling --- ...NexusOperationAlreadyStartedException.java | 36 ++++++++ .../client/NexusOperationException.java | 31 +++++++ .../client/NexusOperationFailedException.java | 17 ++++ .../NexusOperationNotFoundException.java | 31 +++++++ .../client/NexusOperationHandleImpl.java | 7 +- .../client/RootNexusClientInvoker.java | 92 +++++++++++++++++-- 6 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java new file mode 100644 index 000000000..42c815513 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java @@ -0,0 +1,36 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Thrown by {@link NexusClient} / {@link NexusServiceClient} when the server returns an + * ALREADY_EXISTS error because a Nexus operation with the same ID is already running (or has a + * completed run that conflicts with the requested {@link + * StartNexusOperationOptions#getIdReusePolicy()} / {@link + * StartNexusOperationOptions#getIdConflictPolicy()}). + */ +@Experimental +public final class NexusOperationAlreadyStartedException extends NexusOperationException { + + private final String operation; + + public NexusOperationAlreadyStartedException( + String operationId, String operation, @Nullable String runId, Throwable cause) { + super( + "Nexus operation already started: operationId='" + + operationId + + "', operation='" + + operation + + (runId != null ? "', runId='" + runId + "'" : "'"), + operationId, + runId, + cause); + this.operation = operation; + } + + /** The Nexus operation name that was requested. */ + public String getOperation() { + return operation; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java new file mode 100644 index 000000000..0527aeb92 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java @@ -0,0 +1,31 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.failure.TemporalException; +import javax.annotation.Nullable; + +/** Base exception for standalone Nexus operation execution failures. */ +@Experimental +public abstract class NexusOperationException extends TemporalException { + + private final String operationId; + private final @Nullable String runId; + + protected NexusOperationException( + String message, String operationId, @Nullable String runId, @Nullable Throwable cause) { + super(message, cause); + this.operationId = operationId; + this.runId = runId; + } + + /** The ID of the Nexus operation execution that caused this exception. */ + public String getOperationId() { + return operationId; + } + + /** The run ID of the Nexus operation execution, or {@code null} if not available. */ + @Nullable + public String getRunId() { + return runId; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java new file mode 100644 index 000000000..446f0a5c2 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java @@ -0,0 +1,17 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Thrown by {@link UntypedNexusOperationHandle#getResult} when the standalone Nexus operation was + * not successful. The original cause can be retrieved via {@link #getCause()}. + */ +@Experimental +public final class NexusOperationFailedException extends NexusOperationException { + + public NexusOperationFailedException( + String message, String operationId, @Nullable String runId, @Nullable Throwable cause) { + super(message, operationId, runId, cause); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java new file mode 100644 index 000000000..c25adda35 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java @@ -0,0 +1,31 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Thrown when a Nexus operation with the given ID is not known to the Temporal service or is in an + * incorrect state to perform the requested operation. + * + *

Examples of possible causes: + * + *

    + *
  • operation ID doesn't exist + *
  • operation was purged from the service after reaching its retention limit + *
  • attempt to cancel/terminate/delete an operation that is already closed + *
+ */ +@Experimental +public final class NexusOperationNotFoundException extends NexusOperationException { + + public NexusOperationNotFoundException( + String operationId, @Nullable String runId, @Nullable Throwable cause) { + super( + "Nexus operation not found: operationId='" + + operationId + + (runId != null ? "', runId='" + runId + "'" : "'"), + operationId, + runId, + cause); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java index 875d9e0ca..8a3b91b10 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java @@ -7,6 +7,7 @@ import io.temporal.api.enums.v1.NexusOperationWaitStage; import io.temporal.api.failure.v1.Failure; import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationFailedException; import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.common.converter.DataConverter; import io.temporal.common.interceptors.NexusClientCallsInterceptor; @@ -220,7 +221,11 @@ private R extractResult( PollNexusOperationExecutionOutput out, Class resultClass, @Nullable Type resultType) { Optional failure = out.getFailure(); if (failure.isPresent()) { - throw dataConverter.failureToException(failure.get()); + throw new NexusOperationFailedException( + "Nexus operation failed: operationId='" + operationId + "'", + operationId, + runId, + dataConverter.failureToException(failure.get())); } Optional payload = out.getResult(); if (!payload.isPresent()) { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 262fd368f..b9e039a57 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -1,5 +1,8 @@ package io.temporal.internal.client; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.temporal.api.errordetails.v1.NexusOperationExecutionAlreadyStartedFailure; import io.temporal.api.sdk.v1.UserMetadata; import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsRequest; import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsResponse; @@ -15,15 +18,20 @@ import io.temporal.api.workflowservice.v1.StartNexusOperationExecutionResponse; import io.temporal.api.workflowservice.v1.TerminateNexusOperationExecutionRequest; import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationAlreadyStartedException; import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationNotFoundException; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; import io.temporal.common.interceptors.NexusClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.WorkflowExecutionUtils; +import io.temporal.serviceclient.StatusUtils; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import javax.annotation.Nullable; /** * Root implementation of {@link NexusClientCallsInterceptor} that converts the SDK's Java DTOs into @@ -91,8 +99,21 @@ public StartNexusOperationExecutionOutput startNexusOperationExecution( } } - StartNexusOperationExecutionResponse response = - genericClient.startNexusOperationExecution(request.build()); + StartNexusOperationExecutionResponse response; + try { + response = genericClient.startNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.ALREADY_EXISTS) { + NexusOperationExecutionAlreadyStartedFailure detail = + StatusUtils.getFailure(e, NexusOperationExecutionAlreadyStartedFailure.class); + if (detail != null) { + String runId = detail.getRunId().isEmpty() ? null : detail.getRunId(); + throw new NexusOperationAlreadyStartedException( + operationId, input.getOperation(), runId, e); + } + } + throw e; + } return new StartNexusOperationExecutionOutput( operationId, response.getRunId(), response.getStarted()); } @@ -101,8 +122,12 @@ public StartNexusOperationExecutionOutput startNexusOperationExecution( public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionInput input) { DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input); - DescribeNexusOperationExecutionResponse response = - genericClient.describeNexusOperationExecution(request); + DescribeNexusOperationExecutionResponse response; + try { + response = genericClient.describeNexusOperationExecution(request); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } return new DescribeNexusOperationExecutionOutput( new NexusOperationExecutionDescription( response, clientOptions.getDataConverter(), clientOptions.getNamespace())); @@ -123,17 +148,39 @@ private DescribeNexusOperationExecutionRequest buildDescribeRequest( @Override public PollNexusOperationExecutionOutput pollNexusOperationExecution( PollNexusOperationExecutionInput input) { - PollNexusOperationExecutionResponse response = - genericClient.pollNexusOperationExecution(buildPollRequest(input), input.getDeadline()); + PollNexusOperationExecutionResponse response; + try { + response = + genericClient.pollNexusOperationExecution(buildPollRequest(input), input.getDeadline()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } return toPollOutput(response); } @Override public CompletableFuture pollNexusOperationExecutionAsync( PollNexusOperationExecutionInput input) { + String operationId = input.getOperationId(); + String runId = input.getRunId().orElse(null); return genericClient .pollNexusOperationExecutionAsync(buildPollRequest(input), input.getDeadline()) - .thenApply(this::toPollOutput); + .handle( + (response, err) -> { + if (err == null) { + return CompletableFuture.completedFuture(toPollOutput(response)); + } + Throwable cause = err instanceof CompletionException ? err.getCause() : err; + CompletableFuture failed = new CompletableFuture<>(); + if (cause instanceof StatusRuntimeException) { + failed.completeExceptionally( + mapNotFound(operationId, runId, (StatusRuntimeException) cause)); + } else { + failed.completeExceptionally(err); + } + return failed; + }) + .thenCompose(f -> f); } private PollNexusOperationExecutionRequest buildPollRequest( @@ -205,7 +252,11 @@ public void requestCancelNexusOperationExecution( .setOperationId(input.getOperationId()); input.getRunId().ifPresent(request::setRunId); input.getReason().ifPresent(request::setReason); - genericClient.requestCancelNexusOperationExecution(request.build()); + try { + genericClient.requestCancelNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } } @Override @@ -218,7 +269,11 @@ public void terminateNexusOperationExecution(TerminateNexusOperationExecutionInp .setOperationId(input.getOperationId()); input.getRunId().ifPresent(request::setRunId); input.getReason().ifPresent(request::setReason); - genericClient.terminateNexusOperationExecution(request.build()); + try { + genericClient.terminateNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } } @Override @@ -228,6 +283,23 @@ public void deleteNexusOperationExecution(DeleteNexusOperationExecutionInput inp .setNamespace(clientOptions.getNamespace()) .setOperationId(input.getOperationId()); input.getRunId().ifPresent(request::setRunId); - genericClient.deleteNexusOperationExecution(request.build()); + try { + genericClient.deleteNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } + } + + /** + * Maps a {@link StatusRuntimeException} with {@code NOT_FOUND} status to a typed {@link + * NexusOperationNotFoundException}; otherwise returns the original exception unchanged so the + * caller can rethrow. + */ + private static RuntimeException mapNotFound( + String operationId, @Nullable String runId, StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.NOT_FOUND) { + return new NexusOperationNotFoundException(operationId, runId, e); + } + return e; } } From 0e69f85bb312de960c92cfba44e776cfe11f43fe Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 14:13:50 -0700 Subject: [PATCH 15/33] Test updates --- .../client/NexusServiceClientImpl.java | 7 ++- .../client/RootNexusClientInvoker.java | 3 +- .../client/nexus/NexusClientTest.java | 44 +++++-------------- 3 files changed, 20 insertions(+), 34 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java index 84fbab39e..7ead1c539 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -43,7 +43,12 @@ static NexusServiceClient newInstance( Class serviceInterface, String endpoint, NexusClientOptions options) { - this(invoker, serviceInterface, ServiceDefinition.fromClass(serviceInterface), endpoint, options); + this( + invoker, + serviceInterface, + ServiceDefinition.fromClass(serviceInterface), + endpoint, + options); } private NexusServiceClientImpl( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index b9e039a57..3c4a205e0 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -171,7 +171,8 @@ public CompletableFuture pollNexusOperationEx return CompletableFuture.completedFuture(toPollOutput(response)); } Throwable cause = err instanceof CompletionException ? err.getCause() : err; - CompletableFuture failed = new CompletableFuture<>(); + CompletableFuture failed = + new CompletableFuture<>(); if (cause instanceof StatusRuntimeException) { failed.completeExceptionally( mapNotFound(operationId, runId, (StatusRuntimeException) cause)); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 4cda00056..01e0f6563 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -42,6 +42,8 @@ public static void requireExternalService() { @Test public void listNexusOperationExecutions() { + // Just run a basic test to see if it works + // runStandaloneNexusOperation tests this more thoroughly NexusClient client = testWorkflowRule.getNexusClient(); // Materialize the lazy stream to force at least one page fetch and ensure no exceptions. @@ -53,9 +55,11 @@ public void listNexusOperationExecutions() { @Test public void countNexusOperationExecutions() { // Just run a basic test to see if it works + // runStandaloneNexusOperation tests this more thoroughly countNexusOperations(); } + // A helper function to get the count and do a few validation tests around it public long countNexusOperations() { NexusClient client = testWorkflowRule.getNexusClient(); @@ -70,8 +74,7 @@ public long countNexusOperations() { @Test public void runStandaloneNexusOperation() throws Exception { - TestNexusServiceImpl.received = new java.util.concurrent.CompletableFuture<>(); - TestNexusServiceImpl.invocationCount.set(0); + long initialCount = countNexusOperations(); Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); String inputValue = "ping-" + UUID.randomUUID(); @@ -88,19 +91,10 @@ public void runStandaloneNexusOperation() throws Exception { UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); String operationId = handle.getNexusOperationId(); - // Sync handler: wait for the input to land in the test side-channel; that's how we - // know the operation actually completed on the worker. - String observed; - try { - observed = TestNexusServiceImpl.received.get(60, TimeUnit.SECONDS); - } catch (java.util.concurrent.TimeoutException e) { - Assert.fail( - "Nexus handler was never invoked within 60s. invocationCount=" - + TestNexusServiceImpl.invocationCount.get()); - throw new AssertionError("unreachable"); - } - Assert.assertEquals( - "expected the Nexus handler to receive the same input we sent", inputValue, observed); + // Block on the handle until the operation completes; the echoed result implies the + // handler received our input. + String result = handle.getResult(60, TimeUnit.SECONDS, String.class); + Assert.assertEquals("echo:" + inputValue, result); // Poll the list until our operationId appears. This also tests that the list operation // works correctly. @@ -114,9 +108,8 @@ public void runStandaloneNexusOperation() throws Exception { Assert.assertEquals( TestNexusServices.TestNexusService1.class.getSimpleName(), listed.getService()); Assert.assertEquals("operation", listed.getOperation()); - - // We know count should be at least 1. - Assert.assertTrue(countNexusOperations() >= 1); + // Make sure the count went up. + Assert.assertTrue(countNexusOperations() > initialCount); } private NexusOperationExecutionMetadata waitForListedOperation( @@ -146,23 +139,10 @@ public String execute(String input) { @ServiceImpl(service = TestNexusServices.TestNexusService1.class) public static class TestNexusServiceImpl { - // CompletableFuture (not BlockingQueue) so we can record a null input — the worker may - // legitimately deliver a null payload, and we want a clean assertion failure instead of a - // NullPointerException-driven retry storm. Reassigned per test in a @Before-style reset. - static volatile java.util.concurrent.CompletableFuture received = - new java.util.concurrent.CompletableFuture<>(); - static final java.util.concurrent.atomic.AtomicInteger invocationCount = - new java.util.concurrent.atomic.AtomicInteger(); - @OperationImpl public OperationHandler operation() { return OperationHandler.sync( - (context, details, input) -> { - invocationCount.incrementAndGet(); - // complete() ignores subsequent calls, so the first delivered input wins. - received.complete(input); - return "echo:" + (input == null ? "" : input); - }); + (context, details, input) -> "echo:" + (input == null ? "" : input)); } } } From dcc1d6c21c773e7ceeb463624b1157efa3f9ab98 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 14:48:41 -0700 Subject: [PATCH 16/33] Removing some timeouts --- .../temporal/client/nexus/NexusClientInterceptorChainTest.java | 1 - .../java/io/temporal/client/nexus/NexusOperationHandleTest.java | 2 -- .../java/io/temporal/client/nexus/NexusServiceClientTest.java | 1 - 3 files changed, 4 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java index fc61e0b56..d915061e8 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -31,7 +31,6 @@ public class NexusClientInterceptorChainTest { public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(PlaceholderWorkflowImpl.class) - .setTestTimeoutSeconds(60) .build(); @BeforeClass diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index 95eb9e4ca..dd026c998 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -35,8 +35,6 @@ public class NexusOperationHandleTest { SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(PlaceholderWorkflowImpl.class) .setNexusServiceImplementation(new TestNexusServiceImpl()) - // Default is 10s; standalone Nexus dispatch + worker poll can take longer. - .setTestTimeoutSeconds(120) .build(); @BeforeClass diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index 309d8945c..e10406d48 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -36,7 +36,6 @@ public class NexusServiceClientTest { SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(PlaceholderWorkflowImpl.class) .setNexusServiceImplementation(new TestNexusServiceImpl()) - .setTestTimeoutSeconds(120) .build(); @BeforeClass From 74f4730efb844bd48f27de07c4587f3aaacadc12 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 14:56:17 -0700 Subject: [PATCH 17/33] Refactor due to PR comment --- .../NexusClientInterceptorChainTest.java | 4 +- .../nexus/NexusOperationHandleTest.java | 94 +++++-------------- 2 files changed, 22 insertions(+), 76 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java index d915061e8..4acfe7262 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -29,9 +29,7 @@ public class NexusClientInterceptorChainTest { @Rule public SDKTestWorkflowRule testWorkflowRule = - SDKTestWorkflowRule.newBuilder() - .setWorkflowTypes(PlaceholderWorkflowImpl.class) - .build(); + SDKTestWorkflowRule.newBuilder().setWorkflowTypes(PlaceholderWorkflowImpl.class).build(); @BeforeClass public static void requireExternalService() { diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index dd026c998..752215b77 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -47,92 +47,63 @@ public static void requireExternalService() { @Test public void describeReturnsDescriptionForStartedOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = startOperation(); NexusOperationExecutionDescription description = handle.describe(); Assert.assertNotNull(description); Assert.assertNotNull(description.getRunId()); - Assert.assertEquals(started.runId, description.getRunId()); + Assert.assertEquals(handle.getNexusOperationRunId(), description.getRunId()); Assert.assertNotNull(description.getRawResponse()); } @Test public void describeWithoutRunIdTargetsLatest() { - StartedOperation started = startOperation(); - // Handle with no pinned run ID — server should resolve to the latest run. - UntypedNexusOperationHandle handle = started.client.getHandle(started.operationId); + UntypedNexusOperationHandle started = startOperation(); + // Re-bind a handle with no pinned run ID — server should resolve to the latest run. + UntypedNexusOperationHandle handle = + testWorkflowRule.getNexusClient().getHandle(started.getNexusOperationId()); NexusOperationExecutionDescription description = handle.describe(); Assert.assertNotNull(description); - Assert.assertEquals(started.runId, description.getRunId()); + Assert.assertEquals(started.getNexusOperationRunId(), description.getRunId()); } @Test public void cancelSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.cancel(); + startOperation().cancel(); // No exception — server accepted the cancel request. } @Test public void cancelWithReasonSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.cancel("test-cancel-reason"); + startOperation().cancel("test-cancel-reason"); } @Test public void cancelWithNullReasonSucceeds() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.cancel(null); + startOperation().cancel(null); } @Test public void terminateSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.terminate(); + startOperation().terminate(); } @Test public void terminateWithReasonSucceedsForStartedOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.terminate("test-terminate-reason"); + startOperation().terminate("test-terminate-reason"); } @Test public void terminateWithNullReasonSucceeds() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - handle.terminate(null); + startOperation().terminate(null); } @Test public void getResultReturnsTypedResultForSyncOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle untyped = - started.client.getHandle(started.operationId, started.runId); - - String result = NexusOperationHandle.fromUntyped(untyped, String.class).getResult(); + String result = NexusOperationHandle.fromUntyped(startOperation(), String.class).getResult(); Assert.assertNotNull(result); Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); @@ -140,11 +111,7 @@ public void getResultReturnsTypedResultForSyncOperation() { @Test public void getResultUntypedReturnsResultForSyncOperation() { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); - - String result = handle.getResult(String.class); + String result = startOperation().getResult(String.class); Assert.assertNotNull(result); Assert.assertTrue(result.startsWith("echo:ping-")); @@ -152,12 +119,8 @@ public void getResultUntypedReturnsResultForSyncOperation() { @Test public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception { - StartedOperation started = startOperation(); - UntypedNexusOperationHandle untyped = - started.client.getHandle(started.operationId, started.runId); - String result = - NexusOperationHandle.fromUntyped(untyped, String.class) + NexusOperationHandle.fromUntyped(startOperation(), String.class) .getResultAsync() .get(60, java.util.concurrent.TimeUnit.SECONDS); @@ -165,24 +128,12 @@ public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception Assert.assertTrue(result.startsWith("echo:ping-")); } - /** Holder for state used to drive a single test against one started operation. */ - private static final class StartedOperation { - final NexusClient client; - final String operationId; - final String runId; - - StartedOperation(NexusClient client, String operationId, String runId) { - this.client = client; - this.operationId = operationId; - this.runId = runId; - } - } - - private StartedOperation startOperation() { + private UntypedNexusOperationHandle startOperation() { return startOperation(null); } - private StartedOperation startOperation(@javax.annotation.Nullable String inputOverride) { + private UntypedNexusOperationHandle startOperation( + @javax.annotation.Nullable String inputOverride) { NexusClient client = testWorkflowRule.getNexusClient(); Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); String inputValue = @@ -199,8 +150,7 @@ private StartedOperation startOperation(@javax.annotation.Nullable String inputO UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); Assert.assertNotNull("expected start to return a run ID", handle.getNexusOperationRunId()); - return new StartedOperation( - client, handle.getNexusOperationId(), handle.getNexusOperationRunId()); + return handle; } public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { @@ -231,9 +181,7 @@ public OperationHandler operation() { @Test public void getResultPropagatesOperationFailure() { - StartedOperation started = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); - UntypedNexusOperationHandle handle = - started.client.getHandle(started.operationId, started.runId); + UntypedNexusOperationHandle handle = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); try { handle.getResult(String.class); From bcf23d0a4ed3ac7118a4e3335f5d695e3cf77a20 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 15:18:53 -0700 Subject: [PATCH 18/33] More tests --- .../nexus/NexusOperationHandleTest.java | 111 +++++++++++++++--- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index 752215b77..ea4f27169 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -3,12 +3,17 @@ import static org.junit.Assume.assumeTrue; import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; import io.nexusrpc.handler.OperationHandler; import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.OperationStartDetails; +import io.nexusrpc.handler.OperationStartResult; import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationFailedException; import io.temporal.client.NexusOperationHandle; import io.temporal.client.StartNexusOperationOptions; import io.temporal.client.UntypedNexusOperationHandle; @@ -72,33 +77,84 @@ public void describeWithoutRunIdTargetsLatest() { @Test public void cancelSucceedsForStartedOperation() { - startOperation().cancel(); - // No exception — server accepted the cancel request. + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.cancel(); + assertCancellationRecorded(handle, /* expectedReason= */ null); } @Test public void cancelWithReasonSucceedsForStartedOperation() { - startOperation().cancel("test-cancel-reason"); + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.cancel("test-cancel-reason"); + assertCancellationRecorded(handle, "test-cancel-reason"); } @Test public void cancelWithNullReasonSucceeds() { - startOperation().cancel(null); + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.cancel(null); + assertCancellationRecorded(handle, /* expectedReason= */ null); } @Test public void terminateSucceedsForStartedOperation() { - startOperation().terminate(); + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate(); + assertTerminatedFailure(handle); } @Test public void terminateWithReasonSucceedsForStartedOperation() { - startOperation().terminate("test-terminate-reason"); + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate("test-terminate-reason"); + assertTerminatedFailure(handle); } @Test public void terminateWithNullReasonSucceeds() { - startOperation().terminate(null); + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate(null); + assertTerminatedFailure(handle); + } + + /** + * Async operations stay in RUNNING state until something external transitions them — used by + * cancel/terminate tests so the lifecycle RPC has a non-terminal operation to act on. + */ + private UntypedNexusOperationHandle startPendingOperation() { + return startOperation(TestNexusServiceImpl.ASYNC_PREFIX + UUID.randomUUID()); + } + + /** + * Terminate is forceful and immediate per the proto contract; the server transitions the + * operation to TERMINATED regardless of handler state, so {@code getResult} promptly throws + * {@link NexusOperationFailedException}. + */ + private static void assertTerminatedFailure(UntypedNexusOperationHandle handle) { + try { + handle.getResult(15, java.util.concurrent.TimeUnit.SECONDS, String.class); + Assert.fail("expected getResult to throw after the operation was terminated"); + } catch (NexusOperationFailedException expected) { + // The TerminatedFailure shows up either on this exception's message or via getCause(). + } catch (java.util.concurrent.TimeoutException e) { + Assert.fail("getResult timed out — terminate should have produced a terminal outcome"); + } + } + + /** + * Cancel does NOT auto-transition the operation per the proto contract (handler cooperation is + * required). Assert via {@code describe()} that the cancellation request was at least recorded + * server-side. + */ + private static void assertCancellationRecorded( + UntypedNexusOperationHandle handle, @javax.annotation.Nullable String expectedReason) { + NexusOperationExecutionDescription description = handle.describe(); + Assert.assertNotNull( + "expected cancellation_info to be recorded after cancel()", + description.getCancellationInfo()); + if (expectedReason != null) { + Assert.assertEquals(expectedReason, description.getCancellationInfo().getReason()); + } } @Test @@ -165,17 +221,40 @@ public static class TestNexusServiceImpl { /** Inputs starting with this prefix make the handler throw, exercising the failure path. */ static final String FAIL_PREFIX = "FAIL:"; + /** + * Inputs starting with this prefix make the handler return an async-started result without ever + * completing the operation. Used by cancel/terminate tests so the operation stays in RUNNING + * state long enough for the lifecycle RPC to be observed. + */ + static final String ASYNC_PREFIX = "ASYNC:"; + @OperationImpl public OperationHandler operation() { - return OperationHandler.sync( - (context, details, input) -> { - if (input != null && input.startsWith(FAIL_PREFIX)) { - // OperationException.failed = definitive failure (no retries) so the caller's - // getResult surfaces the failure instead of timing out. - throw OperationException.failed("intentional failure: " + input); - } - return "echo:" + (input == null ? "" : input); - }); + return new OperationHandler() { + @Override + public OperationStartResult start( + OperationContext context, OperationStartDetails details, String input) + throws OperationException { + if (input != null && input.startsWith(FAIL_PREFIX)) { + // OperationException.failed = definitive failure (no retries) so the caller's + // getResult surfaces the failure instead of timing out. + throw OperationException.failed("intentional failure: " + input); + } + if (input != null && input.startsWith(ASYNC_PREFIX)) { + // Async-started: server keeps the operation in RUNNING state until something + // external (terminate, cancellation that takes effect, or schedule-to-close) + // transitions it. + return OperationStartResult.async("token-" + UUID.randomUUID()); + } + return OperationStartResult.sync("echo:" + (input == null ? "" : input)); + } + + @Override + public void cancel(OperationContext context, OperationCancelDetails details) { + // No-op. Tests assert cancellation visibility via describe()'s CancellationInfo + // rather than driving the operation to a terminal state from the handler. + } + }; } } From a94fb8c1a6222c51151181733a16ad07b6bd70e3 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 16:53:28 -0700 Subject: [PATCH 19/33] Adding more tests --- .../nexus/NexusOperationHandleTest.java | 88 ++++++++++++------- .../TestWorkflowMutableStateImpl.java | 1 - 2 files changed, 54 insertions(+), 35 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index ea4f27169..ac998daf9 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -23,6 +23,7 @@ import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Rule; @@ -75,51 +76,76 @@ public void describeWithoutRunIdTargetsLatest() { Assert.assertEquals(started.getNexusOperationRunId(), description.getRunId()); } + // The cancel call just requests the handler to cancel. + // It doesn't automatically cancel. So we are testing not that it + // cancelled the operation, but checking the number of cancel + // invokations the test server received to make sure it increments. @Test public void cancelSucceedsForStartedOperation() { - UntypedNexusOperationHandle handle = startPendingOperation(); - handle.cancel(); - assertCancellationRecorded(handle, /* expectedReason= */ null); + int before = TestNexusServiceImpl.cancelInvocations.get(); + startPendingOperation().cancel(); + assertCancelDelivered(before); } @Test public void cancelWithReasonSucceedsForStartedOperation() { - UntypedNexusOperationHandle handle = startPendingOperation(); - handle.cancel("test-cancel-reason"); - assertCancellationRecorded(handle, "test-cancel-reason"); + int before = TestNexusServiceImpl.cancelInvocations.get(); + startPendingOperation().cancel("test-cancel-reason"); + assertCancelDelivered(before); } @Test public void cancelWithNullReasonSucceeds() { - UntypedNexusOperationHandle handle = startPendingOperation(); - handle.cancel(null); - assertCancellationRecorded(handle, /* expectedReason= */ null); + int before = TestNexusServiceImpl.cancelInvocations.get(); + startPendingOperation().cancel(null); + assertCancelDelivered(before); + } + + /** + * Polls the handler's invocation counter to confirm the cancel RPC reached the worker and the + * handler's {@code cancel(...)} callback ran (the dispatch is asynchronous — server schedules a + * cancel task, worker polls it, then the callback fires). + */ + private static void assertCancelDelivered(int countBeforeCancel) { + long deadlineNanos = System.nanoTime() + Duration.ofSeconds(8).toNanos(); + while (TestNexusServiceImpl.cancelInvocations.get() <= countBeforeCancel + && System.nanoTime() < deadlineNanos) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + Assert.assertTrue( + "cancel RPC was not delivered to the handler within the poll budget", + TestNexusServiceImpl.cancelInvocations.get() > countBeforeCancel); } @Test public void terminateSucceedsForStartedOperation() { UntypedNexusOperationHandle handle = startPendingOperation(); handle.terminate(); - assertTerminatedFailure(handle); + assertTerminalFailure(handle); } @Test public void terminateWithReasonSucceedsForStartedOperation() { UntypedNexusOperationHandle handle = startPendingOperation(); handle.terminate("test-terminate-reason"); - assertTerminatedFailure(handle); + assertTerminalFailure(handle); } @Test public void terminateWithNullReasonSucceeds() { UntypedNexusOperationHandle handle = startPendingOperation(); handle.terminate(null); - assertTerminatedFailure(handle); + assertTerminalFailure(handle); } /** - * Async operations stay in RUNNING state until something external transitions them — used by - * cancel/terminate tests so the lifecycle RPC has a non-terminal operation to act on. + * Starts an operation whose handler returns an async-started result without ever completing, so + * the lifecycle RPCs have a non-terminal operation to act on. */ private UntypedNexusOperationHandle startPendingOperation() { return startOperation(TestNexusServiceImpl.ASYNC_PREFIX + UUID.randomUUID()); @@ -130,7 +156,7 @@ private UntypedNexusOperationHandle startPendingOperation() { * operation to TERMINATED regardless of handler state, so {@code getResult} promptly throws * {@link NexusOperationFailedException}. */ - private static void assertTerminatedFailure(UntypedNexusOperationHandle handle) { + private static void assertTerminalFailure(UntypedNexusOperationHandle handle) { try { handle.getResult(15, java.util.concurrent.TimeUnit.SECONDS, String.class); Assert.fail("expected getResult to throw after the operation was terminated"); @@ -141,22 +167,6 @@ private static void assertTerminatedFailure(UntypedNexusOperationHandle handle) } } - /** - * Cancel does NOT auto-transition the operation per the proto contract (handler cooperation is - * required). Assert via {@code describe()} that the cancellation request was at least recorded - * server-side. - */ - private static void assertCancellationRecorded( - UntypedNexusOperationHandle handle, @javax.annotation.Nullable String expectedReason) { - NexusOperationExecutionDescription description = handle.describe(); - Assert.assertNotNull( - "expected cancellation_info to be recorded after cancel()", - description.getCancellationInfo()); - if (expectedReason != null) { - Assert.assertEquals(expectedReason, description.getCancellationInfo().getReason()); - } - } - @Test public void getResultReturnsTypedResultForSyncOperation() { String result = NexusOperationHandle.fromUntyped(startOperation(), String.class).getResult(); @@ -228,6 +238,14 @@ public static class TestNexusServiceImpl { */ static final String ASYNC_PREFIX = "ASYNC:"; + /** + * Incremented every time the worker invokes the handler's {@code cancel(...)} callback. The + * cancel tests poll this counter to verify the cancel RPC was delivered end-to-end (client → + * server → worker), even though the no-op cancel doesn't drive the operation to a terminal + * state. + */ + static final AtomicInteger cancelInvocations = new AtomicInteger(); + @OperationImpl public OperationHandler operation() { return new OperationHandler() { @@ -243,7 +261,8 @@ public OperationStartResult start( if (input != null && input.startsWith(ASYNC_PREFIX)) { // Async-started: server keeps the operation in RUNNING state until something // external (terminate, cancellation that takes effect, or schedule-to-close) - // transitions it. + // transitions it. Terminate is server-forced so it transitions reliably; cancel is + // cooperative and won't transition without a backing entity. return OperationStartResult.async("token-" + UUID.randomUUID()); } return OperationStartResult.sync("echo:" + (input == null ? "" : input)); @@ -251,8 +270,9 @@ public OperationStartResult start( @Override public void cancel(OperationContext context, OperationCancelDetails details) { - // No-op. Tests assert cancellation visibility via describe()'s CancellationInfo - // rather than driving the operation to a terminal state from the handler. + // Record delivery for the cancel tests; otherwise a no-op. Driving the operation to a + // terminal CANCELED state would require a backing entity (e.g. a workflow). + cancelInvocations.incrementAndGet(); } }; } diff --git a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java index abea5b467..ba45f5251 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java @@ -37,7 +37,6 @@ import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; import io.temporal.api.update.v1.*; import io.temporal.api.workflow.v1.*; -import io.temporal.api.workflow.v1.OnConflictOptions; import io.temporal.api.workflowservice.v1.*; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.failure.ServerFailure; From afbf00222697e30ba124200d545aeb4ca34f0190 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 17:02:45 -0700 Subject: [PATCH 20/33] More checking in a test --- .../nexus/NexusOperationHandleTest.java | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index ac998daf9..cb3db43e6 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -12,6 +12,7 @@ import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; +import io.temporal.client.NexusOperationException; import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusOperationFailedException; import io.temporal.client.NexusOperationHandle; @@ -281,17 +282,37 @@ public void cancel(OperationContext context, OperationCancelDetails details) { @Test public void getResultPropagatesOperationFailure() { UntypedNexusOperationHandle handle = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); + String operationId = handle.getNexusOperationId(); try { handle.getResult(String.class); Assert.fail("expected getResult to throw because the operation handler failed"); - } catch (RuntimeException e) { - // The DataConverter wraps the proto Failure into a Java exception. Either the message - // carries the handler's reason, or one of the cause links does. - String combined = collectMessages(e); + } catch (NexusOperationException e) { + // Outer: NexusOperationFailedException carrying the failed operation's ID. Assert.assertTrue( - "expected exception chain to mention the handler failure, got: " + combined, - combined.contains("intentional failure")); + "expected NexusOperationFailedException, got " + e.getClass().getSimpleName(), + e instanceof NexusOperationFailedException); + Assert.assertEquals(operationId, e.getOperationId()); + Assert.assertTrue( + "expected outer message to reference the operation ID, got: " + e.getMessage(), + e.getMessage() != null && e.getMessage().contains(operationId)); + + // The full cause chain: dataConverter.failureToException(...) translates the proto Failure + // into a Java exception. Walk every link and verify the handler's reason surfaces somewhere. + boolean foundHandlerFailure = false; + for (Throwable c = e.getCause(); c != null; c = c.getCause()) { + if (c.getMessage() != null && c.getMessage().contains("intentional failure")) { + foundHandlerFailure = true; + break; + } + if (c.getCause() == c) { + break; + } + } + Assert.assertTrue( + "expected cause chain to include the handler's failure message, got: " + + collectMessages(e), + foundHandlerFailure); } } From 7f17beeb445f92f4bca916aeecb6814987c99bb2 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 17:35:41 -0700 Subject: [PATCH 21/33] Updated a test --- .../NexusOperationExecutionDescription.java | 26 +++++ .../client/nexus/NexusServiceClientTest.java | 108 +++--------------- 2 files changed, 45 insertions(+), 89 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java index 1c4670126..60910ed1b 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java @@ -181,6 +181,32 @@ public String getIdentity() { return i.isEmpty() ? null : i; } + /** + * Fixed summary attached when the operation was started, decoded from {@code UserMetadata}. + * Decoded on each call; cache the result if called frequently. + */ + @Nullable + public String getStaticSummary() { + if (!info.hasUserMetadata() || !info.getUserMetadata().hasSummary()) { + return null; + } + return dataConverter.fromPayload( + info.getUserMetadata().getSummary(), String.class, String.class); + } + + /** + * Fixed details attached when the operation was started, decoded from {@code UserMetadata}. + * Decoded on each call; cache the result if called frequently. + */ + @Nullable + public String getStaticDetails() { + if (!info.hasUserMetadata() || !info.getUserMetadata().hasDetails()) { + return null; + } + return dataConverter.fromPayload( + info.getUserMetadata().getDetails(), String.class, String.class); + } + /** * Whether the operation input payload is present on this description. Set only when {@link * UntypedNexusOperationHandle#describe()} was called with {@code includeInput=true}. diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index e10406d48..a4dd4fb5d 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -7,19 +7,14 @@ import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusOperationHandle; import io.temporal.client.NexusServiceClient; import io.temporal.client.StartNexusOperationOptions; -import io.temporal.common.SearchAttributeKey; -import io.temporal.common.SearchAttributes; -import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; -import io.temporal.common.interceptors.NexusClientCallsInterceptorBase; -import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; -import java.util.Collections; -import java.util.concurrent.atomic.AtomicReference; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Rule; @@ -69,94 +64,29 @@ public void startReturnsTypedHandleAndPollsResult() { } @Test - public void clientSummaryIsForwardedIntoStartInput() { - AtomicReference captured = new AtomicReference<>(); - RuntimeException sentinel = new RuntimeException("captured-by-test"); - - NexusClientInterceptor recordingFactory = - next -> - new NexusClientCallsInterceptorBase(next) { - @Override - public StartNexusOperationExecutionOutput startNexusOperationExecution( - StartNexusOperationExecutionInput input) { - captured.set(input); - throw sentinel; - } - }; - + public void clientSummaryReachesServer() { NexusServiceClient client = - NexusServiceClient.newInstance( - TestNexusServices.TestNexusService1.class, - "summary-test-endpoint", - testWorkflowRule.getWorkflowServiceStubs(), - NexusClientOptions.newBuilder() - .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) - .setInterceptors(Collections.singletonList(recordingFactory)) - .build()); + buildServiceClient(testWorkflowRule.getNexusEndpoint()); StartNexusOperationOptions startOptions = StartNexusOperationOptions.newBuilder().setSummary("per-call-summary").build(); - try { - client.start(TestNexusServices.TestNexusService1::operation, "ignored", startOptions); - Assert.fail("expected sentinel to be thrown by recording interceptor"); - } catch (RuntimeException e) { - Assert.assertSame(sentinel, e); - } - - StartNexusOperationExecutionInput input = captured.get(); - Assert.assertNotNull("interceptor should have captured a start input", input); - Assert.assertEquals( - "expected summary to be forwarded to the start input", - "per-call-summary", - input.getOptions().getSummary()); + NexusOperationHandle handle = + client.start(TestNexusServices.TestNexusService1::operation, "world", startOptions); + + // Describe round-trips the operation through the server, proving the summary was actually + // persisted on the server-side record rather than just forwarded through the local interceptor + // chain. + UntypedNexusOperationHandle untyped = + testWorkflowRule.getNexusClient().getHandle(handle.getNexusOperationId()); + NexusOperationExecutionDescription description = untyped.describe(); + Assert.assertEquals("per-call-summary", description.getStaticSummary()); } - @Test - public void clientSearchAttributesAreEncodedIntoStartInput() { - SearchAttributeKey customKey = SearchAttributeKey.forKeyword("CustomNexusTestKey"); - SearchAttributes attrs = SearchAttributes.newBuilder().set(customKey, "expected-value").build(); - - AtomicReference captured = new AtomicReference<>(); - RuntimeException sentinel = new RuntimeException("captured-by-test"); - - NexusClientInterceptor recordingFactory = - next -> - new NexusClientCallsInterceptorBase(next) { - @Override - public StartNexusOperationExecutionOutput startNexusOperationExecution( - StartNexusOperationExecutionInput input) { - captured.set(input); - throw sentinel; - } - }; - - NexusServiceClient client = - NexusServiceClient.newInstance( - TestNexusServices.TestNexusService1.class, - "search-attrs-test-endpoint", - testWorkflowRule.getWorkflowServiceStubs(), - NexusClientOptions.newBuilder() - .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) - .setInterceptors(Collections.singletonList(recordingFactory)) - .build()); - - StartNexusOperationOptions startOptions = - StartNexusOperationOptions.newBuilder().setTypedSearchAttributes(attrs).build(); - try { - client.start(TestNexusServices.TestNexusService1::operation, "ignored", startOptions); - Assert.fail("expected sentinel to be thrown by recording interceptor"); - } catch (RuntimeException e) { - Assert.assertSame(sentinel, e); - } - - StartNexusOperationExecutionInput input = captured.get(); - Assert.assertNotNull("interceptor should have captured a start input", input); - SearchAttributes capturedAttrs = input.getOptions().getTypedSearchAttributes(); - Assert.assertNotNull("expected search attributes to be forwarded", capturedAttrs); - Assert.assertTrue( - "expected the custom keyword to be present", capturedAttrs.containsKey(customKey)); - Assert.assertEquals("expected-value", capturedAttrs.get(customKey)); - } + // A search-attribute round-trip via describe() would naturally belong here, but the rule's + // `registerSearchAttribute(...)` is asynchronous on the server side and races the test — + // calling `start(...)` immediately afterwards fails with "no mapping defined for search + // attribute" until the namespace's Visibility index catches up. Reintroduce once the rule + // (or the test) synchronously waits for the mapping to propagate. private NexusServiceClient buildServiceClient( Endpoint endpoint) { From 8a3c2b88faea8ba087a4dcf645055a7775a61812 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 21:06:42 -0700 Subject: [PATCH 22/33] Adding async tests --- .../client/nexus/NexusAsyncApiTest.java | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java new file mode 100644 index 000000000..2b68fa77b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java @@ -0,0 +1,238 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.OperationStartDetails; +import io.nexusrpc.handler.OperationStartResult; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationException; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.client.NexusOperationHandle; +import io.temporal.client.NexusServiceClient; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +/** + * Coverage tests for the {@link CompletableFuture}-returning surface on the standalone Nexus + * client: {@link NexusServiceClient#executeAsync executeAsync} on the typed service client, plus + * the {@code getResultAsync} overloads on both {@link NexusOperationHandle} and {@link + * UntypedNexusOperationHandle}. Each overload is asserted against the existing sync echo handler so + * the Java async API is exercised without depending on server-side async completion. + */ +public class NexusAsyncApiTest { + + private static final Duration FUTURE_GET_TIMEOUT = Duration.ofSeconds(30); + private static final String FAIL_PREFIX = "FAIL:"; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @BeforeClass + public static void requireExternalService() { + // The time-skipping test server does not implement standalone Nexus operation RPCs. + assumeTrue( + "standalone Nexus operations require a real server", + SDKTestWorkflowRule.useExternalService); + } + + // --- NexusServiceClient.executeAsync --- + + @Test + public void serviceClientExecuteAsyncReturnsResult() throws Exception { + String result = + buildServiceClient() + .executeAsync(TestNexusServices.TestNexusService1::operation, "hello") + .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:hello", result); + } + + @Test + public void serviceClientExecuteAsyncWithOptionsReturnsResult() throws Exception { + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + + String result = + buildServiceClient() + .executeAsync(TestNexusServices.TestNexusService1::operation, "world", options) + .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:world", result); + } + + // --- NexusOperationHandle (typed) getResultAsync overloads --- + + @Test + public void typedHandleGetResultAsyncReturnsResult() throws Exception { + NexusOperationHandle handle = + buildServiceClient().start(TestNexusServices.TestNexusService1::operation, "typed"); + + String result = handle.getResultAsync().get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:typed", result); + } + + @Test + public void typedHandleGetResultAsyncWithTimeoutReturnsResult() throws Exception { + NexusOperationHandle handle = + buildServiceClient().start(TestNexusServices.TestNexusService1::operation, "typed-tm"); + + String result = + handle + .getResultAsync(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS) + .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:typed-tm", result); + } + + // --- UntypedNexusOperationHandle getResultAsync overloads --- + + @Test + public void untypedHandleGetResultAsyncByClassReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped"); + + String result = + handle.getResultAsync(String.class).get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:untyped", result); + } + + @Test + public void untypedHandleGetResultAsyncByClassAndTypeReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped-gen"); + + String result = + handle + .getResultAsync(String.class, String.class) + .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:untyped-gen", result); + } + + @Test + public void untypedHandleGetResultAsyncWithTimeoutByClassReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped-tm"); + + String result = + handle + .getResultAsync(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS, String.class) + .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:untyped-tm", result); + } + + @Test + public void untypedHandleGetResultAsyncWithTimeoutByClassAndTypeReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped-tm-gen"); + + String result = + handle + .getResultAsync( + FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS, String.class, String.class) + .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + + Assert.assertEquals("echo:untyped-tm-gen", result); + } + + // --- Failure path --- + + @Test + public void executeAsyncPropagatesOperationFailure() throws Exception { + CompletableFuture future = + buildServiceClient() + .executeAsync(TestNexusServices.TestNexusService1::operation, FAIL_PREFIX + "boom"); + + try { + future.get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); + Assert.fail("expected future to complete exceptionally"); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + Assert.assertTrue( + "expected NexusOperationException, got " + + (cause == null ? "null" : cause.getClass().getSimpleName()), + cause instanceof NexusOperationException); + Assert.assertTrue( + "expected NexusOperationFailedException, got " + cause.getClass().getSimpleName(), + cause instanceof NexusOperationFailedException); + } + } + + // --- helpers --- + + private NexusServiceClient buildServiceClient() { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + return NexusServiceClient.newInstance( + TestNexusServices.TestNexusService1.class, + endpoint.getSpec().getName(), + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + } + + private UntypedNexusOperationHandle startUntyped(String input) { + NexusClient client = testWorkflowRule.getNexusClient(); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + return svcClient.start("operation", StartNexusOperationOptions.newBuilder().build(), input); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return new OperationHandler() { + @Override + public OperationStartResult start( + OperationContext context, OperationStartDetails details, String input) + throws OperationException { + if (input != null && input.startsWith(FAIL_PREFIX)) { + throw OperationException.failed("intentional failure: " + input); + } + return OperationStartResult.sync("echo:" + (input == null ? "" : input)); + } + + @Override + public void cancel(OperationContext context, OperationCancelDetails details) { + // Unused in these tests. + } + }; + } + } +} From 8bc345ed548d220f76448b974369c4daea0592e3 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 21:34:09 -0700 Subject: [PATCH 23/33] Some improvements --- .../NexusOperationExecutionDescription.java | 12 +-- .../NexusOperationExecutionMetadata.java | 50 ++++++------ .../client/nexus/NexusAsyncApiTest.java | 53 +++++-------- .../nexus/NexusOperationHandleTest.java | 78 +++---------------- .../client/nexus/NexusServiceClientTest.java | 15 +--- 5 files changed, 62 insertions(+), 146 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java index 60910ed1b..22b6e5381 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java @@ -33,12 +33,12 @@ public NexusOperationExecutionDescription( /* rawListInfo= */ null, response.getInfo().getOperationId(), nullIfEmpty(response.getInfo().getRunId()), - response.getInfo().getEndpoint(), - response.getInfo().getService(), - response.getInfo().getOperation(), + nullIfEmpty(response.getInfo().getEndpoint()), + nullIfEmpty(response.getInfo().getService()), + nullIfEmpty(response.getInfo().getOperation()), response.getInfo().hasScheduleTime() ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime()) - : Instant.EPOCH, + : null, response.getInfo().hasCloseTime() ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime()) : null, @@ -53,10 +53,6 @@ public NexusOperationExecutionDescription( this.dataConverter = dataConverter; } - private static @Nullable String nullIfEmpty(String s) { - return s == null || s.isEmpty() ? null : s; - } - /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ @Nonnull public DescribeNexusOperationExecutionResponse getRawResponse() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java index 075e2e42f..1db6328fa 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java @@ -22,10 +22,10 @@ public class NexusOperationExecutionMetadata { private final @Nullable NexusOperationExecutionListInfo rawListInfo; private final String operationId; private final @Nullable String runId; - private final String endpoint; - private final String service; - private final String operation; - private final Instant scheduledTime; + private final @Nullable String endpoint; + private final @Nullable String service; + private final @Nullable String operation; + private final @Nullable Instant scheduledTime; private final @Nullable Instant closeTime; private final NexusOperationExecutionStatus status; private final SearchAttributes searchAttributes; @@ -36,10 +36,10 @@ public class NexusOperationExecutionMetadata { @Nullable NexusOperationExecutionListInfo rawListInfo, String operationId, @Nullable String runId, - String endpoint, - String service, - String operation, - Instant scheduledTime, + @Nullable String endpoint, + @Nullable String service, + @Nullable String operation, + @Nullable Instant scheduledTime, @Nullable Instant closeTime, NexusOperationExecutionStatus status, SearchAttributes searchAttributes, @@ -59,16 +59,19 @@ public class NexusOperationExecutionMetadata { this.executionDuration = executionDuration; } + static @Nullable String nullIfEmpty(String s) { + return s == null || s.isEmpty() ? null : s; + } + public static NexusOperationExecutionMetadata fromListInfo(NexusOperationExecutionListInfo info) { - String runId = info.getRunId(); return new NexusOperationExecutionMetadata( info, info.getOperationId(), - runId.isEmpty() ? null : runId, - info.getEndpoint(), - info.getService(), - info.getOperation(), - ProtobufTimeUtils.toJavaInstant(info.getScheduleTime()), + nullIfEmpty(info.getRunId()), + nullIfEmpty(info.getEndpoint()), + nullIfEmpty(info.getService()), + nullIfEmpty(info.getOperation()), + info.hasScheduleTime() ? ProtobufTimeUtils.toJavaInstant(info.getScheduleTime()) : null, info.hasCloseTime() ? ProtobufTimeUtils.toJavaInstant(info.getCloseTime()) : null, info.getStatus(), SearchAttributesUtil.decodeTyped(info.getSearchAttributes()), @@ -99,26 +102,29 @@ public String getRunId() { return runId; } - /** The Nexus endpoint name this operation targets. */ - @Nonnull + /** The Nexus endpoint name this operation targets. {@code null} if the server omitted it. */ + @Nullable public String getEndpoint() { return endpoint; } - /** The Nexus service name on the endpoint. */ - @Nonnull + /** The Nexus service name on the endpoint. {@code null} if the server omitted it. */ + @Nullable public String getService() { return service; } - /** The Nexus operation name within the service. */ - @Nonnull + /** The Nexus operation name within the service. {@code null} if the server omitted it. */ + @Nullable public String getOperation() { return operation; } - /** Time when the operation was originally scheduled via a {@code StartNexusOperation} request. */ - @Nonnull + /** + * Time when the operation was originally scheduled via a {@code StartNexusOperation} request. + * {@code null} if the server omitted it. + */ + @Nullable public Instant getScheduledTime() { return scheduledTime; } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java index 2b68fa77b..0c84437bf 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java @@ -2,14 +2,6 @@ import static org.junit.Assume.assumeTrue; -import io.nexusrpc.OperationException; -import io.nexusrpc.handler.OperationCancelDetails; -import io.nexusrpc.handler.OperationContext; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; -import io.nexusrpc.handler.OperationStartDetails; -import io.nexusrpc.handler.OperationStartResult; -import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusClientOptions; @@ -21,6 +13,7 @@ import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; @@ -42,13 +35,12 @@ public class NexusAsyncApiTest { private static final Duration FUTURE_GET_TIMEOUT = Duration.ofSeconds(30); - private static final String FAIL_PREFIX = "FAIL:"; @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(PlaceholderWorkflowImpl.class) - .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) .build(); @BeforeClass @@ -166,7 +158,9 @@ public void untypedHandleGetResultAsyncWithTimeoutByClassAndTypeReturnsResult() public void executeAsyncPropagatesOperationFailure() throws Exception { CompletableFuture future = buildServiceClient() - .executeAsync(TestNexusServices.TestNexusService1::operation, FAIL_PREFIX + "boom"); + .executeAsync( + TestNexusServices.TestNexusService1::operation, + EchoNexusServiceImpl.FAIL_PREFIX + "boom"); try { future.get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); @@ -180,6 +174,20 @@ public void executeAsyncPropagatesOperationFailure() throws Exception { Assert.assertTrue( "expected NexusOperationFailedException, got " + cause.getClass().getSimpleName(), cause instanceof NexusOperationFailedException); + + // Walk the cause chain and verify the handler's failure message surfaces somewhere. + boolean foundHandlerFailure = false; + for (Throwable c = cause.getCause(); c != null; c = c.getCause()) { + if (c.getMessage() != null && c.getMessage().contains("intentional failure")) { + foundHandlerFailure = true; + break; + } + if (c.getCause() == c) { + break; + } + } + Assert.assertTrue( + "expected cause chain to include the handler's failure message", foundHandlerFailure); } } @@ -212,27 +220,4 @@ public String execute(String input) { return input; } } - - @ServiceImpl(service = TestNexusServices.TestNexusService1.class) - public static class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return new OperationHandler() { - @Override - public OperationStartResult start( - OperationContext context, OperationStartDetails details, String input) - throws OperationException { - if (input != null && input.startsWith(FAIL_PREFIX)) { - throw OperationException.failed("intentional failure: " + input); - } - return OperationStartResult.sync("echo:" + (input == null ? "" : input)); - } - - @Override - public void cancel(OperationContext context, OperationCancelDetails details) { - // Unused in these tests. - } - }; - } - } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index cb3db43e6..bc04aae4e 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -2,14 +2,6 @@ import static org.junit.Assume.assumeTrue; -import io.nexusrpc.OperationException; -import io.nexusrpc.handler.OperationCancelDetails; -import io.nexusrpc.handler.OperationContext; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; -import io.nexusrpc.handler.OperationStartDetails; -import io.nexusrpc.handler.OperationStartResult; -import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusOperationException; @@ -20,11 +12,11 @@ import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Rule; @@ -41,7 +33,7 @@ public class NexusOperationHandleTest { public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(PlaceholderWorkflowImpl.class) - .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) .build(); @BeforeClass @@ -83,21 +75,21 @@ public void describeWithoutRunIdTargetsLatest() { // invokations the test server received to make sure it increments. @Test public void cancelSucceedsForStartedOperation() { - int before = TestNexusServiceImpl.cancelInvocations.get(); + int before = EchoNexusServiceImpl.cancelInvocations.get(); startPendingOperation().cancel(); assertCancelDelivered(before); } @Test public void cancelWithReasonSucceedsForStartedOperation() { - int before = TestNexusServiceImpl.cancelInvocations.get(); + int before = EchoNexusServiceImpl.cancelInvocations.get(); startPendingOperation().cancel("test-cancel-reason"); assertCancelDelivered(before); } @Test public void cancelWithNullReasonSucceeds() { - int before = TestNexusServiceImpl.cancelInvocations.get(); + int before = EchoNexusServiceImpl.cancelInvocations.get(); startPendingOperation().cancel(null); assertCancelDelivered(before); } @@ -109,7 +101,7 @@ public void cancelWithNullReasonSucceeds() { */ private static void assertCancelDelivered(int countBeforeCancel) { long deadlineNanos = System.nanoTime() + Duration.ofSeconds(8).toNanos(); - while (TestNexusServiceImpl.cancelInvocations.get() <= countBeforeCancel + while (EchoNexusServiceImpl.cancelInvocations.get() <= countBeforeCancel && System.nanoTime() < deadlineNanos) { try { Thread.sleep(100); @@ -120,7 +112,7 @@ private static void assertCancelDelivered(int countBeforeCancel) { } Assert.assertTrue( "cancel RPC was not delivered to the handler within the poll budget", - TestNexusServiceImpl.cancelInvocations.get() > countBeforeCancel); + EchoNexusServiceImpl.cancelInvocations.get() > countBeforeCancel); } @Test @@ -149,7 +141,7 @@ public void terminateWithNullReasonSucceeds() { * the lifecycle RPCs have a non-terminal operation to act on. */ private UntypedNexusOperationHandle startPendingOperation() { - return startOperation(TestNexusServiceImpl.ASYNC_PREFIX + UUID.randomUUID()); + return startOperation(EchoNexusServiceImpl.ASYNC_PREFIX + UUID.randomUUID()); } /** @@ -227,61 +219,9 @@ public String execute(String input) { } } - @ServiceImpl(service = TestNexusServices.TestNexusService1.class) - public static class TestNexusServiceImpl { - /** Inputs starting with this prefix make the handler throw, exercising the failure path. */ - static final String FAIL_PREFIX = "FAIL:"; - - /** - * Inputs starting with this prefix make the handler return an async-started result without ever - * completing the operation. Used by cancel/terminate tests so the operation stays in RUNNING - * state long enough for the lifecycle RPC to be observed. - */ - static final String ASYNC_PREFIX = "ASYNC:"; - - /** - * Incremented every time the worker invokes the handler's {@code cancel(...)} callback. The - * cancel tests poll this counter to verify the cancel RPC was delivered end-to-end (client → - * server → worker), even though the no-op cancel doesn't drive the operation to a terminal - * state. - */ - static final AtomicInteger cancelInvocations = new AtomicInteger(); - - @OperationImpl - public OperationHandler operation() { - return new OperationHandler() { - @Override - public OperationStartResult start( - OperationContext context, OperationStartDetails details, String input) - throws OperationException { - if (input != null && input.startsWith(FAIL_PREFIX)) { - // OperationException.failed = definitive failure (no retries) so the caller's - // getResult surfaces the failure instead of timing out. - throw OperationException.failed("intentional failure: " + input); - } - if (input != null && input.startsWith(ASYNC_PREFIX)) { - // Async-started: server keeps the operation in RUNNING state until something - // external (terminate, cancellation that takes effect, or schedule-to-close) - // transitions it. Terminate is server-forced so it transitions reliably; cancel is - // cooperative and won't transition without a backing entity. - return OperationStartResult.async("token-" + UUID.randomUUID()); - } - return OperationStartResult.sync("echo:" + (input == null ? "" : input)); - } - - @Override - public void cancel(OperationContext context, OperationCancelDetails details) { - // Record delivery for the cancel tests; otherwise a no-op. Driving the operation to a - // terminal CANCELED state would require a backing entity (e.g. a workflow). - cancelInvocations.incrementAndGet(); - } - }; - } - } - @Test public void getResultPropagatesOperationFailure() { - UntypedNexusOperationHandle handle = startOperation(TestNexusServiceImpl.FAIL_PREFIX + "boom"); + UntypedNexusOperationHandle handle = startOperation(EchoNexusServiceImpl.FAIL_PREFIX + "boom"); String operationId = handle.getNexusOperationId(); try { diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index a4dd4fb5d..75e29d245 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -2,9 +2,6 @@ import static org.junit.Assume.assumeTrue; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; -import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClientOptions; import io.temporal.client.NexusOperationExecutionDescription; @@ -13,6 +10,7 @@ import io.temporal.client.StartNexusOperationOptions; import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import org.junit.Assert; @@ -30,7 +28,7 @@ public class NexusServiceClientTest { public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(PlaceholderWorkflowImpl.class) - .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) .build(); @BeforeClass @@ -105,13 +103,4 @@ public String execute(String input) { return input; } } - - @ServiceImpl(service = TestNexusServices.TestNexusService1.class) - public static class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return OperationHandler.sync( - (context, details, input) -> "echo:" + (input == null ? "" : input)); - } - } } From fc65dab6136a02d1f457fae4680657797a2eece2 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 22:02:28 -0700 Subject: [PATCH 24/33] Adding tests --- .../client/nexus/NexusClientTest.java | 125 ++++++++++++++++-- .../nexus/NexusOperationHandleTest.java | 54 ++++++++ .../client/nexus/NexusServiceClientTest.java | 39 ++++++ 3 files changed, 205 insertions(+), 13 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 01e0f6563..2042da62f 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -2,9 +2,6 @@ import static org.junit.Assume.assumeTrue; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; -import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusOperationExecutionCount; @@ -13,11 +10,14 @@ import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; +import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Rule; @@ -29,7 +29,7 @@ public class NexusClientTest { public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(NexusClientTest.PlaceholderWorkflowImpl.class) - .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) .build(); @BeforeClass @@ -112,6 +112,114 @@ public void runStandaloneNexusOperation() throws Exception { Assert.assertTrue(countNexusOperations() > initialCount); } + @Test + public void listNexusOperationExecutionsWithQueryFiltersResults() throws Exception { + // Run a known operation through to completion, then assert that an OperationId-scoped query + // narrows the list to exactly that one row. Uses a built-in visibility field (OperationId), so + // the async search-attribute registration race that affects custom SAs doesn't apply. + String operationId = startAndAwaitSyncOperation("list-query"); + NexusClient client = testWorkflowRule.getNexusClient(); + + // Sync on the unfiltered list first so the visibility index has indexed our operation; the + // filtered query reads from the same index. + Assert.assertNotNull( + "expected operation to appear in visibility before filtered query", + waitForListedOperation(client, operationId, Duration.ofSeconds(15))); + + String query = "OperationId='" + operationId + "'"; + List results = + client.listNexusOperationExecutions(query).collect(Collectors.toList()); + + // OperationId is unique server-side, so the filter must produce exactly one row — proving the + // query string actually narrowed results rather than being a no-op passthrough. + Assert.assertEquals("expected exactly one match for query: " + query, 1, results.size()); + Assert.assertEquals(operationId, results.get(0).getOperationId()); + } + + @Test + public void countNexusOperationExecutionsWithQueryFiltersResults() throws Exception { + String operationId = startAndAwaitSyncOperation("count-query"); + NexusClient client = testWorkflowRule.getNexusClient(); + + Assert.assertNotNull( + "expected operation to appear in visibility before filtered count", + waitForListedOperation(client, operationId, Duration.ofSeconds(15))); + + String query = "OperationId='" + operationId + "'"; + NexusOperationExecutionCount count = client.countNexusOperationExecutions(query); + + Assert.assertEquals("expected exactly one match for query: " + query, 1L, count.getCount()); + } + + /** + * Starts a sync echo operation with a unique input, blocks until it completes, and returns the + * operation ID. Used by the filtered list/count tests to obtain a known operation to query for. + */ + private String startAndAwaitSyncOperation(String label) throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusOperationHandle handle = + svcClient.start("operation", opts, label + "-" + UUID.randomUUID()); + handle.getResult(60, TimeUnit.SECONDS, String.class); + return handle.getNexusOperationId(); + } + + @Test + public void untypedExecuteByClassReturnsResult() { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + + String result = + svcClient.execute( + "operation", + String.class, + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "untyped-exec"); + + Assert.assertEquals("echo:untyped-exec", result); + } + + @Test + public void untypedExecuteByClassAndTypeReturnsResult() { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + + // The Type overload exists for generic results (e.g. List); exercising it with the same + // class/type here proves the path is wired through to the data converter. + String result = + svcClient.execute( + "operation", + String.class, + String.class, + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "untyped-exec-typed"); + + Assert.assertEquals("echo:untyped-exec-typed", result); + } + private NexusOperationExecutionMetadata waitForListedOperation( NexusClient client, String operationId, Duration timeout) throws InterruptedException { long deadlineNanos = System.nanoTime() + timeout.toNanos(); @@ -136,13 +244,4 @@ public String execute(String input) { return input; } } - - @ServiceImpl(service = TestNexusServices.TestNexusService1.class) - public static class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return OperationHandler.sync( - (context, details, input) -> "echo:" + (input == null ? "" : input)); - } - } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index bc04aae4e..440b0bc9f 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -2,12 +2,14 @@ import static org.junit.Assume.assumeTrue; +import io.temporal.api.enums.v1.NexusOperationExecutionStatus; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusOperationException; import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusOperationFailedException; import io.temporal.client.NexusOperationHandle; +import io.temporal.client.NexusOperationNotFoundException; import io.temporal.client.StartNexusOperationOptions; import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.client.UntypedNexusServiceClient; @@ -56,6 +58,44 @@ public void describeReturnsDescriptionForStartedOperation() { Assert.assertNotNull(description.getRawResponse()); } + @Test + public void describeReturnsTerminalStateAfterSyncOperationCompletes() { + // Drive a sync echo through to completion, then assert describe surfaces the terminal state. + UntypedNexusOperationHandle handle = startOperation(); + String expected = handle.getResult(String.class); + + NexusOperationExecutionDescription description = handle.describe(); + + Assert.assertEquals( + NexusOperationExecutionStatus.NEXUS_OPERATION_EXECUTION_STATUS_COMPLETED, + description.getStatus()); + Assert.assertNotNull("expected closeTime once terminal", description.getCloseTime()); + Assert.assertNotNull( + "expected executionDuration once terminal", description.getExecutionDuration()); + // describe() defaults to includeOutcome=true, so the success payload should be present. + Assert.assertTrue( + "expected description.hasResult() after a successful sync operation", + description.hasResult()); + Assert.assertEquals(expected, description.getResult(String.class).orElse(null)); + Assert.assertNull("expected no failure on a successful operation", description.getFailure()); + } + + @Test + public void describeThrowsForUnknownOperationId() { + // Mint an operation ID that the server has never seen; describe must surface the typed + // NOT_FOUND-mapped exception rather than a raw gRPC status. + String bogusOperationId = "does-not-exist-" + UUID.randomUUID(); + UntypedNexusOperationHandle handle = + testWorkflowRule.getNexusClient().getHandle(bogusOperationId); + + try { + handle.describe(); + Assert.fail("expected NexusOperationNotFoundException for an unknown operation ID"); + } catch (NexusOperationNotFoundException expected) { + Assert.assertEquals(bogusOperationId, expected.getOperationId()); + } + } + @Test public void describeWithoutRunIdTargetsLatest() { UntypedNexusOperationHandle started = startOperation(); @@ -122,6 +162,20 @@ public void terminateSucceedsForStartedOperation() { assertTerminalFailure(handle); } + @Test + public void terminateTransitionsOperationToTerminatedStatus() { + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate("status-assertion"); + // assertTerminalFailure proves getResult observed terminality; describe must agree that the + // server-side status was specifically TERMINATED (not CANCELED/FAILED/TIMED_OUT). + assertTerminalFailure(handle); + NexusOperationExecutionDescription description = handle.describe(); + Assert.assertEquals( + NexusOperationExecutionStatus.NEXUS_OPERATION_EXECUTION_STATUS_TERMINATED, + description.getStatus()); + Assert.assertNotNull(description.getCloseTime()); + } + @Test public void terminateWithReasonSucceedsForStartedOperation() { UntypedNexusOperationHandle handle = startPendingOperation(); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index 75e29d245..e0f1ecf57 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -13,6 +13,8 @@ import io.temporal.workflow.shared.EchoNexusServiceImpl; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; import org.junit.Assert; import org.junit.BeforeClass; import org.junit.Rule; @@ -61,6 +63,43 @@ public void startReturnsTypedHandleAndPollsResult() { Assert.assertEquals("echo:world", handle.getResult()); } + @Test + public void executeWithOptionsReturnsResult() { + // Covers the 3-arg execute(op, input, options) overload — the no-options variant is already + // covered by executeReturnsTypedResult. + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + + String result = + buildServiceClient(testWorkflowRule.getNexusEndpoint()) + .execute(TestNexusServices.TestNexusService1::operation, "with-opts", options); + + Assert.assertEquals("echo:with-opts", result); + } + + @Test + public void startWithExplicitIdHonoursId() { + String explicitId = "explicit-id-" + UUID.randomUUID(); + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setId(explicitId) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + + NexusOperationHandle handle = + buildServiceClient(testWorkflowRule.getNexusEndpoint()) + .start(TestNexusServices.TestNexusService1::operation, "id-test", options); + + Assert.assertEquals( + "explicit ID supplied via StartNexusOperationOptions.setId must round-trip on the handle", + explicitId, + handle.getNexusOperationId()); + // Sanity-check: the operation still completes normally with the explicit ID. + Assert.assertEquals("echo:id-test", handle.getResult()); + } + @Test public void clientSummaryReachesServer() { NexusServiceClient client = From ec9e10357fc2cbae18abd256830ef80c89c18910 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 28 May 2026 22:02:42 -0700 Subject: [PATCH 25/33] Adding tests --- .../workflow/shared/EchoNexusServiceImpl.java | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java b/temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java new file mode 100644 index 000000000..7b1961fa0 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java @@ -0,0 +1,74 @@ +package io.temporal.workflow.shared; + +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.OperationStartDetails; +import io.nexusrpc.handler.OperationStartResult; +import io.nexusrpc.handler.ServiceImpl; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Shared {@link TestNexusServices.TestNexusService1} implementation used by the standalone Nexus + * client tests. Behaviour is driven entirely by the input string: + * + *
    + *
  • An input starting with {@link #FAIL_PREFIX} causes {@code start} to throw an {@link + * OperationException#failed} so callers see a non-retryable handler failure. + *
  • An input starting with {@link #ASYNC_PREFIX} causes {@code start} to return an + * async-started result with a synthetic operation token; the operation stays in {@code + * RUNNING} until something terminal (cancel that takes effect, terminate, schedule-to-close) + * transitions it. + *
  • Any other input is echoed back as {@code "echo:" + input}. + *
+ * + *

Cancel callbacks increment {@link #cancelInvocations} so tests can assert cancel-RPC delivery + * end-to-end. The counter is process-wide; tests that care should capture a baseline before the + * cancel and assert the post-cancel value is strictly greater. + */ +@ServiceImpl(service = TestNexusServices.TestNexusService1.class) +public class EchoNexusServiceImpl { + + /** Inputs starting with this prefix make {@code start} throw, exercising the failure path. */ + public static final String FAIL_PREFIX = "FAIL:"; + + /** + * Inputs starting with this prefix make {@code start} return an async-started result without ever + * completing the operation. Used by cancel/terminate tests so the operation stays in {@code + * RUNNING} long enough for the lifecycle RPC to be observed. + */ + public static final String ASYNC_PREFIX = "ASYNC:"; + + /** + * Incremented every time the worker invokes the handler's {@code cancel(...)} callback. Tests + * that want to assert end-to-end cancel-RPC delivery (client → server → worker) read the value + * before the cancel, issue the cancel, then poll until this counter exceeds the baseline. + */ + public static final AtomicInteger cancelInvocations = new AtomicInteger(); + + @OperationImpl + public OperationHandler operation() { + return new OperationHandler() { + @Override + public OperationStartResult start( + OperationContext context, OperationStartDetails details, String input) + throws OperationException { + if (input != null && input.startsWith(FAIL_PREFIX)) { + throw OperationException.failed("intentional failure: " + input); + } + if (input != null && input.startsWith(ASYNC_PREFIX)) { + return OperationStartResult.async("token-" + UUID.randomUUID()); + } + return OperationStartResult.sync("echo:" + (input == null ? "" : input)); + } + + @Override + public void cancel(OperationContext context, OperationCancelDetails details) { + cancelInvocations.incrementAndGet(); + } + }; + } +} From 4ffa0cd4673f6a91bbb053f93297ecebb07b7679 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 08:59:49 -0700 Subject: [PATCH 26/33] Don't run the tests against the in-memory server as it doesn't support SANO yet --- .../io/temporal/client/nexus/NexusAsyncApiTest.java | 10 +++------- .../java/io/temporal/client/nexus/NexusClientTest.java | 10 +++------- .../client/nexus/NexusOperationHandleTest.java | 10 +++------- .../temporal/client/nexus/NexusServiceClientTest.java | 10 +++------- 4 files changed, 12 insertions(+), 28 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java index 0c84437bf..e053c3142 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java @@ -1,7 +1,5 @@ package io.temporal.client.nexus; -import static org.junit.Assume.assumeTrue; - import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusClientOptions; @@ -14,6 +12,7 @@ import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.StandaloneNexusTestPrerequisites; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; @@ -44,11 +43,8 @@ public class NexusAsyncApiTest { .build(); @BeforeClass - public static void requireExternalService() { - // The time-skipping test server does not implement standalone Nexus operation RPCs. - assumeTrue( - "standalone Nexus operations require a real server", - SDKTestWorkflowRule.useExternalService); + public static void requireServerWithStandaloneNexusSupport() { + StandaloneNexusTestPrerequisites.requireServerSupport(); } // --- NexusServiceClient.executeAsync --- diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 2042da62f..b0326e339 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -1,7 +1,5 @@ package io.temporal.client.nexus; -import static org.junit.Assume.assumeTrue; - import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; import io.temporal.client.NexusOperationExecutionCount; @@ -11,6 +9,7 @@ import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.StandaloneNexusTestPrerequisites; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; @@ -33,11 +32,8 @@ public class NexusClientTest { .build(); @BeforeClass - public static void requireExternalService() { - // The time-skipping test server does not implement standalone Nexus operation RPCs. - assumeTrue( - "standalone Nexus operations require a real server", - SDKTestWorkflowRule.useExternalService); + public static void requireServerWithStandaloneNexusSupport() { + StandaloneNexusTestPrerequisites.requireServerSupport(); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index 440b0bc9f..2eb3f2547 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -1,7 +1,5 @@ package io.temporal.client.nexus; -import static org.junit.Assume.assumeTrue; - import io.temporal.api.enums.v1.NexusOperationExecutionStatus; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClient; @@ -15,6 +13,7 @@ import io.temporal.client.UntypedNexusServiceClient; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.StandaloneNexusTestPrerequisites; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; @@ -39,11 +38,8 @@ public class NexusOperationHandleTest { .build(); @BeforeClass - public static void requireExternalService() { - // The time-skipping test server does not implement standalone Nexus operation RPCs. - assumeTrue( - "standalone Nexus operations require a real server", - SDKTestWorkflowRule.useExternalService); + public static void requireServerWithStandaloneNexusSupport() { + StandaloneNexusTestPrerequisites.requireServerSupport(); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index e0f1ecf57..6ea3a7f1e 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -1,7 +1,5 @@ package io.temporal.client.nexus; -import static org.junit.Assume.assumeTrue; - import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.NexusClientOptions; import io.temporal.client.NexusOperationExecutionDescription; @@ -11,6 +9,7 @@ import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.StandaloneNexusTestPrerequisites; import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; @@ -34,11 +33,8 @@ public class NexusServiceClientTest { .build(); @BeforeClass - public static void requireExternalService() { - // The time-skipping test server does not implement standalone Nexus operation RPCs. - assumeTrue( - "standalone Nexus operations require a real server", - SDKTestWorkflowRule.useExternalService); + public static void requireServerWithStandaloneNexusSupport() { + StandaloneNexusTestPrerequisites.requireServerSupport(); } @Test From c308856e41b0a568b7c6715af2c0e3ec2226c10c Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 09:00:09 -0700 Subject: [PATCH 27/33] Adding prereq check for SANO --- .../StandaloneNexusTestPrerequisites.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/shared/StandaloneNexusTestPrerequisites.java diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/shared/StandaloneNexusTestPrerequisites.java b/temporal-sdk/src/test/java/io/temporal/workflow/shared/StandaloneNexusTestPrerequisites.java new file mode 100644 index 000000000..3275fc49b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/shared/StandaloneNexusTestPrerequisites.java @@ -0,0 +1,87 @@ +package io.temporal.workflow.shared; + +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsRequest; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import org.junit.Assume; + +/** + * Skip-guard for tests that exercise standalone Nexus operation RPCs ({@code + * StartNexusOperationExecution}, {@code DescribeNexusOperationExecution}, etc.). Call from + * {@code @BeforeClass}. + * + *

{@link SDKTestWorkflowRule#useExternalService} only tells us that an external server is + * reachable — not that it implements every RPC we want to call. The Temporal CLI's {@code + * start-dev} server (which CI uses for the "Unit test with CLI" job) accepts connections but + * returns {@code UNIMPLEMENTED} for the standalone Nexus RPCs. Hitting that mid-test produces a + * confusing failure; this guard probes the server once and skips the suite cleanly if the RPCs + * aren't wired through. + * + *

Suites are skipped via {@link Assume#assumeTrue} when: + * + *

    + *
  • the rule is using the in-memory test server ({@code USE_EXTERNAL_SERVICE} unset/false), or + *
  • the configured external server returns {@code UNIMPLEMENTED} for a standalone Nexus RPC. + *
+ */ +public final class StandaloneNexusTestPrerequisites { + + private static volatile Boolean cachedServerSupportsStandaloneNexus; + private static final Object PROBE_LOCK = new Object(); + + private StandaloneNexusTestPrerequisites() {} + + /** + * Skips the calling suite if the configured server doesn't support standalone Nexus RPCs. Probes + * the server at most once per JVM and caches the outcome. + */ + public static void requireServerSupport() { + Assume.assumeTrue( + "standalone Nexus operations require an external server (USE_EXTERNAL_SERVICE=true)", + SDKTestWorkflowRule.useExternalService); + Assume.assumeTrue( + "configured external server does not implement standalone Nexus RPCs", + probeServerSupport()); + } + + private static boolean probeServerSupport() { + Boolean cached = cachedServerSupportsStandaloneNexus; + if (cached != null) { + return cached; + } + synchronized (PROBE_LOCK) { + if (cachedServerSupportsStandaloneNexus != null) { + return cachedServerSupportsStandaloneNexus; + } + cachedServerSupportsStandaloneNexus = probeOnce(); + return cachedServerSupportsStandaloneNexus; + } + } + + private static boolean probeOnce() { + String address = System.getenv("TEMPORAL_SERVICE_ADDRESS"); + if (address == null || address.isEmpty()) { + address = "127.0.0.1:7233"; + } + WorkflowServiceStubs stubs = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder().setTarget(address).build()); + try { + stubs + .blockingStub() + .countNexusOperationExecutions( + CountNexusOperationExecutionsRequest.newBuilder().setNamespace("default").build()); + return true; + } catch (StatusRuntimeException e) { + // UNIMPLEMENTED is the only status that tells us the RPC method isn't wired through. Every + // other status (NOT_FOUND for an absent namespace, INVALID_ARGUMENT, PERMISSION_DENIED, etc.) + // proves the method exists on the server even if this particular call was rejected. + return e.getStatus().getCode() != Status.Code.UNIMPLEMENTED; + } finally { + stubs.shutdownNow(); + } + } +} From 46b973ed06c7a592fba8ee0723aa3ad12464dd31 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 09:51:35 -0700 Subject: [PATCH 28/33] Checking for more SANO operations --- .../client/nexus/NexusClientInterceptorChainTest.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java index 4acfe7262..00facf0ca 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -1,7 +1,5 @@ package io.temporal.client.nexus; -import static org.junit.Assume.assumeTrue; - import io.temporal.client.NexusClient; import io.temporal.client.NexusClientImpl; import io.temporal.client.NexusClientOptions; @@ -10,6 +8,7 @@ import io.temporal.common.interceptors.NexusClientCallsInterceptorBase; import io.temporal.common.interceptors.NexusClientInterceptor; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.StandaloneNexusTestPrerequisites; import io.temporal.workflow.shared.TestWorkflows; import java.util.ArrayList; import java.util.Arrays; @@ -32,11 +31,8 @@ public class NexusClientInterceptorChainTest { SDKTestWorkflowRule.newBuilder().setWorkflowTypes(PlaceholderWorkflowImpl.class).build(); @BeforeClass - public static void requireExternalService() { - // The time-skipping test server does not implement standalone Nexus operation RPCs. - assumeTrue( - "standalone Nexus operations require a real server", - SDKTestWorkflowRule.useExternalService); + public static void requireServerWithStandaloneNexusSupport() { + StandaloneNexusTestPrerequisites.requireServerSupport(); } @Test From 599a73117968e1828ca8c8e2500c33144bd1004b Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 11:09:37 -0700 Subject: [PATCH 29/33] Test fix --- ...ReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java index a8c139c67..364ac3cde 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java @@ -94,8 +94,8 @@ public void queriedWorkflowFailureDoesntProduceAdditionalLogsWhenWorkflowIsNotCo assertEquals("exit", workflow.execute()); assertEquals("my-state", workflow.getState()); assertEquals( - "There was three executions - one original and two full replays for query.", - 3, + "There was four executions - one original and three full replays for query.", + 4, workflowCodeExecutionCount.get()); assertEquals( "Only the original exception should be logged.", From 3d7e218a733040e4cfb94afb0a9641a1076f09bd Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 15:24:56 -0700 Subject: [PATCH 30/33] Restoring test --- ...ReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java index 364ac3cde..a8c139c67 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java @@ -94,8 +94,8 @@ public void queriedWorkflowFailureDoesntProduceAdditionalLogsWhenWorkflowIsNotCo assertEquals("exit", workflow.execute()); assertEquals("my-state", workflow.getState()); assertEquals( - "There was four executions - one original and three full replays for query.", - 4, + "There was three executions - one original and two full replays for query.", + 3, workflowCodeExecutionCount.get()); assertEquals( "Only the original exception should be logged.", From 35f0c2ade0a8cfa9c34000ad0e68ead94db7b694 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 16:14:25 -0700 Subject: [PATCH 31/33] Now requiring UUID --- .../temporal/client/NexusServiceClient.java | 46 ++++--------------- .../client/StartNexusOperationOptions.java | 25 +++++----- .../client/UntypedNexusServiceClientImpl.java | 7 +-- .../client/RootNexusClientInvoker.java | 4 +- .../client/nexus/NexusAsyncApiTest.java | 21 +++++++-- .../client/nexus/NexusClientTest.java | 4 ++ .../nexus/NexusOperationHandleTest.java | 1 + .../client/nexus/NexusServiceClientTest.java | 20 ++++++-- 8 files changed, 60 insertions(+), 68 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java index acebd48af..28bf5a6fd 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -50,20 +50,9 @@ static NexusServiceClient newInstance( } /** - * Executes an operation synchronously. Equivalent to {@link #start(BiFunction, Object)} followed - * by {@link NexusOperationHandle#getResult()}. - * - * @param operation a method reference on {@code T} identifying the operation - * @param input the operation input - * @return the operation result - * @throws RuntimeException if the operation failed, timed out, or was cancelled - */ - default R execute(BiFunction operation, U input) { - return start(operation, input).getResult(); - } - - /** - * Executes an operation synchronously with per-call options. + * Executes an operation synchronously with per-call options. The supplied {@link + * StartNexusOperationOptions} must have its {@code id} set; the SDK does not generate operation + * IDs on the caller's behalf. * * @param operation a method reference on {@code T} identifying the operation * @param input the operation input @@ -77,18 +66,8 @@ default R execute( } /** - * Starts an operation and returns a typed handle for tracking its execution. - * - * @param operation a method reference on {@code T} identifying the operation - * @param input the operation input - * @return a typed handle bound to the started operation - */ - default NexusOperationHandle start(BiFunction operation, U input) { - return start(operation, input, StartNexusOperationOptions.getDefaultInstance()); - } - - /** - * Starts an operation with per-call options and returns a typed handle. + * Starts an operation with per-call options and returns a typed handle. The supplied {@link + * StartNexusOperationOptions} must have its {@code id} set. * * @param operation a method reference on {@code T} identifying the operation * @param input the operation input @@ -99,18 +78,9 @@ NexusOperationHandle start( BiFunction operation, U input, StartNexusOperationOptions options); /** - * Async variant of {@link #execute(BiFunction, Object)}. Returns a {@link CompletableFuture} that - * completes with the typed result, or completes exceptionally if the operation fails. - * - * @param operation a method reference on {@code T} identifying the operation - * @param input the operation input - */ - default CompletableFuture executeAsync(BiFunction operation, U input) { - return start(operation, input).getResultAsync(); - } - - /** - * Async variant of {@link #execute(BiFunction, Object, StartNexusOperationOptions)}. + * Async variant of {@link #execute(BiFunction, Object, StartNexusOperationOptions)}. Returns a + * {@link CompletableFuture} that completes with the typed result, or completes exceptionally if + * the operation fails. * * @param operation a method reference on {@code T} identifying the operation * @param input the operation input diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java index b0e55774b..4da608a4d 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -6,6 +6,7 @@ import io.temporal.common.SearchAttributes; import java.time.Duration; import java.util.Objects; +import javax.annotation.Nonnull; import javax.annotation.Nullable; /** @@ -23,13 +24,6 @@ public static Builder newBuilder(StartNexusOperationOptions options) { return new Builder(options); } - private static final StartNexusOperationOptions DEFAULT_INSTANCE = newBuilder().build(); - - /** Returns an options instance with no per-call fields set. */ - public static StartNexusOperationOptions getDefaultInstance() { - return DEFAULT_INSTANCE; - } - public static final class Builder { private @Nullable String id; private @Nullable Duration scheduleToCloseTimeout; @@ -57,11 +51,11 @@ private Builder(StartNexusOperationOptions options) { } /** - * Required. Unique identifier for this operation within its namespace. If left null, the SDK - * generates a random UUID. + * Required. Unique identifier for this operation within its namespace. Callers must supply this + * explicitly; the SDK does not invent one on the caller's behalf. */ - public Builder setId(@Nullable String id) { - this.id = id; + public Builder setId(@Nonnull String id) { + this.id = Objects.requireNonNull(id, "id"); return this; } @@ -108,11 +102,16 @@ public Builder setIdConflictPolicy(@Nullable NexusOperationIdConflictPolicy idCo } public StartNexusOperationOptions build() { + if (id == null) { + throw new IllegalStateException( + "StartNexusOperationOptions.Builder.setId(...) must be called with a non-null id " + + "before build(); the SDK does not generate operation IDs."); + } return new StartNexusOperationOptions(this); } } - private final @Nullable String id; + private final @Nonnull String id; private final @Nullable Duration scheduleToCloseTimeout; private final @Nullable Duration scheduleToStartTimeout; private final @Nullable Duration startToCloseTimeout; @@ -136,7 +135,7 @@ public Builder toBuilder() { return new Builder(this); } - @Nullable + @Nonnull public String getId() { return id; } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java index 30de99aa6..e9545e15e 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -45,12 +45,7 @@ public UntypedNexusOperationHandle start( Payload payload = serializeInput(arg); StartNexusOperationExecutionInput input = new StartNexusOperationExecutionInput( - endpoint, - serviceName, - operation, - payload, - options != null ? options : StartNexusOperationOptions.getDefaultInstance(), - Collections.emptyMap()); + endpoint, serviceName, operation, payload, options, Collections.emptyMap()); StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); return new NexusOperationHandleImpl( output.getOperationId(), output.getRunId(), invoker, dataConverter); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 3c4a205e0..8f331e030 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -28,6 +28,7 @@ import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.WorkflowExecutionUtils; import io.temporal.serviceclient.StatusUtils; +import java.util.Objects; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -53,7 +54,8 @@ public RootNexusClientInvoker( public StartNexusOperationExecutionOutput startNexusOperationExecution( StartNexusOperationExecutionInput input) { StartNexusOperationOptions options = input.getOptions(); - String operationId = options.getId() != null ? options.getId() : UUID.randomUUID().toString(); + // The builder validates that id is non-null; this is a defense-in-depth assertion. + String operationId = Objects.requireNonNull(options.getId(), "StartNexusOperationOptions.id"); StartNexusOperationExecutionRequest.Builder request = StartNexusOperationExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java index e053c3142..fd7bd5b28 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java @@ -16,6 +16,7 @@ import io.temporal.workflow.shared.TestNexusServices; import io.temporal.workflow.shared.TestWorkflows; import java.time.Duration; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -53,7 +54,8 @@ public static void requireServerWithStandaloneNexusSupport() { public void serviceClientExecuteAsyncReturnsResult() throws Exception { String result = buildServiceClient() - .executeAsync(TestNexusServices.TestNexusService1::operation, "hello") + .executeAsync( + TestNexusServices.TestNexusService1::operation, "hello", newOptionsWithId()) .get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); Assert.assertEquals("echo:hello", result); @@ -63,6 +65,7 @@ public void serviceClientExecuteAsyncReturnsResult() throws Exception { public void serviceClientExecuteAsyncWithOptionsReturnsResult() throws Exception { StartNexusOperationOptions options = StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(); @@ -79,7 +82,8 @@ public void serviceClientExecuteAsyncWithOptionsReturnsResult() throws Exception @Test public void typedHandleGetResultAsyncReturnsResult() throws Exception { NexusOperationHandle handle = - buildServiceClient().start(TestNexusServices.TestNexusService1::operation, "typed"); + buildServiceClient() + .start(TestNexusServices.TestNexusService1::operation, "typed", newOptionsWithId()); String result = handle.getResultAsync().get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); @@ -89,7 +93,8 @@ public void typedHandleGetResultAsyncReturnsResult() throws Exception { @Test public void typedHandleGetResultAsyncWithTimeoutReturnsResult() throws Exception { NexusOperationHandle handle = - buildServiceClient().start(TestNexusServices.TestNexusService1::operation, "typed-tm"); + buildServiceClient() + .start(TestNexusServices.TestNexusService1::operation, "typed-tm", newOptionsWithId()); String result = handle @@ -156,7 +161,8 @@ public void executeAsyncPropagatesOperationFailure() throws Exception { buildServiceClient() .executeAsync( TestNexusServices.TestNexusService1::operation, - EchoNexusServiceImpl.FAIL_PREFIX + "boom"); + EchoNexusServiceImpl.FAIL_PREFIX + "boom", + newOptionsWithId()); try { future.get(FUTURE_GET_TIMEOUT.getSeconds(), TimeUnit.SECONDS); @@ -207,7 +213,12 @@ private UntypedNexusOperationHandle startUntyped(String input) { client.newUntypedNexusServiceClient( endpoint.getSpec().getName(), TestNexusServices.TestNexusService1.class.getSimpleName()); - return svcClient.start("operation", StartNexusOperationOptions.newBuilder().build(), input); + return svcClient.start("operation", newOptionsWithId(), input); + } + + /** Builds a minimal {@link StartNexusOperationOptions} with a unique id. */ + private static StartNexusOperationOptions newOptionsWithId() { + return StartNexusOperationOptions.newBuilder().setId(UUID.randomUUID().toString()).build(); } public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index b0326e339..a0b4c3096 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -82,6 +82,7 @@ public void runStandaloneNexusOperation() throws Exception { TestNexusServices.TestNexusService1.class.getSimpleName()); StartNexusOperationOptions opts = StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(); UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); @@ -161,6 +162,7 @@ private String startAndAwaitSyncOperation(String label) throws Exception { TestNexusServices.TestNexusService1.class.getSimpleName()); StartNexusOperationOptions opts = StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(); UntypedNexusOperationHandle handle = @@ -184,6 +186,7 @@ public void untypedExecuteByClassReturnsResult() { "operation", String.class, StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(), "untyped-exec"); @@ -209,6 +212,7 @@ public void untypedExecuteByClassAndTypeReturnsResult() { String.class, String.class, StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(), "untyped-exec-typed"); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java index 2eb3f2547..4217c5c06 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -254,6 +254,7 @@ private UntypedNexusOperationHandle startOperation( TestNexusServices.TestNexusService1.class.getSimpleName()); StartNexusOperationOptions opts = StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(); UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java index 6ea3a7f1e..ab647a529 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -42,7 +42,8 @@ public void executeReturnsTypedResult() { NexusServiceClient client = buildServiceClient(testWorkflowRule.getNexusEndpoint()); - String result = client.execute(TestNexusServices.TestNexusService1::operation, "hello"); + String result = + client.execute(TestNexusServices.TestNexusService1::operation, "hello", newOptionsWithId()); Assert.assertEquals("echo:hello", result); } @@ -53,7 +54,7 @@ public void startReturnsTypedHandleAndPollsResult() { buildServiceClient(testWorkflowRule.getNexusEndpoint()); NexusOperationHandle handle = - client.start(TestNexusServices.TestNexusService1::operation, "world"); + client.start(TestNexusServices.TestNexusService1::operation, "world", newOptionsWithId()); Assert.assertNotNull(handle.getNexusOperationId()); Assert.assertEquals("echo:world", handle.getResult()); @@ -61,10 +62,11 @@ public void startReturnsTypedHandleAndPollsResult() { @Test public void executeWithOptionsReturnsResult() { - // Covers the 3-arg execute(op, input, options) overload — the no-options variant is already - // covered by executeReturnsTypedResult. + // Covers the 3-arg execute(op, input, options) overload — exercises a non-default + // scheduleToCloseTimeout in addition to the required id. StartNexusOperationOptions options = StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build(); @@ -102,7 +104,10 @@ public void clientSummaryReachesServer() { buildServiceClient(testWorkflowRule.getNexusEndpoint()); StartNexusOperationOptions startOptions = - StartNexusOperationOptions.newBuilder().setSummary("per-call-summary").build(); + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setSummary("per-call-summary") + .build(); NexusOperationHandle handle = client.start(TestNexusServices.TestNexusService1::operation, "world", startOptions); @@ -121,6 +126,11 @@ public void clientSummaryReachesServer() { // attribute" until the namespace's Visibility index catches up. Reintroduce once the rule // (or the test) synchronously waits for the mapping to propagate. + /** Builds a minimal {@link StartNexusOperationOptions} with a unique id. */ + private static StartNexusOperationOptions newOptionsWithId() { + return StartNexusOperationOptions.newBuilder().setId(UUID.randomUUID().toString()).build(); + } + private NexusServiceClient buildServiceClient( Endpoint endpoint) { return NexusServiceClient.newInstance( From 96e9abb1fbbc5da06096b4c4bd782f52a42b8e6e Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 16:25:40 -0700 Subject: [PATCH 32/33] Some more checks on ID --- .../client/StartNexusOperationOptions.java | 13 +++-- .../StartNexusOperationOptionsTest.java | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java index 4da608a4d..f8c5fe5b4 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -51,11 +51,14 @@ private Builder(StartNexusOperationOptions options) { } /** - * Required. Unique identifier for this operation within its namespace. Callers must supply this - * explicitly; the SDK does not invent one on the caller's behalf. + * Required. Unique identifier for this operation within its namespace. */ public Builder setId(@Nonnull String id) { - this.id = Objects.requireNonNull(id, "id"); + Objects.requireNonNull(id, "id"); + if (id.isEmpty()) { + throw new IllegalArgumentException("id must not be empty"); + } + this.id = id; return this; } @@ -102,9 +105,9 @@ public Builder setIdConflictPolicy(@Nullable NexusOperationIdConflictPolicy idCo } public StartNexusOperationOptions build() { - if (id == null) { + if (id == null || id.isEmpty()) { throw new IllegalStateException( - "StartNexusOperationOptions.Builder.setId(...) must be called with a non-null id " + "StartNexusOperationOptions.Builder.setId(...) must be called with a non-empty id " + "before build(); the SDK does not generate operation IDs."); } return new StartNexusOperationOptions(this); diff --git a/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java new file mode 100644 index 000000000..e99ea435c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java @@ -0,0 +1,53 @@ +package io.temporal.client; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Pure unit tests for {@link StartNexusOperationOptions.Builder} input validation. ID is required — + * callers must supply a non-empty value via {@link StartNexusOperationOptions.Builder#setId} and + * the SDK does not invent one on their behalf. + */ +public class StartNexusOperationOptionsTest { + + @Test + public void buildThrowsWhenIdNotSet() { + try { + StartNexusOperationOptions.newBuilder().build(); + Assert.fail("expected IllegalStateException when id is unset"); + } catch (IllegalStateException expected) { + Assert.assertTrue( + "error message should mention setId, got: " + expected.getMessage(), + expected.getMessage() != null && expected.getMessage().contains("setId")); + } + } + + @Test + public void setIdRejectsNull() { + try { + StartNexusOperationOptions.newBuilder().setId(null); + Assert.fail("expected NullPointerException when setId is called with null"); + } catch (NullPointerException expected) { + // expected + } + } + + @Test + public void setIdRejectsEmpty() { + try { + StartNexusOperationOptions.newBuilder().setId(""); + Assert.fail("expected IllegalArgumentException when setId is called with an empty string"); + } catch (IllegalArgumentException expected) { + Assert.assertTrue( + "error message should mention empty, got: " + expected.getMessage(), + expected.getMessage() != null && expected.getMessage().contains("empty")); + } + } + + @Test + public void buildSucceedsWithNonEmptyId() { + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder().setId("my-id").build(); + Assert.assertEquals("my-id", options.getId()); + } +} From d3c18ddf763c8f455b72c38d7b4293d6f239f9ba Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 29 May 2026 16:41:36 -0700 Subject: [PATCH 33/33] Cleaned up some inputs --- .../io/temporal/client/NexusClientImpl.java | 69 ++--------------- .../client/StartNexusOperationOptions.java | 4 +- .../NexusClientCallsInterceptor.java | 76 +++++-------------- .../client/NexusOperationHandleImpl.java | 15 +--- .../client/RootNexusClientInvoker.java | 49 ++++++++---- 5 files changed, 62 insertions(+), 151 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java index 4976a15f0..3b390b45e 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -2,7 +2,6 @@ import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread; -import com.google.protobuf.ByteString; import com.uber.m3.tally.Scope; import io.temporal.common.Experimental; import io.temporal.common.interceptors.NexusClientCallsInterceptor; @@ -19,14 +18,9 @@ import io.temporal.internal.client.external.GenericWorkflowClientImpl; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.WorkflowServiceStubs; -import java.util.Iterator; import java.util.List; -import java.util.NoSuchElementException; -import java.util.Spliterator; -import java.util.Spliterators; import java.util.stream.Collectors; import java.util.stream.Stream; -import java.util.stream.StreamSupport; import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -132,16 +126,15 @@ NexusClientCallsInterceptor getNexusClientCallsInvoker() { return nexusClientCallsInvoker; } - private static final int DEFAULT_LIST_PAGE_SIZE = 1000; - @Override public Stream listNexusOperationExecutions( @Nullable String query) { - Iterator iter = - new ListPageIterator(nexusClientCallsInvoker, query, DEFAULT_LIST_PAGE_SIZE); - return StreamSupport.stream( - Spliterators.spliteratorUnknownSize(iter, Spliterator.ORDERED | Spliterator.NONNULL), - false); + // Pagination is handled inside the interceptor invoker; we receive a fully materialized list + // and expose a Stream view of it to honour the public API contract. + ListNexusOperationExecutionsOutput out = + nexusClientCallsInvoker.listNexusOperationExecutions( + new ListNexusOperationExecutionsInput(query)); + return out.getOperations().stream().map(NexusOperationExecutionMetadata::fromListInfo); } @Override @@ -158,54 +151,4 @@ public NexusOperationExecutionCount countNexusOperationExecutions(@Nullable Stri .collect(Collectors.toList()); return new NexusOperationExecutionCount(out.getCount(), publicGroups); } - - /** Lazily fetches pages from the interceptor and flattens them into a single iteration. */ - private static final class ListPageIterator implements Iterator { - private final NexusClientCallsInterceptor invoker; - private final @Nullable String query; - private final int pageSize; - private Iterator current = - java.util.Collections.emptyIterator(); - private @Nullable ByteString nextPageToken = null; - private boolean exhausted = false; - - ListPageIterator(NexusClientCallsInterceptor invoker, @Nullable String query, int pageSize) { - this.invoker = invoker; - this.query = query; - this.pageSize = pageSize; - } - - @Override - public boolean hasNext() { - while (!current.hasNext() && !exhausted) { - fetchNextPage(); - } - return current.hasNext(); - } - - @Override - public NexusOperationExecutionMetadata next() { - if (!hasNext()) { - throw new NoSuchElementException(); - } - return current.next(); - } - - private void fetchNextPage() { - ListNexusOperationExecutionsOutput page = - invoker.listNexusOperationExecutions( - new ListNexusOperationExecutionsInput(query, pageSize, nextPageToken)); - current = - page.getOperations().stream() - .map(NexusOperationExecutionMetadata::fromListInfo) - .iterator(); - ByteString token = page.getNextPageToken(); - if (token == null || token.isEmpty()) { - exhausted = true; - nextPageToken = null; - } else { - nextPageToken = token; - } - } - } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java index f8c5fe5b4..21a2cd282 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -50,9 +50,7 @@ private Builder(StartNexusOperationOptions options) { this.idConflictPolicy = options.idConflictPolicy; } - /** - * Required. Unique identifier for this operation within its namespace. - */ + /** Required. Unique identifier for this operation within its namespace. */ public Builder setId(@Nonnull String id) { Objects.requireNonNull(id, "id"); if (id.isEmpty()) { diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index ff146c19f..cb5587989 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -1,6 +1,5 @@ package io.temporal.common.interceptors; -import com.google.protobuf.ByteString; import io.grpc.Deadline; import io.temporal.api.common.v1.Payload; import io.temporal.api.enums.v1.NexusOperationWaitStage; @@ -45,19 +44,17 @@ StartNexusOperationExecutionOutput startNexusOperationExecution( /** * Returns a point-in-time snapshot of a standalone Nexus operation execution. * - * @param input operation ID, optional run ID, and flags controlling whether to include input and - * outcome payloads + * @param input operation ID and optional run ID * @return output wrapping the {@link NexusOperationExecutionDescription} */ DescribeNexusOperationExecutionOutput describeNexusOperationExecution( DescribeNexusOperationExecutionInput input); /** - * Synchronously long-polls the server until the Nexus operation reaches the wait stage requested - * in {@code input}, then returns the outcome. Blocks the calling thread for the duration. + * Synchronously long-polls the server until the Nexus operation reaches a terminal stage, then + * returns the outcome. Blocks the calling thread for the duration. * - * @param input operation ID, optional run ID, target wait stage, and the deadline bounding the - * poll + * @param input operation ID, optional run ID, and the deadline bounding the poll * @return output containing the run ID, wait stage reached, operation token, and either the * result payload or failure (when the operation has reached a terminal stage) */ @@ -68,8 +65,7 @@ PollNexusOperationExecutionOutput pollNexusOperationExecution( * Asynchronous variant of {@link #pollNexusOperationExecution} that returns a future without * blocking the calling thread. * - * @param input operation ID, optional run ID, target wait stage, and the deadline bounding the - * poll + * @param input operation ID, optional run ID, and the deadline bounding the poll * @return a future that completes with the poll output, or completes exceptionally if the poll * fails or the deadline expires */ @@ -77,11 +73,11 @@ CompletableFuture pollNexusOperationExecution PollNexusOperationExecutionInput input); /** - * Lists standalone Nexus operation executions matching a Visibility query, with paging support. + * Lists standalone Nexus operation executions matching a Visibility query. Pagination is handled + * internally by the SDK; the returned output contains the full materialized result set. * - * @param input Visibility query string, page size, and optional next-page token from a prior call - * @return output wrapping the matching operations and the next-page token (empty when the result - * set is exhausted) + * @param input Visibility query string + * @return output wrapping the matching operations */ ListNexusOperationExecutionsOutput listNexusOperationExecutions( ListNexusOperationExecutionsInput input); @@ -199,15 +195,10 @@ public boolean isStarted() { final class DescribeNexusOperationExecutionInput { private final String operationId; private final @Nullable String runId; - private final boolean includeInput; - private final boolean includeOutcome; - public DescribeNexusOperationExecutionInput( - String operationId, @Nullable String runId, boolean includeInput, boolean includeOutcome) { + public DescribeNexusOperationExecutionInput(String operationId, @Nullable String runId) { this.operationId = operationId; this.runId = runId; - this.includeInput = includeInput; - this.includeOutcome = includeOutcome; } public String getOperationId() { @@ -217,14 +208,6 @@ public String getOperationId() { public Optional getRunId() { return Optional.ofNullable(runId); } - - public boolean isIncludeInput() { - return includeInput; - } - - public boolean isIncludeOutcome() { - return includeOutcome; - } } final class DescribeNexusOperationExecutionOutput { @@ -242,17 +225,12 @@ public NexusOperationExecutionDescription getDescription() { final class PollNexusOperationExecutionInput { private final String operationId; private final @Nullable String runId; - private final NexusOperationWaitStage waitStage; private final @Nonnull Deadline deadline; public PollNexusOperationExecutionInput( - String operationId, - @Nullable String runId, - NexusOperationWaitStage waitStage, - @Nonnull Deadline deadline) { + String operationId, @Nullable String runId, @Nonnull Deadline deadline) { this.operationId = operationId; this.runId = runId; - this.waitStage = waitStage; this.deadline = deadline; } @@ -264,10 +242,6 @@ public Optional getRunId() { return Optional.ofNullable(runId); } - public NexusOperationWaitStage getWaitStage() { - return waitStage; - } - public Deadline getDeadline() { return deadline; } @@ -316,46 +290,30 @@ public Optional getFailure() { final class ListNexusOperationExecutionsInput { private final @Nullable String query; - private final int pageSize; - private final @Nullable ByteString nextPageToken; - public ListNexusOperationExecutionsInput( - @Nullable String query, int pageSize, @Nullable ByteString nextPageToken) { + public ListNexusOperationExecutionsInput(@Nullable String query) { this.query = query; - this.pageSize = pageSize; - this.nextPageToken = nextPageToken; } public Optional getQuery() { return Optional.ofNullable(query); } - - public int getPageSize() { - return pageSize; - } - - public Optional getNextPageToken() { - return Optional.ofNullable(nextPageToken); - } } + /** + * Result of a list call. Holds the full materialized result set; pagination is handled inside the + * SDK and not exposed through the interceptor surface. + */ final class ListNexusOperationExecutionsOutput { private final List operations; - private final ByteString nextPageToken; - public ListNexusOperationExecutionsOutput( - List operations, ByteString nextPageToken) { + public ListNexusOperationExecutionsOutput(List operations) { this.operations = Collections.unmodifiableList(operations); - this.nextPageToken = nextPageToken; } public List getOperations() { return operations; } - - public ByteString getNextPageToken() { - return nextPageToken; - } } final class CountNexusOperationExecutionsInput { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java index 8a3b91b10..17033da5b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java @@ -69,8 +69,7 @@ public String getNexusOperationId() { @Override public NexusOperationExecutionDescription describe() { DescribeNexusOperationExecutionInput input = - new DescribeNexusOperationExecutionInput( - operationId, runId, /* includeInput= */ false, /* includeOutcome= */ true); + new DescribeNexusOperationExecutionInput(operationId, runId); DescribeNexusOperationExecutionOutput output = interceptor.describeNexusOperationExecution(input); return output.getDescription(); @@ -161,11 +160,7 @@ private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadl throws TimeoutException { while (true) { PollNexusOperationExecutionInput pollInput = - new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - deadline); + new PollNexusOperationExecutionInput(operationId, runId, deadline); PollNexusOperationExecutionOutput out; try { out = interceptor.pollNexusOperationExecution(pollInput); @@ -184,11 +179,7 @@ private PollNexusOperationExecutionOutput pollSyncUntilCompletedOrDeadline(Deadl private CompletableFuture pollAsyncUntilCompletedOrDeadline( Deadline deadline) { PollNexusOperationExecutionInput pollInput = - new PollNexusOperationExecutionInput( - operationId, - runId, - NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED, - deadline); + new PollNexusOperationExecutionInput(operationId, runId, deadline); CompletableFuture pollFuture; try { pollFuture = interceptor.pollNexusOperationExecutionAsync(pollInput); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 8f331e030..af34ef06b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -2,6 +2,7 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.temporal.api.enums.v1.NexusOperationWaitStage; import io.temporal.api.errordetails.v1.NexusOperationExecutionAlreadyStartedFailure; import io.temporal.api.sdk.v1.UserMetadata; import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsRequest; @@ -137,12 +138,15 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( private DescribeNexusOperationExecutionRequest buildDescribeRequest( DescribeNexusOperationExecutionInput input) { + // Describe defaults: outcome is included so callers can read the success/failure of completed + // operations; input is omitted to keep responses small. These are SDK-internal decisions and + // not exposed through the interceptor surface. DescribeNexusOperationExecutionRequest.Builder request = DescribeNexusOperationExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) .setOperationId(input.getOperationId()) - .setIncludeInput(input.isIncludeInput()) - .setIncludeOutcome(input.isIncludeOutcome()); + .setIncludeInput(false) + .setIncludeOutcome(true); input.getRunId().ifPresent(request::setRunId); return request.build(); } @@ -192,7 +196,9 @@ private PollNexusOperationExecutionRequest buildPollRequest( PollNexusOperationExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) .setOperationId(input.getOperationId()) - .setWaitStage(input.getWaitStage()); + // Poll always waits for the operation to reach a terminal state; intermediate stages + // are not exposed through the interceptor surface. + .setWaitStage(NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED); input.getRunId().ifPresent(request::setRunId); return request.build(); } @@ -207,20 +213,35 @@ private PollNexusOperationExecutionOutput toPollOutput( response.hasFailure() ? response.getFailure() : null); } + /** Page size used when looping over list pages internally. */ + private static final int LIST_PAGE_SIZE = 1000; + @Override public ListNexusOperationExecutionsOutput listNexusOperationExecutions( ListNexusOperationExecutionsInput input) { - ListNexusOperationExecutionsRequest.Builder request = - ListNexusOperationExecutionsRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) - .setPageSize(input.getPageSize()); - input.getQuery().ifPresent(request::setQuery); - input.getNextPageToken().ifPresent(request::setNextPageToken); - - ListNexusOperationExecutionsResponse response = - genericClient.listNexusOperationExecutions(request.build()); - return new ListNexusOperationExecutionsOutput( - response.getOperationsList(), response.getNextPageToken()); + // Pagination is an internal concern; the interceptor surface sees a single query in and a + // materialized list out. The loop bounds itself by the server-supplied next_page_token. + java.util.List all = + new java.util.ArrayList<>(); + com.google.protobuf.ByteString token = com.google.protobuf.ByteString.EMPTY; + while (true) { + ListNexusOperationExecutionsRequest.Builder request = + ListNexusOperationExecutionsRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setPageSize(LIST_PAGE_SIZE); + input.getQuery().ifPresent(request::setQuery); + if (!token.isEmpty()) { + request.setNextPageToken(token); + } + ListNexusOperationExecutionsResponse response = + genericClient.listNexusOperationExecutions(request.build()); + all.addAll(response.getOperationsList()); + token = response.getNextPageToken(); + if (token.isEmpty()) { + break; + } + } + return new ListNexusOperationExecutionsOutput(all); } @Override