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..c57ae0275 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java @@ -0,0 +1,72 @@ +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. + * + *

Per-operation actions (describe, cancel, terminate, delete, get result) live on {@link + * NexusClientHandle}; obtain a handle via {@link #getHandle}. + */ +@Experimental +public interface NexusClient { + static NexusClient newInstance(WorkflowServiceStubs service) { + return NexusClientImpl.newInstance(service, NexusClientOperationOptions.getDefaultInstance()); + } + + static NexusClient newInstance( + WorkflowServiceStubs service, NexusClientOperationOptions options) { + return NexusClientImpl.newInstance(service, options); + } + + /** Returns the underlying gRPC stubs this client routes RPCs through. */ + WorkflowServiceStubs getWorkflowServiceStubs(); + + /** + * Obtain an untyped handle to an existing operation; targets the latest run. To bind a result + * type, wrap the returned handle with {@link NexusClientHandle#fromUntyped}. + */ + UntypedNexusClientHandle getHandle(String operationId); + + /** + * Obtain an untyped handle to an existing operation, optionally pinned to a specific run. To bind + * a result type, wrap the returned handle with {@link NexusClientHandle#fromUntyped}. + */ + UntypedNexusClientHandle getHandle(String operationId, @Nullable String runId); + + /** Obtain a typed handle to an existing operation, bound to {@code resultClass}. */ + NexusClientHandle getHandle( + String operationId, @Nullable String runId, Class resultClass); + + /** + * Obtain a typed handle to an existing operation, 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}). + */ + NexusClientHandle getHandle( + String operationId, @Nullable String runId, Class resultClass, @Nullable Type resultType); + + /** Build an untyped service client targeting {@code endpoint}/{@code serviceName}. */ + UntypedNexusServiceClient newUntypedNexusServiceClient(String endpoint, String serviceName); + + /** + * Returns a stream of standalone Nexus operation executions matching the given query. The stream + * paginates lazily over server-side results. + * + * @param query Temporal visibility query string, or {@code null} to return all executions in the + * client namespace + */ + Stream listNexusOperationExecutions(@Nullable String query); + + /** + * Returns the count of standalone Nexus operation executions matching the given query. + * + * @param query Temporal visibility query string, or {@code null} to count all executions in the + * client namespace + */ + 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..90331389c --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -0,0 +1,217 @@ +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 NexusClientOperationOptions options; + private final GenericWorkflowClient genericClient; + private final Scope metricsScope; + private final NexusClientCallsInterceptor nexusClientCallsInvoker; + private final List interceptors; + + public static NexusClient newInstance( + WorkflowServiceStubs service, NexusClientOperationOptions options) { + enforceNonWorkflowThread(); + return WorkflowThreadMarker.protectFromWorkflowThread( + new NexusClientImpl(service, options), NexusClient.class); + } + + NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientOperationOptions 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/NexusClientOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationOptions.java new file mode 100644 index 000000000..0cf2d92a9 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOperationOptions.java @@ -0,0 +1,197 @@ +package io.temporal.client; + +import io.temporal.api.enums.v1.NexusOperationIdConflictPolicy; +import io.temporal.api.enums.v1.NexusOperationIdReusePolicy; +import io.temporal.common.SearchAttributes; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.common.interceptors.NexusClientInterceptor; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nullable; + +public class NexusClientOperationOptions { + + private final String namespace; + private final List interceptors; + private final DataConverter dataConverter; + private final @Nullable SearchAttributes searchAttributes; + private final @Nullable String summary; + private final @Nullable NexusOperationIdReusePolicy idReusePolicy; + private final @Nullable NexusOperationIdConflictPolicy idConflictPolicy; + + private NexusClientOperationOptions( + String namespace, + List interceptors, + DataConverter dataConverter, + @Nullable SearchAttributes searchAttributes, + @Nullable String summary, + @Nullable NexusOperationIdReusePolicy idReusePolicy, + @Nullable NexusOperationIdConflictPolicy idConflictPolicy) { + this.namespace = namespace; + this.interceptors = interceptors; + this.dataConverter = dataConverter; + this.searchAttributes = searchAttributes; + this.summary = summary; + this.idReusePolicy = idReusePolicy; + this.idConflictPolicy = idConflictPolicy; + } + + /** 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; + } + + /** + * Default search attributes attached to operations started through this client. May be {@code + * null}. + * + *

Encoded to the proto representation and forwarded into every {@code + * StartNexusOperationExecution} request issued through this client. + */ + public @Nullable SearchAttributes getSearchAttributes() { + return searchAttributes; + } + + /** + * Default operation summary attached to operations started through this client. May be {@code + * null}. + */ + public @Nullable String getSummary() { + return summary; + } + + /** + * Default operation-id reuse policy applied when starting operations through this client. May be + * {@code null} (server default applies). + */ + public @Nullable NexusOperationIdReusePolicy getIdReusePolicy() { + return idReusePolicy; + } + + /** + * Default operation-id conflict policy applied when starting operations through this client. May + * be {@code null} (server default applies). + */ + public @Nullable NexusOperationIdConflictPolicy getIdConflictPolicy() { + return idConflictPolicy; + } + + public static NexusClientOperationOptions.Builder newBuilder() { + return new NexusClientOperationOptions.Builder(); + } + + public static NexusClientOperationOptions.Builder newBuilder( + NexusClientOperationOptions options) { + return new NexusClientOperationOptions.Builder(options); + } + + private static final NexusClientOperationOptions DEFAULT_INSTANCE; + + public static NexusClientOperationOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + static { + DEFAULT_INSTANCE = NexusClientOperationOptions.newBuilder().build(); + } + + public static class Builder { + private String namespace; + private List interceptors = Collections.emptyList(); + private DataConverter dataConverter = GlobalDataConverter.get(); + private @Nullable SearchAttributes searchAttributes; + private @Nullable String summary; + private @Nullable NexusOperationIdReusePolicy idReusePolicy; + private @Nullable NexusOperationIdConflictPolicy idConflictPolicy; + + private Builder() {} + + private Builder(NexusClientOperationOptions options) { + if (options == null) { + return; + } + namespace = options.namespace; + interceptors = options.interceptors; + dataConverter = options.dataConverter; + searchAttributes = options.searchAttributes; + summary = options.summary; + idReusePolicy = options.idReusePolicy; + idConflictPolicy = options.idConflictPolicy; + } + + /** Set the namespace this client will operate on. */ + public NexusClientOperationOptions.Builder setNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** Set the interceptors for this client, but don't allow null lists to happen. */ + public NexusClientOperationOptions.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 NexusClientOperationOptions.Builder setDataConverter(DataConverter dataConverter) { + this.dataConverter = dataConverter; + return this; + } + + /** Set default search attributes attached to operations started through this client. */ + public NexusClientOperationOptions.Builder setSearchAttributes( + @Nullable SearchAttributes searchAttributes) { + this.searchAttributes = searchAttributes; + return this; + } + + /** Set the default operation summary attached to operations started through this client. */ + public NexusClientOperationOptions.Builder setSummary(@Nullable String summary) { + this.summary = summary; + return this; + } + + /** Set the default operation-id reuse policy. */ + public NexusClientOperationOptions.Builder setIdReusePolicy( + @Nullable NexusOperationIdReusePolicy idReusePolicy) { + this.idReusePolicy = idReusePolicy; + return this; + } + + /** Set the default operation-id conflict policy. */ + public NexusClientOperationOptions.Builder setIdConflictPolicy( + @Nullable NexusOperationIdConflictPolicy idConflictPolicy) { + this.idConflictPolicy = idConflictPolicy; + return this; + } + + public NexusClientOperationOptions build() { + return new NexusClientOperationOptions( + namespace, + interceptors, + dataConverter, + searchAttributes, + summary, + idReusePolicy, + idConflictPolicy); + } + } +} 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..68b0acec3 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java @@ -0,0 +1,90 @@ +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; + } + + public List> getGroupValues() { + return groupValues; + } + + 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); + } + + public long getCount() { + return count; + } + + @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..b0bdac031 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java @@ -0,0 +1,205 @@ +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; + } + + @Nonnull + public String getOperationId() { + return operationId; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nonnull + public String getEndpoint() { + return endpoint; + } + + @Nonnull + public String getService() { + return service; + } + + @Nonnull + public String getOperation() { + return operation; + } + + @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; + } + + @Nonnull + public NexusOperationExecutionStatus getStatus() { + return status; + } + + @Nonnull + public SearchAttributes getSearchAttributes() { + return searchAttributes; + } + + 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..443073023 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -0,0 +1,68 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.workflow.NexusOperationOptions; +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, NexusClientOperationOptions.getDefaultInstance()); + } + + static NexusServiceClient newInstance( + Class service, + String endpoint, + WorkflowServiceStubs stubs, + NexusClientOperationOptions options) { + return NexusServiceClientImpl.newInstance(service, endpoint, stubs, options); + } + + /** + * Execute an operation synchronously. Equivalent to {@link #start(BiFunction, Object)} followed + * by {@link NexusClientHandle#getResult()}. + */ + R execute(BiFunction operation, U input); + + /** Execute an operation synchronously with per-call options. */ + R execute(BiFunction operation, U input, NexusOperationOptions options); + + /** Start an operation and return a typed handle to track its execution. */ + NexusClientHandle start(BiFunction operation, U input); + + /** Start an operation with per-call options and return a typed handle. */ + NexusClientHandle start( + BiFunction operation, U input, NexusOperationOptions 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. + */ + CompletableFuture executeAsync(BiFunction operation, U input); + + /** Async variant of {@link #execute(BiFunction, Object, NexusOperationOptions)}. */ + CompletableFuture executeAsync( + BiFunction operation, U input, NexusOperationOptions options); + + /** + * Async variant of {@link #start(BiFunction, Object)}. Returns a {@link CompletableFuture} that + * completes with the typed handle once the start RPC has acknowledged the operation. + */ + CompletableFuture> startAsync(BiFunction operation, U input); + + /** Async variant of {@link #start(BiFunction, Object, NexusOperationOptions)}. */ + CompletableFuture> startAsync( + BiFunction operation, U input, NexusOperationOptions 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 new file mode 100644 index 000000000..cdcf05ba9 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -0,0 +1,144 @@ +package io.temporal.client; + +import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread; + +import com.google.common.base.Defaults; +import io.nexusrpc.Operation; +import io.nexusrpc.ServiceDefinition; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.internal.WorkflowThreadMarker; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.workflow.NexusOperationOptions; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; +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, + NexusClientOperationOptions 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, + NexusClientOperationOptions options) { + super(invoker, endpoint, ServiceDefinition.fromClass(serviceInterface).getName(), options); + this.serviceInterface = serviceInterface; + } + + @Override + public R execute(BiFunction operation, U input) { + return execute(operation, input, NexusOperationOptions.getDefaultInstance()); + } + + @Override + public R execute(BiFunction operation, U input, NexusOperationOptions options) { + return start(operation, input, options).getResult(); + } + + @Override + public NexusClientHandle start(BiFunction operation, U input) { + return start(operation, input, NexusOperationOptions.getDefaultInstance()); + } + + @Override + public NexusClientHandle start( + BiFunction operation, U input, NexusOperationOptions options) { + OperationCapture capture = captureOperation(operation, input); + UntypedNexusClientHandle untyped = start(capture.operationName, options, input); + return NexusClientHandle.fromUntyped(untyped, capture.resultClass, capture.resultType); + } + + @Override + public java.util.concurrent.CompletableFuture executeAsync( + BiFunction operation, U input) { + return executeAsync(operation, input, NexusOperationOptions.getDefaultInstance()); + } + + @Override + public java.util.concurrent.CompletableFuture executeAsync( + BiFunction operation, U input, NexusOperationOptions options) { + return startAsync(operation, input, options).thenCompose(NexusClientHandle::getResultAsync); + } + + @Override + public java.util.concurrent.CompletableFuture> startAsync( + BiFunction operation, U input) { + return startAsync(operation, input, NexusOperationOptions.getDefaultInstance()); + } + + @Override + public java.util.concurrent.CompletableFuture> startAsync( + BiFunction operation, U input, NexusOperationOptions options) { + // The underlying start RPC is sync; wrap on the common pool. A truly non-blocking start would + // require an async startNexusOperationExecution variant on the calls interceptor. + return java.util.concurrent.CompletableFuture.supplyAsync( + () -> start(operation, input, options)); + } + + /** Records the operation method invoked on the service proxy. */ + private static final class OperationCapture { + String operationName; + + @SuppressWarnings("rawtypes") + Class resultClass; + + Type resultType; + } + + @SuppressWarnings({"unchecked", "ReturnValueIgnored"}) + private OperationCapture captureOperation(BiFunction operation, U input) { + OperationCapture capture = new OperationCapture<>(); + InvocationHandler handler = + (Object proxy, Method method, Object[] args) -> { + if (Object.class.equals(method.getDeclaringClass())) { + return Defaults.defaultValue(method.getReturnType()); + } + Operation opAnnotation = method.getAnnotation(Operation.class); + capture.operationName = + opAnnotation != null && !opAnnotation.name().isEmpty() + ? opAnnotation.name() + : method.getName(); + capture.resultClass = method.getReturnType(); + capture.resultType = method.getGenericReturnType(); + return Defaults.defaultValue(method.getReturnType()); + }; + T proxy = + (T) + Proxy.newProxyInstance( + serviceInterface.getClassLoader(), new Class[] {serviceInterface}, handler); + operation.apply(proxy, input); + if (capture.operationName == null) { + throw new IllegalArgumentException( + "Could not extract Nexus operation name; the BiFunction must invoke a method on the" + + " service proxy (e.g. ServiceInterface::operationMethod)"); + } + return capture; + } +} 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..c65283743 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -0,0 +1,241 @@ +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); + } + + 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..00c42dbb3 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java @@ -0,0 +1,27 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.workflow.NexusOperationOptions; +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, NexusOperationOptions options, @Nullable Object arg); + + /** Execute an operation synchronously by name. */ + R execute( + String operation, Class resultClass, NexusOperationOptions options, @Nullable Object arg); + + /** Execute an operation synchronously by name with explicit generic-result {@link Type}. */ + R execute( + String operation, + Class resultClass, + Type resultType, + NexusOperationOptions 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..6f97e0d03 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -0,0 +1,101 @@ +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 io.temporal.workflow.NexusOperationOptions; +import java.lang.reflect.Type; +import java.util.UUID; +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; + private final NexusClientOperationOptions clientOptions; + + UntypedNexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + String endpoint, + String serviceName, + NexusClientOperationOptions 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(); + this.clientOptions = clientOptions; + } + + @Override + public UntypedNexusClientHandle start( + String operation, NexusOperationOptions options, @Nullable Object arg) { + Payload payload = serializeInput(arg); + StartNexusOperationOptions.Builder startOptions = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setTypedSearchAttributes(clientOptions.getSearchAttributes()) + .setIdReusePolicy(clientOptions.getIdReusePolicy()) + .setIdConflictPolicy(clientOptions.getIdConflictPolicy()); + if (options != null) { + startOptions + .setScheduleToCloseTimeout(options.getScheduleToCloseTimeout()) + .setScheduleToStartTimeout(options.getScheduleToStartTimeout()) + .setStartToCloseTimeout(options.getStartToCloseTimeout()) + .setSummary( + options.getSummary() != null ? options.getSummary() : clientOptions.getSummary()); + } else { + startOptions.setSummary(clientOptions.getSummary()); + } + StartNexusOperationExecutionInput input = + new StartNexusOperationExecutionInput( + endpoint, serviceName, operation, payload, startOptions.build()); + StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); + return new NexusClientHandleImpl<>( + invoker, output.getOperationId(), output.getRunId(), dataConverter); + } + + @Override + public R execute( + String operation, Class resultClass, NexusOperationOptions options, @Nullable Object arg) { + return execute(operation, resultClass, /* resultType= */ null, options, arg); + } + + @Override + public R execute( + String operation, + Class resultClass, + @Nullable Type resultType, + NexusOperationOptions 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..a2ce4945f --- /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.NexusClientOperationOptions; +import io.temporal.common.Experimental; + +/** + * Outer interceptor for {@link NexusClient}. Implementations are registered via {@link + * NexusClientOperationOptions.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..c072e748f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -0,0 +1,240 @@ +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.NexusClientOperationOptions; +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 NexusClientOperationOptions clientOptions; + + public RootNexusClientInvoker( + GenericWorkflowClient genericClient, NexusClientOperationOptions 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()) + .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()) + .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()) + .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/common/LinkConverter.java b/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java index d1ee56f0d..6d270eec6 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/LinkConverter.java @@ -32,11 +32,19 @@ public class LinkConverter { public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { try { + String url = String.format( linkPathFormat, URLEncoder.encode(we.getNamespace(), StandardCharsets.UTF_8.toString()), - URLEncoder.encode(we.getWorkflowId(), StandardCharsets.UTF_8.toString()), + // The 'replace' below handles spaces - the encoder will convert them to a plus, + // which the UI then handles as a plus, thus breaking the link as the + // space is lost. + // It's a known quirk with the URLEncoder as it encodes for forms, not general URIs. + // Only done for the WorkflowId as the other two are values we control, + // and will never have spaces. + URLEncoder.encode(we.getWorkflowId(), StandardCharsets.UTF_8.toString()) + .replace("+", "%20"), URLEncoder.encode(we.getRunId(), StandardCharsets.UTF_8.toString())); List> queryParams = new ArrayList<>(); 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..244dfc558 --- /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.NexusClientOperationOptions; +import io.temporal.client.UntypedNexusClientHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationOptions; +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(), + NexusClientOperationOptions.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()); + NexusOperationOptions opts = + NexusOperationOptions.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..5f0206efd --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -0,0 +1,112 @@ +package io.temporal.client.nexus; + +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientImpl; +import io.temporal.client.NexusClientOperationOptions; +import io.temporal.client.NexusOperationExecutionCount; +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.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(), + NexusClientOperationOptions.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..1f6fb277c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -0,0 +1,217 @@ +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.NexusClientOperationOptions; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.client.NexusOperationExecutionMetadata; +import io.temporal.client.UntypedNexusClientHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationOptions; +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()) + // Default is 10s; standalone Nexus dispatch + worker poll can take longer. + .setTestTimeoutSeconds(120) + .build(); + + private NexusClient createNexusClient() { + return NexusClientImpl.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOperationOptions.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()); + NexusOperationOptions opts = + NexusOperationOptions.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..555ba8f36 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -0,0 +1,224 @@ +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.NexusClientOperationOptions; +import io.temporal.client.NexusServiceClient; +import io.temporal.common.SearchAttributeKey; +import io.temporal.common.SearchAttributes; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput; +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(), + NexusClientOperationOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setSummary("client-default-summary") + .setInterceptors(Collections.singletonList(recordingFactory)) + .build()); + + try { + client.start(TestNexusServices.TestNexusService1::operation, "ignored"); + 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", + "client-default-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(), + NexusClientOperationOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setSearchAttributes(attrs) + .setInterceptors(Collections.singletonList(recordingFactory)) + .build()); + + try { + client.start(TestNexusServices.TestNexusService1::operation, "ignored"); + 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(), + io.temporal.client.NexusClientOperationOptions.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-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java index b9bc4a6e3..60b67b1b8 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/common/LinkConverterTest.java @@ -6,6 +6,9 @@ import io.temporal.api.common.v1.Link; import io.temporal.api.enums.v1.EventType; +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import org.junit.Test; public class LinkConverterTest { @@ -98,6 +101,35 @@ public void testConvertWorkflowEventToNexus_ValidSlash() { assertEquals(expected, actual); } + @Test + public void testConvertWorkflowEventToNexus_ValidSpace() throws UnsupportedEncodingException { + Link.WorkflowEvent input = + Link.WorkflowEvent.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf space+plus") + .setRunId("run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventId(1) + .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl( + "temporal:///namespaces/ns/workflows/wf%20space%2Bplus/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted") + .setType("temporal.api.common.v1.Link.WorkflowEvent") + .build(); + + io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); + assertEquals(expected, actual); + + String decoded = URLDecoder.decode(actual.getUrl(), StandardCharsets.UTF_8.toString()); + assertEquals( + "temporal:///namespaces/ns/workflows/wf space+plus/run-id/history?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + decoded); + } + @Test public void testConvertWorkflowEventToNexus_ValidEventIDMissing() { Link.WorkflowEvent 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 a1cf4e111..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 @@ -621,7 +621,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;