From f71f93b067fe3baa8796aa98197b31832e3e8e5e Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Wed, 27 May 2026 15:16:57 -0400 Subject: [PATCH 001/107] remove dead omes job (#2891) --- .github/workflows/omes.yml | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 .github/workflows/omes.yml diff --git a/.github/workflows/omes.yml b/.github/workflows/omes.yml deleted file mode 100644 index 59e6bce918..0000000000 --- a/.github/workflows/omes.yml +++ /dev/null @@ -1,17 +0,0 @@ -name: Omes testing -on: - push: - branches: - - master - -jobs: - omes-image-build: - uses: temporalio/omes/.github/workflows/docker-images.yml@main - secrets: inherit - with: - lang: java - sdk-repo-url: ${{ github.event.pull_request.head.repo.full_name || 'temporalio/sdk-java' }} - sdk-repo-ref: ${{ github.event.pull_request.head.ref || github.ref }} - # TODO: Remove once we have a good way of cleaning up sha-based pushed images - docker-tag-ext: ci-latest - do-push: true From 187421b0e5e10b1dce62e661e5debeda45217e43 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Wed, 27 May 2026 16:44:02 -0700 Subject: [PATCH 002/107] Upgrade temporal-api to v1.62.12 (#2892) --- temporal-serviceclient/src/main/proto | 2 +- .../internal/testservice/TestWorkflowMutableStateImpl.java | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index 67150b14e0..d2fc34ab84 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit 67150b14e0509210bf250960bd3278a4509e091c +Subproject commit d2fc34ab844603f50e41365f46c7fb82bdedffe6 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 a1cf4e1110..f28075db6a 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java @@ -37,6 +37,7 @@ import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; import io.temporal.api.update.v1.*; import io.temporal.api.workflow.v1.*; +import io.temporal.api.workflow.v1.OnConflictOptions; import io.temporal.api.workflowservice.v1.*; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.failure.ServerFailure; From e947cc23d152dfe9eb55c9d1a8bc5c635911876d Mon Sep 17 00:00:00 2001 From: Baekgyu Kim Date: Mon, 1 Jun 2026 23:25:50 +0900 Subject: [PATCH 003/107] Add history hints to workflow task started attributes (#2865) --- .../testservice/TestWorkflowStoreImpl.java | 38 +++- .../WorkflowTaskStartedAttributesTest.java | 169 ++++++++++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 temporal-test-server/src/test/java/io/temporal/testserver/functional/WorkflowTaskStartedAttributesTest.java diff --git a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowStoreImpl.java b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowStoreImpl.java index 16c144c5e2..88c153ecf2 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowStoreImpl.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowStoreImpl.java @@ -10,9 +10,11 @@ import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.EventType; import io.temporal.api.enums.v1.HistoryEventFilterType; +import io.temporal.api.enums.v1.SuggestContinueAsNewReason; import io.temporal.api.enums.v1.WorkflowExecutionStatus; import io.temporal.api.history.v1.History; import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.WorkflowTaskStartedEventAttributes; import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; import io.temporal.api.workflow.v1.WorkflowExecutionInfo; import io.temporal.api.workflowservice.v1.*; @@ -40,6 +42,8 @@ class TestWorkflowStoreImpl implements TestWorkflowStore { private static final Logger log = LoggerFactory.getLogger(TestWorkflowStoreImpl.class); + private static final long HISTORY_SIZE_SUGGEST_CONTINUE_AS_NEW = 4L * 1024 * 1024; + private static final long HISTORY_COUNT_SUGGEST_CONTINUE_AS_NEW = 4L * 1024; private final Lock lock = new ReentrantLock(); private final Map histories = new HashMap<>(); @@ -50,12 +54,33 @@ class TestWorkflowStoreImpl implements TestWorkflowStore { private final Map> nexusTaskQueues = new HashMap<>(); private final SelfAdvancingTimer selfAdvancingTimer; + private static void populateWorkflowTaskStartedEventAttributes( + WorkflowTaskStartedEventAttributes.Builder attributes, + long historySizeBytes, + long historyCount) { + // Size excludes the started event; count is the started event id. + attributes.setHistorySizeBytes(historySizeBytes); + if (historySizeBytes >= HISTORY_SIZE_SUGGEST_CONTINUE_AS_NEW) { + attributes.setSuggestContinueAsNew(true); + attributes.addSuggestContinueAsNewReasons( + SuggestContinueAsNewReason.SUGGEST_CONTINUE_AS_NEW_REASON_HISTORY_SIZE_TOO_LARGE); + } + if (historyCount >= HISTORY_COUNT_SUGGEST_CONTINUE_AS_NEW) { + attributes.setSuggestContinueAsNew(true); + attributes.addSuggestContinueAsNewReasons( + SuggestContinueAsNewReason.SUGGEST_CONTINUE_AS_NEW_REASON_TOO_MANY_HISTORY_EVENTS); + } + } + private static class HistoryStore { private final ExecutionId id; private final Lock lock; private final Condition newEventsCondition; private final List history = new ArrayList<>(); + + private long historySizeBytes; + private boolean completed; private HistoryStore(ExecutionId id, Lock lock) { @@ -91,8 +116,17 @@ List addAllLocked(List events, Timestamp eventTime) if (Timestamps.toMillis(eBuilder.getEventTime()) == 0) { eBuilder.setEventTime(eventTime); } - history.add(eBuilder.build()); - completed = completed || WorkflowExecutionUtils.isWorkflowExecutionClosedEvent(eBuilder); + if (EventType.EVENT_TYPE_WORKFLOW_TASK_STARTED == eBuilder.getEventType()) { + populateWorkflowTaskStartedEventAttributes( + eBuilder.getWorkflowTaskStartedEventAttributesBuilder(), + historySizeBytes, + history.size() + 1L); + } + HistoryEvent historyEvent = eBuilder.build(); + history.add(historyEvent); + historySizeBytes += historyEvent.getSerializedSize(); + completed = + completed || WorkflowExecutionUtils.isWorkflowExecutionClosedEvent(historyEvent); } newEventsCondition.signalAll(); return history.subList(currentSize, history.size()); diff --git a/temporal-test-server/src/test/java/io/temporal/testserver/functional/WorkflowTaskStartedAttributesTest.java b/temporal-test-server/src/test/java/io/temporal/testserver/functional/WorkflowTaskStartedAttributesTest.java new file mode 100644 index 0000000000..770a1034a3 --- /dev/null +++ b/temporal-test-server/src/test/java/io/temporal/testserver/functional/WorkflowTaskStartedAttributesTest.java @@ -0,0 +1,169 @@ +package io.temporal.testserver.functional; + +import static io.temporal.internal.common.InternalUtils.createNormalTaskQueue; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.RecordMarkerCommandAttributes; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.enums.v1.CommandType; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.enums.v1.SuggestContinueAsNewReason; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.WorkflowTaskStartedEventAttributes; +import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.testing.internal.TestServiceUtils; +import io.temporal.testserver.TestServer; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class WorkflowTaskStartedAttributesTest { + + private static final long HISTORY_SIZE_SUGGEST_CONTINUE_AS_NEW = 4L * 1024 * 1024; + private static final long HISTORY_COUNT_SUGGEST_CONTINUE_AS_NEW = 4L * 1024; + private static final int MARKER_PAYLOAD_SIZE = 1024 * 1024; + private static final int MARKER_ITERATIONS = 5; + + private final String NAMESPACE = "namespace"; + private final String TASK_QUEUE = "taskQueue"; + private final String WORKFLOW_TYPE = "wfType"; + + private TestServer.InProcessTestServer testServer; + private WorkflowServiceStubs workflowServiceStubs; + + @Before + public void setUp() { + this.testServer = TestServer.createServer(true); + this.workflowServiceStubs = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setChannel(testServer.getChannel()) + .validateAndBuildWithDefaults()); + } + + @After + public void tearDown() { + this.workflowServiceStubs.shutdownNow(); + this.workflowServiceStubs.awaitTermination(1, TimeUnit.SECONDS); + this.testServer.close(); + } + + @Test + public void firstWorkflowTaskStartedReportsHistorySizeWithoutSuggestion() throws Exception { + TestServiceUtils.startWorkflowExecution( + NAMESPACE, TASK_QUEUE, WORKFLOW_TYPE, workflowServiceStubs); + + PollWorkflowTaskQueueResponse response = + TestServiceUtils.pollWorkflowTaskQueue( + NAMESPACE, createNormalTaskQueue(TASK_QUEUE), workflowServiceStubs); + + WorkflowTaskStartedEventAttributes startedAttributes = lastStartedAttributes(response); + assertTrue(startedAttributes.getHistorySizeBytes() > 0); + assertFalse(startedAttributes.getSuggestContinueAsNew()); + assertTrue(startedAttributes.getSuggestContinueAsNewReasonsList().isEmpty()); + } + + @Test + public void historySizeAboveThresholdSuggestsContinueAsNew() throws Exception { + TestServiceUtils.startWorkflowExecution( + NAMESPACE, TASK_QUEUE, WORKFLOW_TYPE, workflowServiceStubs); + + PollWorkflowTaskQueueResponse response = + TestServiceUtils.pollWorkflowTaskQueue( + NAMESPACE, createNormalTaskQueue(TASK_QUEUE), workflowServiceStubs); + + for (int i = 0; i < MARKER_ITERATIONS; i++) { + workflowServiceStubs + .blockingStub() + .respondWorkflowTaskCompleted( + RespondWorkflowTaskCompletedRequest.newBuilder() + .setTaskToken(response.getTaskToken()) + .addCommands(newLargeMarkerCommand()) + .build()); + TestServiceUtils.signalWorkflow( + response.getWorkflowExecution(), NAMESPACE, workflowServiceStubs); + response = + TestServiceUtils.pollWorkflowTaskQueue( + NAMESPACE, createNormalTaskQueue(TASK_QUEUE), workflowServiceStubs); + } + + WorkflowTaskStartedEventAttributes startedAttributes = lastStartedAttributes(response); + assertTrue( + "Expected history >= 4 MiB but was " + startedAttributes.getHistorySizeBytes(), + startedAttributes.getHistorySizeBytes() >= HISTORY_SIZE_SUGGEST_CONTINUE_AS_NEW); + assertSuggestsContinueAsNew( + startedAttributes, + SuggestContinueAsNewReason.SUGGEST_CONTINUE_AS_NEW_REASON_HISTORY_SIZE_TOO_LARGE); + } + + @Test + public void historyCountAboveThresholdSuggestsContinueAsNew() throws Exception { + TestServiceUtils.startWorkflowExecution( + NAMESPACE, TASK_QUEUE, WORKFLOW_TYPE, workflowServiceStubs); + + PollWorkflowTaskQueueResponse response = + TestServiceUtils.pollWorkflowTaskQueue( + NAMESPACE, createNormalTaskQueue(TASK_QUEUE), workflowServiceStubs); + + RespondWorkflowTaskCompletedRequest.Builder completedRequest = + RespondWorkflowTaskCompletedRequest.newBuilder().setTaskToken(response.getTaskToken()); + for (int i = 0; i < HISTORY_COUNT_SUGGEST_CONTINUE_AS_NEW; i++) { + completedRequest.addCommands(newMarkerCommand()); + } + workflowServiceStubs.blockingStub().respondWorkflowTaskCompleted(completedRequest.build()); + TestServiceUtils.signalWorkflow( + response.getWorkflowExecution(), NAMESPACE, workflowServiceStubs); + + response = + TestServiceUtils.pollWorkflowTaskQueue( + NAMESPACE, createNormalTaskQueue(TASK_QUEUE), workflowServiceStubs); + + WorkflowTaskStartedEventAttributes startedAttributes = lastStartedAttributes(response); + assertSuggestsContinueAsNew( + startedAttributes, + SuggestContinueAsNewReason.SUGGEST_CONTINUE_AS_NEW_REASON_TOO_MANY_HISTORY_EVENTS); + } + + private static WorkflowTaskStartedEventAttributes lastStartedAttributes( + PollWorkflowTaskQueueResponse response) { + HistoryEvent last = response.getHistory().getEvents(response.getHistory().getEventsCount() - 1); + assertEquals(EventType.EVENT_TYPE_WORKFLOW_TASK_STARTED, last.getEventType()); + return last.getWorkflowTaskStartedEventAttributes(); + } + + private static void assertSuggestsContinueAsNew( + WorkflowTaskStartedEventAttributes startedAttributes, SuggestContinueAsNewReason reason) { + assertTrue(startedAttributes.getSuggestContinueAsNew()); + assertTrue(startedAttributes.getSuggestContinueAsNewReasonsList().contains(reason)); + } + + private static Command newLargeMarkerCommand() { + ByteString markerData = ByteString.copyFrom(new byte[MARKER_PAYLOAD_SIZE]); + Payloads markerPayloads = + Payloads.newBuilder().addPayloads(Payload.newBuilder().setData(markerData)).build(); + return newMarkerCommand(markerPayloads); + } + + private static Command newMarkerCommand() { + return newMarkerCommand(Payloads.getDefaultInstance()); + } + + private static Command newMarkerCommand(Payloads markerPayloads) { + return Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_RECORD_MARKER) + .setRecordMarkerCommandAttributes( + RecordMarkerCommandAttributes.newBuilder() + .setMarkerName("large-history-marker") + .putDetails("payload", markerPayloads)) + .build(); + } +} From 3ed49850aa2a75e6c976781a3255a6f77f24f89d Mon Sep 17 00:00:00 2001 From: James Watkins-Harvey Date: Tue, 2 Jun 2026 15:36:29 -0400 Subject: [PATCH 004/107] Add cooldown on dependabot config (#2888) --- .github/dependabot.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 10ef831183..b78c46d9d9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,7 +4,13 @@ updates: directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 0 + cooldown: + default-days: 14 - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" + open-pull-requests-limit: 0 + cooldown: + default-days: 14 From 4d5397601097ebfc683400435e285252514718ff Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Mon, 8 Jun 2026 15:12:12 -0500 Subject: [PATCH 005/107] Wait for MARKER_RECORDED to fire version callback on replay (#2821) * Add interleaved update replay reproducer Add a Java replay test that mirrors the Kotlin GreetingWorkflow sample and replays the provided workflow history fixture. Assert that replay fails with the embedded TMPRL1100 NonDeterministicException message so the reproducer stays pinned to the reported failure mode. * Add replay ordering reproducer for interleaved updates * Delay flagged version replay callback to marker match Run a constrained experiment for the interleaved update replay bug by changing VersionStateMachine replay timing only for histories with SKIP_YIELD_ON_VERSION set. In that path, getVersion still returns synchronously, but the replay callback is no longer fired at fake RECORD_MARKER command creation and is instead delayed until the real MARKER_RECORDED event is matched. The goal of the experiment was to verify that flagged histories do not depend on the current early replay callback or its extra eventLoop scheduling. The legacy interleaved update repro history does not have SKIP_YIELD_ON_VERSION, so it continues to fail unchanged and serves as the control case. Verified with: ./gradlew --offline :temporal-sdk:test --tests "io.temporal.workflow.versionTests.GetVersionMultithreadingRemoveTest" --tests "io.temporal.workflow.versionTests.GetVersionRemovedInReplayTest" --tests "io.temporal.workflow.versionTests.GetVersionWithoutCommandEventTest" --tests "io.temporal.workflow.versionTests.GetVersionAndTimerTest" --tests "io.temporal.workflow.versionTests.GetVersionMultipleCallsTest" --tests "io.temporal.workflow.versionTests.GetVersionInSignalTest" --tests "io.temporal.workflow.versionTests.GetVersionMultithreadingTest" --tests "io.temporal.workflow.versionTests.GetVersionInterleavedUpdateReplayTest" --tests "io.temporal.internal.replay.GetVersionInterleavedUpdateReplayTaskHandlerTest" ./gradlew --offline :temporal-sdk:test --tests "io.temporal.workflow.versionTests.GetVersionRemovedInReplayTest" --tests "io.temporal.workflow.versionTests.GetVersionMultithreadingRemoveTest" --tests "io.temporal.workflow.versionTests.GetVersionMultipleCallsTest" --tests "io.temporal.workflow.versionTests.GetVersionMultithreadingTest" * Delay version replay callback until marker match Change VersionStateMachine replay semantics so getVersion no longer resumes workflow code when the fake RECORD_MARKER command is created. Replay now waits until the real MARKER_RECORDED event is matched before firing the version callback, which makes version-marker ordering consistent with replayed side effects. This fixes the interleaved update replay bug reproduced by testGetVersionInterleavedUpdateReplayHistory.json. That history previously failed replay with [TMPRL1100] because the second getVersion callback ran ahead of update completion protocol handling. After this change, the same recorded history replays successfully through both WorkflowReplayer and the lower-level direct-query replay task handler. The earlier flag-gated experiment showed that delaying the callback was already safe for histories with SKIP_YIELD_ON_VERSION. This commit removes that temporary gating and applies the same replay ordering to all histories. Verified with: ./gradlew --offline :temporal-sdk:test --tests "io.temporal.workflow.versionTests.GetVersionMultithreadingRemoveTest" --tests "io.temporal.workflow.versionTests.GetVersionRemovedInReplayTest" --tests "io.temporal.workflow.versionTests.GetVersionWithoutCommandEventTest" --tests "io.temporal.workflow.versionTests.GetVersionAndTimerTest" --tests "io.temporal.workflow.versionTests.GetVersionMultipleCallsTest" --tests "io.temporal.workflow.versionTests.GetVersionInSignalTest" --tests "io.temporal.workflow.versionTests.GetVersionMultithreadingTest" --tests "io.temporal.workflow.versionTests.GetVersionInterleavedUpdateReplayTest" --tests "io.temporal.internal.replay.GetVersionInterleavedUpdateReplayTaskHandlerTest" ./gradlew --offline :temporal-sdk:test --tests "io.temporal.workflow.versionTests.GetVersionInterleavedUpdateReplayTest" --tests "io.temporal.internal.replay.GetVersionInterleavedUpdateReplayTaskHandlerTest" * Doc comments for new regression tests. * Gate VersionStateMachine behavior correction behind new sdk flag `VERSION_WAIT_FOR_MARKER` * Disable recorded-history replay test for interleaved histories, since we have elected not to fix that specific history. Make sure there is a run-then-replay regression test which confirms new histories won't be broken that way. * Add flag introduction history on `VERSION_WAIT_FOR_MARKER` SDK flag * Make reproducer for original issue check that it still throws the same exception. * Add test replaying history with `VERSION_WAIT_FOR_MARKER` set in `sdkMetadata.langUsedFlags` * [visibility] attempt to produce interleaved replay behavior with workflow.Async * Revert change to SKIP_YIELD_ON_VERSION that was in place for testing. * Fix stale test * Make Docker build more reliable against transient network failures in CI * Fix zlib download URL --- .github/workflows/build-native-image.yml | 8 +- docker/native-image-musl/dockerfile | 12 +- docker/native-image-musl/install-musl.sh | 41 +- .../io/temporal/internal/common/SdkFlag.java | 11 + .../statemachines/VersionStateMachine.java | 27 +- .../statemachines/WorkflowStateMachines.java | 1 + ...nterleavedUpdateReplayTaskHandlerTest.java | 108 ++ .../temporal/worker/WorkerVersioningTest.java | 7 + ...etVersionAsyncLocalActivityReplayTest.java | 183 +++ ...GetVersionInterleavedUpdateReplayTest.java | 262 ++++ ...VersionInterleavedUpdateReplayHistory.json | 1069 +++++++++++++++++ ...eavedUpdateReplayWaitForMarkerHistory.json | 283 +++++ 12 files changed, 1984 insertions(+), 28 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/replay/GetVersionInterleavedUpdateReplayTaskHandlerTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionAsyncLocalActivityReplayTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java create mode 100644 temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayHistory.json create mode 100644 temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayWaitForMarkerHistory.json diff --git a/.github/workflows/build-native-image.yml b/.github/workflows/build-native-image.yml index 5f2a35367c..42daac94f7 100644 --- a/.github/workflows/build-native-image.yml +++ b/.github/workflows/build-native-image.yml @@ -87,7 +87,9 @@ jobs: - name: Build native test server (Docker non-musl) if: matrix.os_family == 'linux' && matrix.musl == false run: | - IMAGE_ID=$(docker build -q ./docker/native-image) + IMAGE_ID_FILE="$(mktemp)" + docker build --iidfile "$IMAGE_ID_FILE" ./docker/native-image + IMAGE_ID="$(cat "$IMAGE_ID_FILE")" docker run \ --rm -w /github/workspace -v "$(pwd):/github/workspace" \ "$IMAGE_ID" \ @@ -96,7 +98,9 @@ jobs: - name: Build native test server (Docker musl) if: matrix.os_family == 'linux' && matrix.musl == true run: | - IMAGE_ID=$(docker build -q ./docker/native-image-musl) + IMAGE_ID_FILE="$(mktemp)" + docker build --iidfile "$IMAGE_ID_FILE" ./docker/native-image-musl + IMAGE_ID="$(cat "$IMAGE_ID_FILE")" docker run \ --rm -w /github/workspace -v "$(pwd):/github/workspace" \ "$IMAGE_ID" \ diff --git a/docker/native-image-musl/dockerfile b/docker/native-image-musl/dockerfile index 6f53affefb..68d93a4d89 100644 --- a/docker/native-image-musl/dockerfile +++ b/docker/native-image-musl/dockerfile @@ -3,16 +3,16 @@ FROM ubuntu:24.04 ENV JAVA_HOME=/usr/lib64/graalvm/graalvm-community-java23 COPY --from=ghcr.io/graalvm/native-image-community:23 $JAVA_HOME $JAVA_HOME ENV PATH="${JAVA_HOME}/bin:${PATH}" -RUN apt-get -y update --allow-releaseinfo-change && apt-get install -y -V git build-essential curl binutils +RUN apt-get -y update --allow-releaseinfo-change && apt-get install -y -V git build-essential curl ca-certificates binutils COPY install-musl.sh /opt/install-musl.sh RUN chmod +x /opt/install-musl.sh WORKDIR /opt -# We need to build musl and zlibc with musl to for a static build -# See https://www.graalvm.org/21.3/reference-manual/native-image/StaticImages/index.html +# We need to build musl and zlib with musl for a static build. +# See https://www.graalvm.org/21.3/reference-manual/native-image/StaticImages/index.html. RUN ./install-musl.sh ENV MUSL_HOME=/opt/musl-toolchain ENV PATH="$MUSL_HOME/bin:$PATH" -# Verify installation +# Verify installation. RUN x86_64-linux-musl-gcc --version -# Avoid errors like: "fatal: detected dubious ownership in repository" -RUN git config --global --add safe.directory '*' \ No newline at end of file +# Avoid errors like: "fatal: detected dubious ownership in repository". +RUN git config --global --add safe.directory '*' diff --git a/docker/native-image-musl/install-musl.sh b/docker/native-image-musl/install-musl.sh index 9cd4000cf7..3e829406d5 100644 --- a/docker/native-image-musl/install-musl.sh +++ b/docker/native-image-musl/install-musl.sh @@ -1,28 +1,35 @@ -# Specify an installation directory for musl: -export MUSL_HOME=$PWD/musl-toolchain +#!/usr/bin/env bash +set -euo pipefail -# Download musl and zlib sources: -curl -O https://musl.libc.org/releases/musl-1.2.5.tar.gz -curl -O https://zlib.net/fossils/zlib-1.2.13.tar.gz +readonly MUSL_VERSION=1.2.5 +readonly ZLIB_VERSION=1.2.13 -# Build musl from source -tar -xzvf musl-1.2.5.tar.gz -cd musl-1.2.5 || exit +export MUSL_HOME="$PWD/musl-toolchain" + +curl --fail --location --retry 5 --retry-all-errors --output "musl-${MUSL_VERSION}.tar.gz" \ + "https://musl.libc.org/releases/musl-${MUSL_VERSION}.tar.gz" +curl --fail --location --retry 5 --retry-all-errors --output "zlib-${ZLIB_VERSION}.tar.gz" \ + "https://github.com/madler/zlib/releases/download/v${ZLIB_VERSION}/zlib-${ZLIB_VERSION}.tar.gz" + +# Build musl from source. +tar -xzf "musl-${MUSL_VERSION}.tar.gz" +cd "musl-${MUSL_VERSION}" ./configure --prefix=$MUSL_HOME --static -# The next operation may require privileged access to system resources, so use sudo -make && make install +make -j"$(nproc)" +make install cd .. -# Install a symlink for use by native-image -ln -s $MUSL_HOME/bin/musl-gcc $MUSL_HOME/bin/x86_64-linux-musl-gcc +# Install a symlink for use by native-image. +ln -sf "$MUSL_HOME/bin/musl-gcc" "$MUSL_HOME/bin/x86_64-linux-musl-gcc" -# Extend the system path and confirm that musl is available by printing its version +# Extend the system path and confirm that musl is available by printing its version. export PATH="$MUSL_HOME/bin:$PATH" x86_64-linux-musl-gcc --version -# Build zlib with musl from source and install into the MUSL_HOME directory -tar -xzvf zlib-1.2.13.tar.gz -cd zlib-1.2.13 || exit +# Build zlib with musl from source and install into the MUSL_HOME directory. +tar -xzf "zlib-${ZLIB_VERSION}.tar.gz" +cd "zlib-${ZLIB_VERSION}" CC=musl-gcc ./configure --prefix=$MUSL_HOME --static -make && make install +make -j"$(nproc)" +make install cd .. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java b/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java index 77bc147585..82bde814f1 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java @@ -25,6 +25,17 @@ public enum SdkFlag { * condition is resolved before the timeout. */ CANCEL_AWAIT_TIMER_ON_CONDITION(4), + /* + * Changes replay behavior of GetVersion to wait for the matching marker event before executing + * the callback. + * + * Introduced: 1.36.0 + * + * Enabled: (pending) + * + * Bug: https://github.com/temporalio/sdk-java/issues/2796 + */ + VERSION_WAIT_FOR_MARKER(5), UNKNOWN(Integer.MAX_VALUE); private final int value; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java index 560920cac4..c218a82dbe 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java @@ -133,17 +133,20 @@ class InvocationStateMachine private final int minSupported; private final int maxSupported; + private final boolean waitForMarkerRecordedReplaying; private final Functions.Func1 upsertSearchAttributeCallback; private final Functions.Proc2 resultCallback; InvocationStateMachine( int minSupported, int maxSupported, + boolean waitForMarkerRecordedReplaying, Functions.Func1 upsertSearchAttributeCallback, Functions.Proc2 callback) { super(STATE_MACHINE_DEFINITION, VersionStateMachine.this.commandSink, stateMachineSink); this.minSupported = minSupported; this.maxSupported = maxSupported; + this.waitForMarkerRecordedReplaying = waitForMarkerRecordedReplaying; this.upsertSearchAttributeCallback = upsertSearchAttributeCallback; this.resultCallback = Objects.requireNonNull(callback); } @@ -264,9 +267,14 @@ void notifySkippedExecuting() { } void notifyMarkerCreatedReplaying() { + if (waitForMarkerRecordedReplaying) { + // Replay already preloads the version value, so delay the callback until the real marker + // event is matched. + return; + } try { - // it's a replay and the version to return from the getVersion call should be preloaded from - // the history + // It's a replay and the version to return from the getVersion call should be preloaded + // from the history. final boolean usePreloadedVersion = true; validateVersionAndThrow(usePreloadedVersion); notifyFromVersion(usePreloadedVersion); @@ -295,6 +303,14 @@ void flushPreloadedVersionAndUpdateFromEventReplaying() { Preconditions.checkState( preloadedVersion != null, "preloadedVersion is expected to be initialized"); flushPreloadedVersionAndUpdateFromEvent(currentEvent); + if (waitForMarkerRecordedReplaying) { + try { + validateVersionAndThrow(false); + notifyFromVersion(false); + } catch (RuntimeException ex) { + notifyFromException(ex); + } + } } void notifySkippedReplaying() { @@ -393,11 +409,16 @@ private VersionStateMachine( public Integer getVersion( int minSupported, int maxSupported, + boolean waitForMarkerRecordedReplaying, Functions.Func1 upsertSearchAttributeCallback, Functions.Proc2 callback) { InvocationStateMachine ism = new InvocationStateMachine( - minSupported, maxSupported, upsertSearchAttributeCallback, callback); + minSupported, + maxSupported, + waitForMarkerRecordedReplaying, + upsertSearchAttributeCallback, + callback); ism.explicitEvent(ExplicitEvent.CHECK_EXECUTION_STATE); ism.explicitEvent(ExplicitEvent.SCHEDULE); // If the state is SKIPPED_REPLAYING that means we: diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java index d2b5da8a00..884e6947d7 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java @@ -1253,6 +1253,7 @@ public Integer getVersion( return stateMachine.getVersion( minSupported, maxSupported, + checkSdkFlag(SdkFlag.VERSION_WAIT_FOR_MARKER), (version) -> { if (!workflowImplOptions.isEnableUpsertVersionSearchAttributes()) { return null; diff --git a/temporal-sdk/src/test/java/io/temporal/internal/replay/GetVersionInterleavedUpdateReplayTaskHandlerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/replay/GetVersionInterleavedUpdateReplayTaskHandlerTest.java new file mode 100644 index 0000000000..2e90698ff7 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/replay/GetVersionInterleavedUpdateReplayTaskHandlerTest.java @@ -0,0 +1,108 @@ +package io.temporal.internal.replay; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; + +import com.uber.m3.tally.NoopScope; +import io.temporal.api.query.v1.WorkflowQuery; +import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse; +import io.temporal.client.WorkflowClient; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.internal.worker.QueryReplayHelper; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.Worker; +import io.temporal.workflow.versionTests.GetVersionInterleavedUpdateReplayTest; +import io.temporal.workflow.versionTests.GetVersionInterleavedUpdateReplayTest.GreetingWorkflowImpl; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Arrays; +import org.junit.Test; + +public class GetVersionInterleavedUpdateReplayTaskHandlerTest { + private static final String EXPECTED_FIRST_CHANGE_ID = "ChangeId1"; + private static final String EXPECTED_SECOND_CHANGE_ID = "ChangeId2"; + + /** Regression test for the lower-level replay path behind the public replayer API. */ + @Test + public void testReplayDirectQueryWorkflowTaskSucceeds() throws Throwable { + WorkflowExecutionHistory history = + GetVersionInterleavedUpdateReplayTest.captureReplayableHistory(); + assertEquals( + Arrays.asList(EXPECTED_FIRST_CHANGE_ID, EXPECTED_SECOND_CHANGE_ID), + GetVersionInterleavedUpdateReplayTest.extractVersionChangeIds(history.getEvents())); + + TestWorkflowEnvironment testEnvironment = TestWorkflowEnvironment.newInstance(); + ReplayWorkflowRunTaskHandler runTaskHandler = null; + try { + Worker worker = testEnvironment.newWorker(GetVersionInterleavedUpdateReplayTest.TASK_QUEUE); + worker.registerWorkflowImplementationTypes(GreetingWorkflowImpl.class); + + ReplayWorkflowTaskHandler replayTaskHandler = getNonStickyReplayTaskHandler(worker); + PollWorkflowTaskQueueResponse.Builder replayTask = newReplayTask(history); + runTaskHandler = createStatefulHandler(replayTaskHandler, replayTask); + + WorkflowServiceStubs service = + getField(replayTaskHandler, "service", WorkflowServiceStubs.class); + String namespace = getField(replayTaskHandler, "namespace", String.class); + ServiceWorkflowHistoryIterator historyIterator = + new ServiceWorkflowHistoryIterator(service, namespace, replayTask, new NoopScope()); + + QueryResult result = + runTaskHandler.handleDirectQueryWorkflowTask(replayTask, historyIterator); + assertNotNull(result); + assertFalse(result.isWorkflowMethodCompleted()); + assertFalse(result.getResponsePayloads().isPresent()); + } finally { + if (runTaskHandler != null) { + runTaskHandler.close(); + } + testEnvironment.close(); + } + } + + private static PollWorkflowTaskQueueResponse.Builder newReplayTask( + WorkflowExecutionHistory history) { + return PollWorkflowTaskQueueResponse.newBuilder() + .setWorkflowExecution(history.getWorkflowExecution()) + .setWorkflowType( + history + .getHistory() + .getEvents(0) + .getWorkflowExecutionStartedEventAttributes() + .getWorkflowType()) + .setStartedEventId(Long.MAX_VALUE) + .setPreviousStartedEventId(Long.MAX_VALUE) + .setHistory(history.getHistory()) + .setQuery(WorkflowQuery.newBuilder().setQueryType(WorkflowClient.QUERY_TYPE_REPLAY_ONLY)); + } + + private static ReplayWorkflowTaskHandler getNonStickyReplayTaskHandler(Worker worker) + throws Exception { + Object workflowWorker = getField(worker, "workflowWorker", Object.class); + QueryReplayHelper queryReplayHelper = + getField(workflowWorker, "queryReplayHelper", QueryReplayHelper.class); + return getField(queryReplayHelper, "handler", ReplayWorkflowTaskHandler.class); + } + + private static ReplayWorkflowRunTaskHandler createStatefulHandler( + ReplayWorkflowTaskHandler replayTaskHandler, PollWorkflowTaskQueueResponse.Builder replayTask) + throws Exception { + Method method = + ReplayWorkflowTaskHandler.class.getDeclaredMethod( + "createStatefulHandler", + PollWorkflowTaskQueueResponse.Builder.class, + com.uber.m3.tally.Scope.class); + method.setAccessible(true); + return (ReplayWorkflowRunTaskHandler) + method.invoke(replayTaskHandler, replayTask, new NoopScope()); + } + + private static T getField(Object target, String fieldName, Class expectedType) + throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + return expectedType.cast(field.get(target)); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerVersioningTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerVersioningTest.java index be420be024..0713cb9915 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerVersioningTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerVersioningTest.java @@ -146,6 +146,7 @@ public void testBasicWorkerVersioning() { DescribeWorkerDeploymentResponse describeResp1 = waitUntilWorkerDeploymentVisible(v1); setCurrentVersion(v1, describeResp1.getConflictToken()); + waitForRoutingConfigPropagation(v1); // Start workflow 1 which will use the 1.0 worker on auto-upgrade TestWorkflows.QueryableWorkflow wf1 = @@ -160,6 +161,7 @@ public void testBasicWorkerVersioning() { new WorkerDeploymentVersion(testWorkflowRule.getDeploymentName(), "2.0"); DescribeWorkerDeploymentResponse describeResp2 = waitUntilWorkerDeploymentVisible(v2); setCurrentVersion(v2, describeResp2.getConflictToken()); + waitForRoutingConfigPropagation(v2); TestWorkflows.QueryableWorkflow wf2 = testWorkflowRule.newWorkflowStubTimeoutOptions( @@ -173,6 +175,7 @@ public void testBasicWorkerVersioning() { // Set current version to 3.0 setCurrentVersion(v3, describeResp3.getConflictToken()); + waitForRoutingConfigPropagation(v3); TestWorkflows.QueryableWorkflow wf3 = testWorkflowRule.newWorkflowStubTimeoutOptions( @@ -224,8 +227,10 @@ public void testRampWorkerVersioning() { // Set cur ver to 1 & ramp 100% to 2 SetWorkerDeploymentCurrentVersionResponse setCurR = setCurrentVersion(v1, describeResp1.getConflictToken()); + waitForRoutingConfigPropagation(v1); SetWorkerDeploymentRampingVersionResponse rampResp = setRampingVersion(v2, 100, setCurR.getConflictToken()); + waitForRoutingConfigPropagation(v1, v2); // Run workflows and verify they've both started & run on v2 for (int i = 0; i < 3; i++) { String res = runWorkflow("versioning-ramp-100"); @@ -234,12 +239,14 @@ public void testRampWorkerVersioning() { // Set ramp to 0, and see them start on v1 SetWorkerDeploymentRampingVersionResponse rampResp2 = setRampingVersion(v2, 0, rampResp.getConflictToken()); + waitForRoutingConfigPropagation(v1, v2); for (int i = 0; i < 3; i++) { String res = runWorkflow("versioning-ramp-0"); Assert.assertEquals("version-v1", res); } // Set to 50% and see we eventually will have one run on v1 and one on v2 setRampingVersion(v2, 50, rampResp2.getConflictToken()); + waitForRoutingConfigPropagation(v1, v2); HashSet seenRanOn = new HashSet<>(); Eventually.assertEventually( Duration.ofSeconds(30), diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionAsyncLocalActivityReplayTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionAsyncLocalActivityReplayTest.java new file mode 100644 index 0000000000..1a36b7c4ab --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionAsyncLocalActivityReplayTest.java @@ -0,0 +1,183 @@ +package io.temporal.workflow.versionTests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.LocalActivityOptions; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.internal.common.SdkFlag; +import io.temporal.internal.history.VersionMarkerUtils; +import io.temporal.internal.statemachines.WorkflowStateMachines; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.WorkflowReplayer; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerOptions; +import io.temporal.workflow.Async; +import io.temporal.workflow.CompletablePromise; +import io.temporal.workflow.Promise; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.unsafe.WorkflowUnsafe; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class GetVersionAsyncLocalActivityReplayTest { + private static final String TASK_QUEUE = "get-version-async-local-activity-replay"; + private static final String CHANGE_ID = "async-local-activity-change"; + + private static boolean hasReplayed; + + private List savedInitialFlags; + + @Before + public void setUp() { + hasReplayed = false; + savedInitialFlags = WorkflowStateMachines.initialFlags; + WorkflowStateMachines.initialFlags = + Collections.singletonList(SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION); + } + + @After + public void tearDown() { + WorkflowStateMachines.initialFlags = savedInitialFlags; + } + + @Test + public void testGetVersionReplayWithAsyncLocalActivitiesKeepsExpectCBoundToC() throws Exception { + WorkflowExecutionHistory history = executeWorkflowAndCaptureHistory(); + + assertTrue(hasReplayed); + assertTrue(hasVersionMarker(history, CHANGE_ID)); + assertTrue(hasSdkFlag(history, SdkFlag.SKIP_YIELD_ON_VERSION)); + + WorkflowReplayer.replayWorkflowExecution(history, AsyncLocalActivityWorkflowImpl.class); + } + + private WorkflowExecutionHistory executeWorkflowAndCaptureHistory() { + try (TestWorkflowEnvironment testEnvironment = TestWorkflowEnvironment.newInstance()) { + Worker worker = + testEnvironment.newWorker( + TASK_QUEUE, + WorkerOptions.newBuilder() + .setStickyQueueScheduleToStartTimeout(Duration.ZERO) + .build()); + worker.registerWorkflowImplementationTypes(AsyncLocalActivityWorkflowImpl.class); + worker.registerActivitiesImplementations(new EchoActivitiesImpl()); + testEnvironment.start(); + + WorkflowClient client = testEnvironment.getWorkflowClient(); + ReplayTestWorkflow workflow = + client.newWorkflowStub( + ReplayTestWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(TASK_QUEUE) + .setWorkflowRunTimeout(Duration.ofMinutes(1)) + .setWorkflowTaskTimeout(Duration.ofSeconds(5)) + .build()); + + WorkflowExecution execution = WorkflowClient.start(workflow::execute); + assertEquals("ABC", WorkflowStub.fromTyped(workflow).getResult(String.class)); + + return client.fetchHistory(execution.getWorkflowId(), execution.getRunId()); + } + } + + private static boolean hasSdkFlag(WorkflowExecutionHistory history, SdkFlag flag) { + for (HistoryEvent event : history.getEvents()) { + if (event.getEventType() != EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED) { + continue; + } + if (!event.getWorkflowTaskCompletedEventAttributes().hasSdkMetadata()) { + continue; + } + if (event + .getWorkflowTaskCompletedEventAttributes() + .getSdkMetadata() + .getLangUsedFlagsList() + .contains(flag.getValue())) { + return true; + } + } + return false; + } + + private static boolean hasVersionMarker(WorkflowExecutionHistory history, String changeId) { + for (HistoryEvent event : history.getEvents()) { + if (changeId.equals(VersionMarkerUtils.tryGetChangeIdFromVersionMarkerEvent(event))) { + return true; + } + } + return false; + } + + @WorkflowInterface + public interface ReplayTestWorkflow { + @WorkflowMethod + String execute(); + } + + @ActivityInterface + public interface EchoActivities { + @ActivityMethod + String echo(String value); + } + + public static class EchoActivitiesImpl implements EchoActivities { + @Override + public String echo(String value) { + return value.toUpperCase(Locale.ROOT); + } + } + + public static class AsyncLocalActivityWorkflowImpl implements ReplayTestWorkflow { + private final EchoActivities echoActivities = + Workflow.newLocalActivityStub( + EchoActivities.class, + LocalActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(5)) + .build()); + + @Override + public String execute() { + CompletablePromise expectA = Workflow.newPromise(); + CompletablePromise expectB = Workflow.newPromise(); + Promise asyncBranch = + Async.procedure( + () -> { + expectA.complete(echoActivities.echo("a")); + expectB.complete(echoActivities.echo("b")); + }); + + int version = Workflow.getVersion(CHANGE_ID, Workflow.DEFAULT_VERSION, 1); + assertEquals(1, version); + + String expectC = echoActivities.echo("c"); + asyncBranch.get(); + + assertEquals("A", expectA.get()); + assertEquals("B", expectB.get()); + assertEquals("C", expectC); + + if (WorkflowUnsafe.isReplaying()) { + hasReplayed = true; + } + + Workflow.sleep(Duration.ofSeconds(1)); + return expectA.get() + expectB.get() + expectC; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java new file mode 100644 index 0000000000..73f895a744 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java @@ -0,0 +1,262 @@ +package io.temporal.workflow.versionTests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.RetryOptions; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.internal.common.SdkFlag; +import io.temporal.internal.history.VersionMarkerUtils; +import io.temporal.internal.statemachines.WorkflowStateMachines; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.WorkflowHistoryLoader; +import io.temporal.testing.WorkflowReplayer; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.Worker; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.Test; +import org.slf4j.Logger; + +/** + * Mirrors app/src/main/kotlin/io/temporal/samples/update_nde/GreetingWorkflow.kt from + * gauravthadani/samples-kotlin and captures histories that exercise interleaved updates around + * getVersion. + */ +public class GetVersionInterleavedUpdateReplayTest { + private static final String HISTORY_RESOURCE = + "testGetVersionInterleavedUpdateReplayHistory.json"; + private static final String WAIT_FOR_MARKER_HISTORY_RESOURCE = + "testGetVersionInterleavedUpdateReplayWaitForMarkerHistory.json"; + public static final String TASK_QUEUE = "get-version-interleaved-update-replay"; + private static final String EXPECTED_FIRST_CHANGE_ID = "ChangeId1"; + private static final String EXPECTED_SECOND_CHANGE_ID = "ChangeId2"; + + /** + * This recorded history predates {@link SdkFlag#SKIP_YIELD_ON_VERSION}, so it no longer matches + * the histories produced by the current branch. + * + *

Keep this fixture as a reproducer that old histories without the newer flags still preserve + * the old failure. Making this exact history replay again would require changing replay behavior + * for histories that did not record the newer flags, which may break other existing replays. The + * fix is to put the state-machine behavior change behind an SDK flag {@link + * SdkFlag#VERSION_WAIT_FOR_MARKER}, and to make sure new workflows run with {@link + * SdkFlag#SKIP_YIELD_ON_VERSION} by default to avoid interleaved histories. + */ + @Test + public void testReplayHistoryWithoutFlagStillFails() { + RuntimeException replayFailure = + assertThrows( + RuntimeException.class, + () -> + WorkflowReplayer.replayWorkflowExecutionFromResource( + HISTORY_RESOURCE, GreetingWorkflowImpl.class)); + + assertTrue( + replayFailure + .getMessage() + .contains("[TMPRL1100] getVersion call before the existing version marker event")); + } + + @Test + public void testReproducedHistoryReplays() throws Exception { + WorkflowExecutionHistory history = captureReplayableHistory(); + + assertEquals( + Arrays.asList(EXPECTED_FIRST_CHANGE_ID, EXPECTED_SECOND_CHANGE_ID), + extractVersionChangeIds(history.getEvents())); + assertTrue( + "The reproduced history must advertise SKIP_YIELD_ON_VERSION.", + hasSdkFlag(history, SdkFlag.SKIP_YIELD_ON_VERSION)); + assertTrue( + "The reproduced history must include at least one completed update.", + hasEvent(history.getEvents(), EventType.EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED)); + + WorkflowReplayer.replayWorkflowExecution(history, GreetingWorkflowImpl.class); + } + + @Test + public void testReplayHistoryWithWaitForMarkerFlagReplaysWithoutDefaultEnable() throws Exception { + WorkflowExecutionHistory history = + WorkflowHistoryLoader.readHistoryFromResource(WAIT_FOR_MARKER_HISTORY_RESOURCE); + assertTrue( + "The recorded history must advertise VERSION_WAIT_FOR_MARKER.", + hasSdkFlag(history, SdkFlag.VERSION_WAIT_FOR_MARKER)); + + List savedInitialFlags = WorkflowStateMachines.initialFlags; + List replayFlags = new ArrayList<>(savedInitialFlags); + replayFlags.remove(SdkFlag.VERSION_WAIT_FOR_MARKER); + WorkflowStateMachines.initialFlags = Collections.unmodifiableList(replayFlags); + try { + WorkflowReplayer.replayWorkflowExecution(history, GreetingWorkflowImpl.class); + } finally { + WorkflowStateMachines.initialFlags = savedInitialFlags; + } + } + + public static WorkflowExecutionHistory captureReplayableHistory() { + List savedInitialFlags = WorkflowStateMachines.initialFlags; + List replayableFlags = new ArrayList<>(savedInitialFlags); + if (!replayableFlags.contains(SdkFlag.SKIP_YIELD_ON_VERSION)) { + replayableFlags.add(SdkFlag.SKIP_YIELD_ON_VERSION); + } + WorkflowStateMachines.initialFlags = Collections.unmodifiableList(replayableFlags); + try (TestWorkflowEnvironment testEnvironment = TestWorkflowEnvironment.newInstance()) { + Worker worker = testEnvironment.newWorker(TASK_QUEUE); + worker.registerWorkflowImplementationTypes(GreetingWorkflowImpl.class); + testEnvironment.start(); + + WorkflowClient client = testEnvironment.getWorkflowClient(); + GreetingWorkflow workflow = + client.newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(TASK_QUEUE) + .setWorkflowId(UUID.randomUUID().toString()) + .build()); + WorkflowExecution execution = WorkflowClient.start(workflow::greeting, "Temporal"); + + WorkflowStub workflowStub = WorkflowStub.fromTyped(workflow); + SDKTestWorkflowRule.waitForOKQuery(workflowStub); + assertEquals("works", workflow.notify("update")); + + return client.fetchHistory(execution.getWorkflowId(), execution.getRunId()); + } finally { + WorkflowStateMachines.initialFlags = savedInitialFlags; + } + } + + public static List extractVersionChangeIds(List events) { + List changeIds = new ArrayList<>(); + for (HistoryEvent event : events) { + String changeId = VersionMarkerUtils.tryGetChangeIdFromVersionMarkerEvent(event); + if (changeId != null) { + changeIds.add(changeId); + } + } + return changeIds; + } + + private static boolean hasSdkFlag(WorkflowExecutionHistory history, SdkFlag flag) { + for (HistoryEvent event : history.getEvents()) { + if (event.getEventType() != EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED) { + continue; + } + if (!event.getWorkflowTaskCompletedEventAttributes().hasSdkMetadata()) { + continue; + } + if (event + .getWorkflowTaskCompletedEventAttributes() + .getSdkMetadata() + .getLangUsedFlagsList() + .contains(flag.getValue())) { + return true; + } + } + return false; + } + + private static boolean hasEvent(List events, EventType eventType) { + for (HistoryEvent event : events) { + if (event.getEventType() == eventType) { + return true; + } + } + return false; + } + + public static class Request { + private final String name; + private final OffsetDateTime date; + + public Request(String name, OffsetDateTime date) { + this.name = name; + this.date = date; + } + + public String getName() { + return name; + } + + public OffsetDateTime getDate() { + return date; + } + } + + @WorkflowInterface + public interface GreetingWorkflow { + @WorkflowMethod + String greeting(String name); + + @UpdateMethod + String notify(String name); + } + + public static class GreetingWorkflowImpl implements GreetingWorkflow { + private final Logger logger = Workflow.getLogger(GreetingWorkflow.class); + + public GreetingWorkflowImpl() { + logger.info("Workflow is initialized"); + } + + private GreetingActivities getActivities() { + return Workflow.newActivityStub( + GreetingActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build()); + } + + @Override + public String greeting(String name) { + logger.info("Workflow started"); + + Workflow.getVersion("ChangeId1", 0, 1); + Workflow.getVersion("ChangeId2", 0, 1); + + Workflow.await(() -> false); + return getActivities().composeGreeting("hello", name); + } + + @Override + public String notify(String name) { + logger.info("Signal received: {}", name); + Workflow.sideEffect(UUID.class, UUID::randomUUID); + return "works"; + } + } + + public static class GreetingActivitiesImpl implements GreetingActivities { + @Override + public String composeGreeting(String greeting, String name) { + System.out.println("Greeting started: " + greeting); + return greeting + ", " + name + "!"; + } + } + + @ActivityInterface + public interface GreetingActivities { + @ActivityMethod(name = "greet") + String composeGreeting(String greeting, String name); + } +} diff --git a/temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayHistory.json b/temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayHistory.json new file mode 100644 index 0000000000..950c0387d6 --- /dev/null +++ b/temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayHistory.json @@ -0,0 +1,1069 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-02-23T06:56:17.252716209Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "version": "100265", + "taskId": "269777867", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "GreetingWorkflow" + }, + "taskQueue": { + "name": "HelloActivityTaskQueue", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IjIwMjYtMDItMjNUMTc6NTY6MTUuNjU4Njk2KzExOjAwIg==" + } + ] + }, + "workflowExecutionTimeout": "0s", + "workflowRunTimeout": "0s", + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "019c8948-d364-7ae9-8664-d048fecb96eb", + "identity": "85298@Gauravs-MacBook-Pro.local", + "firstExecutionRunId": "019c8948-d364-7ae9-8664-d048fecb96eb", + "attempt": 1, + "firstWorkflowTaskBackoff": "0s", + "header": {}, + "workflowId": "WORKFLOW_ID_2bc8474d-11d3-47f4-a93d-90ac85c55d29" + } + }, + { + "eventId": "2", + "eventTime": "2026-02-23T06:56:17.252853556Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "version": "100265", + "taskId": "269777868", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "HelloActivityTaskQueue", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-02-23T06:56:40.930085120Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "version": "100265", + "taskId": "269777873", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "85431@Gauravs-MacBook-Pro.local", + "requestId": "e46ac299-489f-436e-bceb-1833e070408e", + "historySizeBytes": "388" + } + }, + { + "eventId": "4", + "eventTime": "2026-02-23T06:56:41.431966628Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "version": "100265", + "taskId": "269777877", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "startedEventId": "3", + "identity": "85431@Gauravs-MacBook-Pro.local", + "workerVersion": {}, + "sdkMetadata": { + "langUsedFlags": [ + 1 + ], + "sdkName": "temporal-java", + "sdkVersion": "1.32.1" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-02-23T06:56:41.432163996Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777878", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "f91c4b0b-61ae-4767-81d0-b9938fbf6dea", + "acceptedRequestMessageId": "f91c4b0b-61ae-4767-81d0-b9938fbf6dea/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "f91c4b0b-61ae-4767-81d0-b9938fbf6dea", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDki" + } + ] + } + } + } + } + }, + { + "eventId": "6", + "eventTime": "2026-02-23T06:56:41.432259357Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777879", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IjcyMjg3YWMxLWUyMTQtNGM4MC05OGVkLWY1YjNjZTA4Nzg5OCI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "7", + "eventTime": "2026-02-23T06:56:41.432281438Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777880", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "d66dd71c-dc64-4e37-a60e-ab0e5dee941b", + "acceptedRequestMessageId": "d66dd71c-dc64-4e37-a60e-ab0e5dee941b/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "d66dd71c-dc64-4e37-a60e-ab0e5dee941b", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDEi" + } + ] + } + } + } + } + }, + { + "eventId": "8", + "eventTime": "2026-02-23T06:56:41.432304437Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777881", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImJkNWJjNWMwLTBhMDUtNGFjMS05ZDUyLTE4Yjc1ZDRkNjE4MSI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "9", + "eventTime": "2026-02-23T06:56:41.432322160Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777882", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "a6c6599a-0774-4746-8d68-0a21641292de", + "acceptedRequestMessageId": "a6c6599a-0774-4746-8d68-0a21641292de/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "a6c6599a-0774-4746-8d68-0a21641292de", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDEwIg==" + } + ] + } + } + } + } + }, + { + "eventId": "10", + "eventTime": "2026-02-23T06:56:41.432343822Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777883", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImEwMjgzMGY1LWZlNmMtNDI2NS1iNzBjLTdiMmE2OTFkNDYxMCI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "11", + "eventTime": "2026-02-23T06:56:41.432373919Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777884", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "1ca000bf-a781-45c8-a9fb-fa6ddaab87b3", + "acceptedRequestMessageId": "1ca000bf-a781-45c8-a9fb-fa6ddaab87b3/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "1ca000bf-a781-45c8-a9fb-fa6ddaab87b3", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDci" + } + ] + } + } + } + } + }, + { + "eventId": "12", + "eventTime": "2026-02-23T06:56:41.432411958Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777885", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "Ijc5OTM3NTM2LWNmMWEtNGY5Ni05YjdmLWE5NWY0MzE1YmIxMCI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "13", + "eventTime": "2026-02-23T06:56:41.432432135Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777886", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "598b6a70-d476-4151-aadc-540f96d76372", + "acceptedRequestMessageId": "598b6a70-d476-4151-aadc-540f96d76372/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "598b6a70-d476-4151-aadc-540f96d76372", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDIi" + } + ] + } + } + } + } + }, + { + "eventId": "14", + "eventTime": "2026-02-23T06:56:41.432453739Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777887", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImRkODVkNTMwLWMxN2QtNDg2Ny1iM2QwLWY3Mzk5MWE3ZWJjNCI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "15", + "eventTime": "2026-02-23T06:56:41.432471258Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777888", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "f5f7a12c-c159-4c0b-984e-2de35b6f15e5", + "acceptedRequestMessageId": "f5f7a12c-c159-4c0b-984e-2de35b6f15e5/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "f5f7a12c-c159-4c0b-984e-2de35b6f15e5", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDUi" + } + ] + } + } + } + } + }, + { + "eventId": "16", + "eventTime": "2026-02-23T06:56:41.432498893Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777889", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IjRkNTgzNTliLTE0OTAtNGNiOS1hMTI4LWQ5OTBlY2UzYmFiNSI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "17", + "eventTime": "2026-02-23T06:56:41.432521769Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777890", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "6fbaa9a4-05d1-4d62-8a88-b76d9e913e06", + "acceptedRequestMessageId": "6fbaa9a4-05d1-4d62-8a88-b76d9e913e06/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "6fbaa9a4-05d1-4d62-8a88-b76d9e913e06", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDMi" + } + ] + } + } + } + } + }, + { + "eventId": "18", + "eventTime": "2026-02-23T06:56:41.432547878Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777891", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IjA1ZWY0OGFhLWIzMDItNDcxMy04N2JmLTM5Mzg3MDVjYTkwOCI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "19", + "eventTime": "2026-02-23T06:56:41.432567726Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777892", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "58b8174f-2735-4609-867c-e78e08e95ab5", + "acceptedRequestMessageId": "58b8174f-2735-4609-867c-e78e08e95ab5/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "58b8174f-2735-4609-867c-e78e08e95ab5", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDgi" + } + ] + } + } + } + } + }, + { + "eventId": "20", + "eventTime": "2026-02-23T06:56:41.432591390Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777893", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImMwYjBiYzRlLWMyZmItNDlkYy04NTA0LTcwMDhiY2Y4NjNiYSI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "21", + "eventTime": "2026-02-23T06:56:41.432610107Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777894", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "44502c0d-be6f-423f-ba79-cf892a639768", + "acceptedRequestMessageId": "44502c0d-be6f-423f-ba79-cf892a639768/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "44502c0d-be6f-423f-ba79-cf892a639768", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDYi" + } + ] + } + } + } + } + }, + { + "eventId": "22", + "eventTime": "2026-02-23T06:56:41.432639883Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777895", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImJkZGRlNmRjLTdmYWYtNGM4Yi05NjZjLTQ4MjFlNmNiMzIzYiI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "23", + "eventTime": "2026-02-23T06:56:41.432656671Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "version": "100265", + "taskId": "269777896", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "543dea54-c2ac-49ed-8098-d84c19724cc9", + "acceptedRequestMessageId": "543dea54-c2ac-49ed-8098-d84c19724cc9/request", + "acceptedRequestSequencingEventId": "2", + "acceptedRequest": { + "meta": { + "updateId": "543dea54-c2ac-49ed-8098-d84c19724cc9", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "InZhbDQi" + } + ] + } + } + } + } + }, + { + "eventId": "24", + "eventTime": "2026-02-23T06:56:41.432685603Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777897", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IjM5NjVhYjI3LWUwNDYtNGQyZC1iYjBjLTJkOTJiMThhNTJhOCI=" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "25", + "eventTime": "2026-02-23T06:56:41.432695745Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777898", + "markerRecordedEventAttributes": { + "markerName": "Version", + "details": { + "changeId": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IkNoYW5nZUlkMSI=" + } + ] + }, + "version": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "MQ==" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "26", + "eventTime": "2026-02-23T06:56:41.432728762Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777899", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "f91c4b0b-61ae-4767-81d0-b9938fbf6dea", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "5", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "27", + "eventTime": "2026-02-23T06:56:41.432759704Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777900", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "d66dd71c-dc64-4e37-a60e-ab0e5dee941b", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "7", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "28", + "eventTime": "2026-02-23T06:56:41.432791754Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777901", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "a6c6599a-0774-4746-8d68-0a21641292de", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "9", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "29", + "eventTime": "2026-02-23T06:56:41.432821654Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777902", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "1ca000bf-a781-45c8-a9fb-fa6ddaab87b3", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "11", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "30", + "eventTime": "2026-02-23T06:56:41.432845613Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777903", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "598b6a70-d476-4151-aadc-540f96d76372", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "13", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "31", + "eventTime": "2026-02-23T06:56:41.432872469Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777904", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "f5f7a12c-c159-4c0b-984e-2de35b6f15e5", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "15", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "32", + "eventTime": "2026-02-23T06:56:41.432897002Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777905", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "6fbaa9a4-05d1-4d62-8a88-b76d9e913e06", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "17", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "33", + "eventTime": "2026-02-23T06:56:41.432919961Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777906", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "58b8174f-2735-4609-867c-e78e08e95ab5", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "19", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "34", + "eventTime": "2026-02-23T06:56:41.432944158Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777907", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "44502c0d-be6f-423f-ba79-cf892a639768", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "21", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "35", + "eventTime": "2026-02-23T06:56:41.432966813Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "version": "100265", + "taskId": "269777908", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "543dea54-c2ac-49ed-8098-d84c19724cc9", + "identity": "85298@Gauravs-MacBook-Pro.local" + }, + "acceptedEventId": "23", + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IndvcmtzIg==" + } + ] + } + } + } + }, + { + "eventId": "36", + "eventTime": "2026-02-23T06:56:41.432995818Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "version": "100265", + "taskId": "269777909", + "markerRecordedEventAttributes": { + "markerName": "Version", + "details": { + "changeId": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "IkNoYW5nZUlkMiI=" + } + ] + }, + "version": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "MQ==" + } + ] + } + }, + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "37", + "eventTime": "2026-02-23T06:56:54.154170962Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "version": "100265", + "taskId": "269777912", + "workflowExecutionSignaledEventAttributes": { + "signalName": "test", + "input": {}, + "identity": "gaurav.thadani@temporal.io - webui" + } + }, + { + "eventId": "38", + "eventTime": "2026-02-23T06:56:54.154175360Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "version": "100265", + "taskId": "269777913", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "85431@Gauravs-MacBook-Pro.local:fb2f24f2-467c-4aab-82db-b5a2e2a42c0d", + "kind": "TASK_QUEUE_KIND_STICKY", + "normalName": "HelloActivityTaskQueue" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "39", + "eventTime": "2026-02-23T06:56:59.158018087Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT", + "version": "100265", + "taskId": "269777917", + "workflowTaskTimedOutEventAttributes": { + "scheduledEventId": "38", + "timeoutType": "TIMEOUT_TYPE_SCHEDULE_TO_START" + } + }, + { + "eventId": "40", + "eventTime": "2026-02-23T06:56:59.158026481Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "version": "100265", + "taskId": "269777918", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "HelloActivityTaskQueue", + "kind": "TASK_QUEUE_KIND_NORMAL" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "41", + "eventTime": "2026-02-23T06:56:59.165746096Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "version": "100265", + "taskId": "269777921", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "40", + "identity": "85505@Gauravs-MacBook-Pro.local", + "requestId": "71c5fe60-8ab5-4286-b1b9-d640ed56c2b8", + "historySizeBytes": "6340" + } + }, + { + "eventId": "42", + "eventTime": "2026-02-23T06:56:59.860055251Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_FAILED", + "version": "100265", + "taskId": "269777925", + "workflowTaskFailedEventAttributes": { + "scheduledEventId": "40", + "startedEventId": "41", + "cause": "WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE", + "failure": { + "message": "Failure handling event 26 of type 'EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED' during replay. {WorkflowTaskStartedEventId=41, CurrentStartedEventId=3}", + "source": "JavaSDK", + "stackTrace": "io.temporal.internal.statemachines.WorkflowStateMachines.createEventProcessingException(WorkflowStateMachines.java:445)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleEventsBatch(WorkflowStateMachines.java:346)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleEvent(WorkflowStateMachines.java:305)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.applyServerHistory(ReplayWorkflowRunTaskHandler.java:246)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTaskImpl(ReplayWorkflowRunTaskHandler.java:228)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTask(ReplayWorkflowRunTaskHandler.java:151)\nio.temporal.internal.replay.ReplayWorkflowTaskHandler.handleWorkflowTaskWithQuery(ReplayWorkflowTaskHandler.java:115)\nio.temporal.internal.replay.ReplayWorkflowTaskHandler.handleWorkflowTask(ReplayWorkflowTaskHandler.java:80)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handleTask(WorkflowWorker.java:564)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handle(WorkflowWorker.java:403)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handle(WorkflowWorker.java:343)\nio.temporal.internal.worker.PollTaskExecutor.lambda$process$1(PollTaskExecutor.java:76)\njava.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)\njava.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)\njava.base/java.lang.Thread.run(Thread.java:1583)\n", + "cause": { + "message": "Version: failure executing RESULT_NOTIFIED_REPLAYING->NON_MATCHING_EVENT, transition history is [CREATED->CHECK_EXECUTION_STATE, REPLAYING->SCHEDULE, MARKER_COMMAND_CREATED_REPLAYING->RECORD_MARKER]", + "source": "JavaSDK", + "stackTrace": "io.temporal.internal.statemachines.StateMachine.executeTransition(StateMachine.java:143)\nio.temporal.internal.statemachines.StateMachine.handleExplicitEvent(StateMachine.java:73)\nio.temporal.internal.statemachines.EntityStateMachineBase.explicitEvent(EntityStateMachineBase.java:75)\nio.temporal.internal.statemachines.VersionStateMachine$InvocationStateMachine.handleEvent(VersionStateMachine.java:165)\nio.temporal.internal.statemachines.CancellableCommand.handleEvent(CancellableCommand.java:53)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleCommandEvent(WorkflowStateMachines.java:583)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleSingleEvent(WorkflowStateMachines.java:477)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleEventsBatch(WorkflowStateMachines.java:344)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleEvent(WorkflowStateMachines.java:305)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.applyServerHistory(ReplayWorkflowRunTaskHandler.java:246)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTaskImpl(ReplayWorkflowRunTaskHandler.java:228)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTask(ReplayWorkflowRunTaskHandler.java:151)\nio.temporal.internal.replay.ReplayWorkflowTaskHandler.handleWorkflowTaskWithQuery(ReplayWorkflowTaskHandler.java:115)\nio.temporal.internal.replay.ReplayWorkflowTaskHandler.handleWorkflowTask(ReplayWorkflowTaskHandler.java:80)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handleTask(WorkflowWorker.java:564)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handle(WorkflowWorker.java:403)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handle(WorkflowWorker.java:343)\nio.temporal.internal.worker.PollTaskExecutor.lambda$process$1(PollTaskExecutor.java:76)\njava.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)\njava.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)\njava.base/java.lang.Thread.run(Thread.java:1583)\n", + "cause": { + "message": "[TMPRL1100] getVersion call before the existing version marker event. The most probable cause is retroactive addition of a getVersion call with an existing 'changeId'", + "source": "JavaSDK", + "stackTrace": "io.temporal.internal.statemachines.VersionStateMachine$InvocationStateMachine.missingMarkerReplaying(VersionStateMachine.java:328)\nio.temporal.internal.statemachines.FixedTransitionAction.apply(FixedTransitionAction.java:26)\nio.temporal.internal.statemachines.StateMachine.executeTransition(StateMachine.java:139)\nio.temporal.internal.statemachines.StateMachine.handleExplicitEvent(StateMachine.java:73)\nio.temporal.internal.statemachines.EntityStateMachineBase.explicitEvent(EntityStateMachineBase.java:75)\nio.temporal.internal.statemachines.VersionStateMachine$InvocationStateMachine.handleEvent(VersionStateMachine.java:165)\nio.temporal.internal.statemachines.CancellableCommand.handleEvent(CancellableCommand.java:53)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleCommandEvent(WorkflowStateMachines.java:583)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleSingleEvent(WorkflowStateMachines.java:477)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleEventsBatch(WorkflowStateMachines.java:344)\nio.temporal.internal.statemachines.WorkflowStateMachines.handleEvent(WorkflowStateMachines.java:305)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.applyServerHistory(ReplayWorkflowRunTaskHandler.java:246)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTaskImpl(ReplayWorkflowRunTaskHandler.java:228)\nio.temporal.internal.replay.ReplayWorkflowRunTaskHandler.handleWorkflowTask(ReplayWorkflowRunTaskHandler.java:151)\nio.temporal.internal.replay.ReplayWorkflowTaskHandler.handleWorkflowTaskWithQuery(ReplayWorkflowTaskHandler.java:115)\nio.temporal.internal.replay.ReplayWorkflowTaskHandler.handleWorkflowTask(ReplayWorkflowTaskHandler.java:80)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handleTask(WorkflowWorker.java:564)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handle(WorkflowWorker.java:403)\nio.temporal.internal.worker.WorkflowWorker$TaskHandlerImpl.handle(WorkflowWorker.java:343)\nio.temporal.internal.worker.PollTaskExecutor.lambda$process$1(PollTaskExecutor.java:76)\njava.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)\njava.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)\njava.base/java.lang.Thread.run(Thread.java:1583)\n", + "applicationFailureInfo": { + "type": "io.temporal.worker.NonDeterministicException" + } + }, + "applicationFailureInfo": { + "type": "java.lang.RuntimeException" + } + }, + "applicationFailureInfo": { + "type": "io.temporal.internal.statemachines.InternalWorkflowTaskException" + } + }, + "identity": "85505@Gauravs-MacBook-Pro.local" + } + } + ] +} \ No newline at end of file diff --git a/temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayWaitForMarkerHistory.json b/temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayWaitForMarkerHistory.json new file mode 100644 index 0000000000..6beec688c0 --- /dev/null +++ b/temporal-sdk/src/test/resources/testGetVersionInterleavedUpdateReplayWaitForMarkerHistory.json @@ -0,0 +1,283 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-04-01T20:02:55.362Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "GreetingWorkflow" + }, + "taskQueue": { + "name": "get-version-interleaved-update-replay" + }, + "input": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "IlRlbXBvcmFsIg\u003d\u003d" + } + ] + }, + "workflowExecutionTimeout": "315360000s", + "workflowRunTimeout": "315360000s", + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "f9105a8c-5934-4674-a3bf-b537ade1ef06", + "identity": "46549@ambrose.local", + "firstExecutionRunId": "f9105a8c-5934-4674-a3bf-b537ade1ef06", + "attempt": 1, + "firstWorkflowTaskBackoff": "0s", + "header": {} + } + }, + { + "eventId": "2", + "eventTime": "2026-04-01T20:02:55.362Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "get-version-interleaved-update-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-04-01T20:02:55.368Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "46549@ambrose.local" + } + }, + { + "eventId": "4", + "eventTime": "2026-04-01T20:02:55.423Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "identity": "46549@ambrose.local", + "sdkMetadata": { + "langUsedFlags": [ + 1, + 2, + 3, + 5 + ], + "sdkName": "temporal-java", + "sdkVersion": "1.34.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-04-01T20:02:55.423Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "markerRecordedEventAttributes": { + "markerName": "Version", + "details": { + "changeId": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "IkNoYW5nZUlkMSI\u003d" + } + ] + }, + "version": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "MQ\u003d\u003d" + } + ] + } + }, + "workflowTaskCompletedEventId": "3" + } + }, + { + "eventId": "6", + "eventTime": "2026-04-01T20:02:55.423Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "markerRecordedEventAttributes": { + "markerName": "Version", + "details": { + "changeId": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "IkNoYW5nZUlkMiI\u003d" + } + ] + }, + "version": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "MQ\u003d\u003d" + } + ] + } + }, + "workflowTaskCompletedEventId": "3" + } + }, + { + "eventId": "7", + "eventTime": "2026-04-01T20:02:55.423Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "get-version-interleaved-update-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 2 + } + }, + { + "eventId": "8", + "eventTime": "2026-04-01T20:02:55.423Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "7", + "identity": "46549@ambrose.local" + } + }, + { + "eventId": "9", + "eventTime": "2026-04-01T20:02:55.425Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "7", + "identity": "46549@ambrose.local", + "sdkMetadata": { + "sdkName": "temporal-java", + "sdkVersion": "1.34.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "10", + "eventTime": "2026-04-01T20:02:55.428Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "get-version-interleaved-update-replay" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "11", + "eventTime": "2026-04-01T20:02:55.428Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "10", + "identity": "46549@ambrose.local" + } + }, + { + "eventId": "12", + "eventTime": "2026-04-01T20:02:55.443Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "10", + "identity": "46549@ambrose.local", + "sdkMetadata": { + "sdkName": "temporal-java", + "sdkVersion": "1.34.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "13", + "eventTime": "2026-04-01T20:02:55.443Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_ACCEPTED", + "workflowExecutionUpdateAcceptedEventAttributes": { + "protocolInstanceId": "4d85fa45-8cae-466a-b92e-196ceca2fd77", + "acceptedRequestMessageId": "4d85fa45-8cae-466a-b92e-196ceca2fd77/request", + "acceptedRequestSequencingEventId": "10", + "acceptedRequest": { + "meta": { + "updateId": "4d85fa45-8cae-466a-b92e-196ceca2fd77", + "identity": "46549@ambrose.local" + }, + "input": { + "header": {}, + "name": "notify", + "args": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "InVwZGF0ZSI\u003d" + } + ] + } + } + } + } + }, + { + "eventId": "14", + "eventTime": "2026-04-01T20:02:55.443Z", + "eventType": "EVENT_TYPE_MARKER_RECORDED", + "markerRecordedEventAttributes": { + "markerName": "SideEffect", + "details": { + "data": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "IjRjN2JhZDI5LWVkMmYtNDZjNS1hZTY5LTUwOWJhMmFmOWIzYSI\u003d" + } + ] + } + }, + "workflowTaskCompletedEventId": "11" + } + }, + { + "eventId": "15", + "eventTime": "2026-04-01T20:02:55.443Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_UPDATE_COMPLETED", + "workflowExecutionUpdateCompletedEventAttributes": { + "meta": { + "updateId": "4d85fa45-8cae-466a-b92e-196ceca2fd77", + "identity": "46549@ambrose.local" + }, + "outcome": { + "success": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg\u003d\u003d" + }, + "data": "IndvcmtzIg\u003d\u003d" + } + ] + } + } + } + } + ] +} \ No newline at end of file From 62a7f08a7c1d7cf6a65918da88e5a1cf5b60846a Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Wed, 10 Jun 2026 16:12:24 -0400 Subject: [PATCH 006/107] Fix flaky test `NexusWorkflowTest.testNexusOperationTimeout_AfterStart` (#2908) --- .github/workflows/ci.yml | 2 +- .../functional/NexusWorkflowTest.java | 61 +++++++++++++++---- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3ba1fa3b3..03e0478cf1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: - name: Start CLI server env: - TEMPORAL_CLI_VERSION: 1.7.0 + TEMPORAL_CLI_VERSION: 1.7.1-standalone-nexus-operations run: | wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz tar -xzf temporal_cli.tar.gz diff --git a/temporal-test-server/src/test/java/io/temporal/testserver/functional/NexusWorkflowTest.java b/temporal-test-server/src/test/java/io/temporal/testserver/functional/NexusWorkflowTest.java index 558b7fcf9c..b059d12a89 100644 --- a/temporal-test-server/src/test/java/io/temporal/testserver/functional/NexusWorkflowTest.java +++ b/temporal-test-server/src/test/java/io/temporal/testserver/functional/NexusWorkflowTest.java @@ -4,6 +4,8 @@ import com.google.protobuf.ByteString; import com.google.protobuf.util.Durations; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; import io.temporal.api.command.v1.*; import io.temporal.api.common.v1.*; import io.temporal.api.common.v1.Link; @@ -31,6 +33,7 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; import java.util.stream.Collectors; import org.junit.*; @@ -579,7 +582,28 @@ public void testNexusOperationTimeout_BeforeStart() { public void testNexusOperationTimeout_AfterStart() { String operationId = UUID.randomUUID().toString(); CompletableFuture nexusPoller = - pollNexusTask().thenCompose(task -> completeNexusTask(task, operationId)); + pollNexusTask() + .thenCompose(task -> completeNexusTask(task, operationId)) + .exceptionally( + e -> { + // If operation already timed out by the time we send response, the RPC call may + // have succeeded + // or it may have thrown NOT_FOUND status code. Both scenarios are treated as + // success. + Throwable cause = (e instanceof CompletionException) ? e.getCause() : e; + if (cause instanceof StatusRuntimeException) { + if (((StatusRuntimeException) cause).getStatus().getCode() + == Status.Code.NOT_FOUND) { + return null; + } + } + // Every other exception should fail the test. + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new CompletionException(cause); + } + }); try { WorkflowStub stub = newWorkflowStub("TestNexusOperationTimeoutAfterStartWorkflow"); @@ -591,22 +615,30 @@ public void testNexusOperationTimeout_AfterStart() { pollResp.getTaskToken(), newScheduleOperationCommand( defaultScheduleOperationAttributes() - .setScheduleToCloseTimeout(Durations.fromSeconds(2)))); + // needs to be at least 3 seconds due to server bug + .setScheduleToCloseTimeout(Durations.fromSeconds(3)))); testWorkflowRule.assertHistoryEvent( execution.getWorkflowId(), EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); // Wait for operation to be started nexusPoller.get(); - // Poll and verify started event is recorded and triggers workflow progress - pollResp = pollWorkflowTask(); - testWorkflowRule.assertHistoryEvent( - execution.getWorkflowId(), EventType.EVENT_TYPE_NEXUS_OPERATION_STARTED); - completeWorkflowTask(pollResp.getTaskToken()); + // Keep processing workflow tasks until operation times out, then complete the workflow. + wftPolling: + while (true) { + pollResp = pollWorkflowTask(); + for (HistoryEvent event : pollResp.getHistory().getEventsList()) { + if (event.getEventType() == EventType.EVENT_TYPE_NEXUS_OPERATION_TIMED_OUT) { + completeWorkflow(pollResp.getTaskToken()); + break wftPolling; + } + } + completeWorkflowTask(pollResp.getTaskToken()); + } - // Poll to wait for new task after operation times out - pollResp = pollWorkflowTask(); - completeWorkflow(pollResp.getTaskToken()); + // Because Nexus operation may have timed out before it started, + // EVENT_TYPE_NEXUS_OPERATION_STARTED + // may or may not be present in history. Both scenarios are OK, so we don't check for it. List events = testWorkflowRule.getHistoryEvents( @@ -614,7 +646,8 @@ public void testNexusOperationTimeout_AfterStart() { Assert.assertEquals(1, events.size()); io.temporal.api.failure.v1.Failure failure = events.get(0).getNexusOperationTimedOutEventAttributes().getFailure(); - assertOperationFailureInfo(operationId, failure.getNexusOperationExecutionFailureInfo()); + // If operation timed out before starting, then operation ID will be missing in failure info + assertOperationFailureInfoAnyID(failure.getNexusOperationExecutionFailureInfo()); Assert.assertEquals("nexus operation completed unsuccessfully", failure.getMessage()); io.temporal.api.failure.v1.Failure cause = failure.getCause(); Assert.assertEquals("operation timed out", cause.getMessage()); @@ -1540,8 +1573,12 @@ private void assertOperationFailureInfo(NexusOperationFailureInfo info) { } private void assertOperationFailureInfo(String operationID, NexusOperationFailureInfo info) { - Assert.assertNotNull(info); + assertOperationFailureInfoAnyID(info); Assert.assertEquals(operationID, info.getOperationToken()); + } + + private void assertOperationFailureInfoAnyID(NexusOperationFailureInfo info) { + Assert.assertNotNull(info); Assert.assertEquals(testEndpoint.getSpec().getName(), info.getEndpoint()); Assert.assertEquals(testService, info.getService()); Assert.assertEquals(testOperation, info.getOperation()); From 5f25aad6b0dc35a8ca002b86ac77bb497409cb30 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 11 Jun 2026 07:16:05 -0700 Subject: [PATCH 007/107] Standalone operations for Nexus (#2872) --- .github/workflows/ci.yml | 1 + .../java/io/temporal/client/NexusClient.java | 153 ++++++ .../io/temporal/client/NexusClientImpl.java | 140 ++++++ .../temporal/client/NexusClientOptions.java | 161 +++++++ ...NexusOperationAlreadyStartedException.java | 36 ++ .../NexusOperationCancellationInfo.java | 95 ++++ .../client/NexusOperationException.java | 31 ++ .../client/NexusOperationExecutionCount.java | 94 ++++ .../NexusOperationExecutionDescription.java | 280 +++++++++++ .../NexusOperationExecutionMetadata.java | 217 +++++++++ .../client/NexusOperationFailedException.java | 17 + .../temporal/client/NexusOperationHandle.java | 83 ++++ .../client/NexusOperationHandleImpl.java | 124 +++++ .../NexusOperationNotFoundException.java | 31 ++ .../temporal/client/NexusServiceClient.java | 130 ++++++ .../client/NexusServiceClientImpl.java | 109 +++++ .../client/StartNexusOperationOptions.java | 235 ++++++++++ .../client/UntypedNexusOperationHandle.java | 149 ++++++ .../client/UntypedNexusServiceClient.java | 64 +++ .../client/UntypedNexusServiceClientImpl.java | 85 ++++ .../NexusClientCallsInterceptor.java | 440 ++++++++++++++++++ .../NexusClientCallsInterceptorBase.java | 73 +++ .../interceptors/NexusClientInterceptor.java | 24 + .../NexusClientInterceptorBase.java | 13 + .../ListNexusOperationExecutionIterator.java | 55 +++ .../client/NexusOperationHandleImpl.java | 139 ++++++ .../client/RootNexusClientInvoker.java | 383 +++++++++++++++ .../external/GenericWorkflowClient.java | 27 ++ .../external/GenericWorkflowClientImpl.java | 116 +++++ .../client/NexusClientOptionsTest.java | 46 ++ .../StartNexusOperationOptionsTest.java | 66 +++ .../client/nexus/NexusAsyncApiTest.java | 232 +++++++++ .../NexusClientInterceptorChainTest.java | 115 +++++ .../client/nexus/NexusClientTest.java | 250 ++++++++++ .../nexus/NexusOperationHandleTest.java | 356 ++++++++++++++ .../client/nexus/NexusServiceClientTest.java | 259 +++++++++++ .../StandaloneNexusClientCancelTest.java | 141 ++++++ .../client/RootNexusClientInvokerTest.java | 89 ++++ .../workflow/shared/EchoNexusServiceImpl.java | 74 +++ .../TestWorkflowMutableStateImpl.java | 3 +- .../testing/internal/SDKTestWorkflowRule.java | 14 + 41 files changed, 5148 insertions(+), 2 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/ListNexusOperationExecutionIterator.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03e0478cf1..a5083cbf28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,7 @@ jobs: --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ --dynamic-config-value frontend.activityAPIsEnabled=true \ --dynamic-config-value activity.enableStandalone=true \ + --dynamic-config-value nexusoperation.enableStandalone=true \ --dynamic-config-value history.enableChasm=true \ --dynamic-config-value history.enableTransitionHistory=true & sleep 10s 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 0000000000..2324a6592e --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClient.java @@ -0,0 +1,153 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.lang.reflect.Type; +import java.util.stream.Stream; +import javax.annotation.Nullable; + +/** + * Client for managing standalone Nexus operation executions. Obtain an instance via {@link + * #newInstance(WorkflowServiceStubs)} or {@link #newInstance(WorkflowServiceStubs, + * NexusClientOptions)}. Do not create this object per request; share it for the lifetime of the + * process. + * + *

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

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

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

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

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

For visibility queries across all operations in the namespace, see {@link + * #listNexusOperationExecutions} and {@link #countNexusOperationExecutions}. + * + * @see NexusServiceClient + * @see UntypedNexusServiceClient + * @see NexusOperationHandle + */ +@Experimental +public interface NexusClient { + + /** + * Creates a client with default {@link NexusClientOptions}. + * + * @param service gRPC stubs connected to a Temporal Service endpoint + */ + static NexusClient newInstance(WorkflowServiceStubs service) { + return NexusClientImpl.newInstance(service, NexusClientOptions.getDefaultInstance()); + } + + /** + * Creates a client with the supplied options. + * + * @param service gRPC stubs connected to a Temporal Service endpoint + * @param options namespace, data converter, interceptors, and defaults applied to operations + * started through this client + */ + static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) { + return NexusClientImpl.newInstance(service, options); + } + + /** Returns the underlying gRPC stubs this client routes RPCs through. */ + WorkflowServiceStubs getWorkflowServiceStubs(); + + /** + * Returns an untyped handle to an existing operation execution, optionally pinned to a specific + * run. + * + * @param operationId the user-assigned operation ID + * @param runId the server-assigned run ID, or {@code null} to target the latest run + * @return an untyped handle + */ + UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId); + + /** + * Returns a typed handle to an existing operation execution, bound to {@code resultClass}. + * + * @param operationId the user-assigned operation ID + * @param runId the server-assigned run ID, or {@code null} to target the latest run + * @param resultClass expected result type + * @param result type + */ + NexusOperationHandle getHandle( + String operationId, @Nullable String runId, Class resultClass); + + /** + * Returns a typed handle to an existing operation execution, bound to {@code resultClass}/{@code + * resultType}. Use the {@code resultType} variant when the result is a generic type whose + * parameters cannot be captured by {@link Class} alone (e.g. {@code List}). + * + * @param operationId the user-assigned operation ID + * @param runId the server-assigned run ID, or {@code null} to target the latest run + * @param resultClass expected result class + * @param resultType generic type for deserialization; may be {@code null} + * @param result type + */ + NexusOperationHandle getHandle( + String operationId, @Nullable String runId, Class resultClass, @Nullable Type resultType); + + /** + * Builds a typed service-bound client targeting the given endpoint, dispatching operations by + * method reference on the {@code @ServiceInterface}-annotated {@code service}. Reuses this + * client's stubs, options, and interceptor chain. + * + * @param service the {@code @ServiceInterface}-annotated service type + * @param endpoint Nexus endpoint name registered on the Temporal Service + * @param the service interface type + */ + NexusServiceClient newNexusServiceClient(Class service, String endpoint); + + /** + * Builds an untyped service-bound client targeting the given endpoint and service. Use this to + * dispatch operations by name string when no service interface is available. + * + * @param endpoint Nexus endpoint name registered on the Temporal Service + * @param serviceName Nexus service name on that endpoint + */ + UntypedNexusServiceClient newUntypedNexusServiceClient(String endpoint, String serviceName); + + /** + * Returns a stream of standalone Nexus operation executions matching the given visibility query. + * The stream paginates lazily over server-side results — pages are fetched on demand as the + * stream is consumed. + * + * @param query Temporal visibility query string, or {@code null} to return all executions in the + * client namespace + * @return a lazy stream of matching executions + */ + Stream listNexusOperationExecutions(@Nullable String query); + + /** + * Returns the count of standalone Nexus operation executions matching the given visibility query, + * optionally with aggregation groups. + * + * @param query Temporal visibility query string, or {@code null} to count all executions in the + * client namespace + * @return execution count, optionally with aggregation groups when the query uses {@code GROUP + * BY} + */ + NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java new file mode 100644 index 0000000000..d19c9f13be --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -0,0 +1,140 @@ +package io.temporal.client; + +import static io.temporal.internal.WorkflowThreadMarker.enforceNonWorkflowThread; + +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.NexusOperationHandleImpl; +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.List; +import java.util.stream.Stream; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Experimental +public class NexusClientImpl implements NexusClient { + + private static final Logger log = LoggerFactory.getLogger(NexusClientImpl.class); + + private final WorkflowServiceStubs workflowServiceStubs; + private final NexusClientOptions options; + private final GenericWorkflowClient genericClient; + private final Scope metricsScope; + private final NexusClientCallsInterceptor nexusClientCallsInvoker; + private final List interceptors; + + public static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) { + enforceNonWorkflowThread(); + return WorkflowThreadMarker.protectFromWorkflowThread( + new NexusClientImpl(service, options), NexusClient.class); + } + + NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientOptions options) { + workflowServiceStubs = + new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace()); + this.workflowServiceStubs = workflowServiceStubs; + this.options = options; + this.metricsScope = + workflowServiceStubs + .getOptions() + .getMetricsScope() + .tagged(MetricsTag.defaultTags(options.getNamespace())); + this.genericClient = new GenericWorkflowClientImpl(workflowServiceStubs, metricsScope); + this.interceptors = options.getInterceptors(); + this.nexusClientCallsInvoker = initializeClientInvoker(); + if (log.isDebugEnabled()) { + log.debug( + "NexusClient initialized: namespace={}, interceptors={}", + options.getNamespace(), + interceptors.size()); + } + } + + private NexusClientCallsInterceptor initializeClientInvoker() { + NexusClientCallsInterceptor invoker = new RootNexusClientInvoker(genericClient, options); + for (NexusClientInterceptor clientInterceptor : interceptors) { + NexusClientCallsInterceptor wrapped = clientInterceptor.nexusClientCallsInterceptor(invoker); + if (wrapped == null) { + throw new IllegalStateException( + "NexusClientInterceptor " + + clientInterceptor.getClass().getName() + + " returned null from nexusClientCallsInterceptor; expected a non-null" + + " NexusClientCallsInterceptor wrapping the supplied next link"); + } + invoker = wrapped; + } + return invoker; + } + + @Override + public WorkflowServiceStubs getWorkflowServiceStubs() { + return workflowServiceStubs; + } + + @Override + public UntypedNexusOperationHandle getHandle(String operationId, @Nullable String runId) { + return new NexusOperationHandleImpl(operationId, runId, nexusClientCallsInvoker); + } + + @Override + public NexusOperationHandle getHandle( + String operationId, @Nullable String runId, Class resultClass) { + return getHandle(operationId, runId, resultClass, null); + } + + @Override + public NexusOperationHandle getHandle( + String operationId, + @Nullable String runId, + Class resultClass, + @Nullable java.lang.reflect.Type resultType) { + return NexusOperationHandle.fromUntyped(getHandle(operationId, runId), resultClass, resultType); + } + + @Override + public NexusServiceClient newNexusServiceClient(Class service, String endpoint) { + enforceNonWorkflowThread(); + return WorkflowThreadMarker.protectFromWorkflowThread( + new NexusServiceClientImpl<>(nexusClientCallsInvoker, service, endpoint, options), + NexusServiceClient.class); + } + + @Override + public UntypedNexusServiceClient newUntypedNexusServiceClient( + String endpoint, String serviceName) { + enforceNonWorkflowThread(); + return WorkflowThreadMarker.protectFromWorkflowThread( + new UntypedNexusServiceClientImpl(nexusClientCallsInvoker, endpoint, serviceName, options), + UntypedNexusServiceClient.class); + } + + @Override + public Stream listNexusOperationExecutions( + @Nullable String query) { + ListNexusOperationExecutionsOutput out = + nexusClientCallsInvoker.listNexusOperationExecutions( + new ListNexusOperationExecutionsInput(query)); + return out.getOperations(); + } + + @Override + public NexusOperationExecutionCount countNexusOperationExecutions(@Nullable String query) { + CountNexusOperationExecutionsOutput out = + nexusClientCallsInvoker.countNexusOperationExecutions( + new CountNexusOperationExecutionsInput(query)); + return out.getCount(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java new file mode 100644 index 0000000000..9c64fe7ac6 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java @@ -0,0 +1,161 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.common.interceptors.NexusClientInterceptor; +import java.lang.management.ManagementFactory; +import java.util.Collections; +import java.util.List; + +/** + * Options that configure a {@link NexusClient} (and the service-bound clients it produces). + * + *

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

Obtain a builder via {@link #newBuilder()} or copy an existing instance via {@link + * #newBuilder(NexusClientOptions)}. The default instance ({@link #getDefaultInstance()}) targets + * the {@code "default"} namespace and uses the {@link GlobalDataConverter}. + * + *

{@code
+ * NexusClientOptions options =
+ *     NexusClientOptions.newBuilder()
+ *         .setNamespace("default")
+ *         .setDataConverter(myDataConverter)
+ *         .build();
+ * }
+ */ +@Experimental +public class NexusClientOptions { + + private static final String DEFAULT_NAMESPACE = "default"; + + private final String namespace; + private final List interceptors; + private final DataConverter dataConverter; + private final String identity; + + private NexusClientOptions( + String namespace, + List interceptors, + DataConverter dataConverter, + String identity) { + this.namespace = namespace; + this.interceptors = interceptors; + this.dataConverter = dataConverter; + this.identity = identity; + } + + /** Get the namespace this client will operate on. */ + public String getNamespace() { + return namespace; + } + + /** Get the interceptors of this client. */ + public List getInterceptors() { + return interceptors; + } + + /** Get the data converter used to serialize Nexus operation inputs and deserialize results. */ + public DataConverter getDataConverter() { + return dataConverter; + } + + /** + * Human-readable identity of this client. Stamped onto outgoing write requests (start, cancel, + * terminate) so server-side history and audit trails can attribute the action to a caller. + */ + public String getIdentity() { + return identity; + } + + /** Returns a fresh builder. */ + public static NexusClientOptions.Builder newBuilder() { + return new NexusClientOptions.Builder(); + } + + /** Returns a builder seeded with the values from {@code options}. */ + public static NexusClientOptions.Builder newBuilder(NexusClientOptions options) { + return new NexusClientOptions.Builder(options); + } + + private static final NexusClientOptions DEFAULT_INSTANCE; + + /** + * Returns an options instance with all defaults. The namespace defaults to {@code "default"}; set + * it explicitly via {@link Builder#setNamespace(String)} to target a different namespace. + */ + public static NexusClientOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + static { + DEFAULT_INSTANCE = NexusClientOptions.newBuilder().build(); + } + + /** Builder for {@link NexusClientOptions}. */ + public static class Builder { + private String namespace; + private List interceptors = Collections.emptyList(); + private DataConverter dataConverter = GlobalDataConverter.get(); + private String identity; + + private Builder() {} + + private Builder(NexusClientOptions options) { + if (options == null) { + return; + } + namespace = options.namespace; + interceptors = options.interceptors; + dataConverter = options.dataConverter; + identity = options.identity; + } + + /** Set the namespace this client will operate on. */ + public NexusClientOptions.Builder setNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** Set the interceptors for this client, but don't allow null lists to happen. */ + public NexusClientOptions.Builder setInterceptors(List interceptors) { + if (interceptors == null) { + this.interceptors = Collections.emptyList(); + } else { + this.interceptors = interceptors; + } + return this; + } + + /** + * Set the data converter used to serialize Nexus operation inputs and deserialize results. + * Defaults to {@link GlobalDataConverter#get()}. + */ + public NexusClientOptions.Builder setDataConverter(DataConverter dataConverter) { + this.dataConverter = dataConverter; + return this; + } + + /** + * Override the human-readable identity stamped on outgoing write requests. Defaults to the JVM + * runtime name (typically {@code pid@host}). + */ + public NexusClientOptions.Builder setIdentity(String identity) { + this.identity = identity; + return this; + } + + public NexusClientOptions build() { + String resolvedIdentity = + identity == null ? ManagementFactory.getRuntimeMXBean().getName() : identity; + return new NexusClientOptions( + namespace == null ? DEFAULT_NAMESPACE : namespace, + interceptors, + dataConverter, + resolvedIdentity); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java new file mode 100644 index 0000000000..42c8155139 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationAlreadyStartedException.java @@ -0,0 +1,36 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Thrown by {@link NexusClient} / {@link NexusServiceClient} when the server returns an + * ALREADY_EXISTS error because a Nexus operation with the same ID is already running (or has a + * completed run that conflicts with the requested {@link + * StartNexusOperationOptions#getIdReusePolicy()} / {@link + * StartNexusOperationOptions#getIdConflictPolicy()}). + */ +@Experimental +public final class NexusOperationAlreadyStartedException extends NexusOperationException { + + private final String operation; + + public NexusOperationAlreadyStartedException( + String operationId, String operation, @Nullable String runId, Throwable cause) { + super( + "Nexus operation already started: operationId='" + + operationId + + "', operation='" + + operation + + (runId != null ? "', runId='" + runId + "'" : "'"), + operationId, + runId, + cause); + this.operation = operation; + } + + /** The Nexus operation name that was requested. */ + public String getOperation() { + return operation; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java new file mode 100644 index 0000000000..2a89426666 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationCancellationInfo.java @@ -0,0 +1,95 @@ +package io.temporal.client; + +import com.google.common.base.Strings; +import io.temporal.api.enums.v1.NexusOperationCancellationState; +import io.temporal.api.nexus.v1.NexusOperationExecutionCancellationInfo; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.internal.common.ProtobufTimeUtils; +import java.time.Instant; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Information about a cancellation request issued against a standalone Nexus operation execution. + * Returned by {@link NexusOperationExecutionDescription#getCancellationInfo()}. + */ +@Experimental +public final class NexusOperationCancellationInfo { + + private final NexusOperationExecutionCancellationInfo info; + private final DataConverter dataConverter; + + NexusOperationCancellationInfo( + NexusOperationExecutionCancellationInfo info, DataConverter dataConverter) { + this.info = info; + this.dataConverter = dataConverter; + } + + /** The raw protobuf info returned by the server. */ + @Nonnull + public NexusOperationExecutionCancellationInfo getRawInfo() { + return info; + } + + /** Time when cancellation was originally requested. */ + @Nullable + public Instant getRequestedTime() { + return info.hasRequestedTime() + ? ProtobufTimeUtils.toJavaInstant(info.getRequestedTime()) + : null; + } + + /** Current state of cancellation-request delivery to the operation handler. */ + @Nonnull + public NexusOperationCancellationState getState() { + return info.getState(); + } + + /** + * Current attempt number for delivering the cancel request to the handler. Represents a minimum + * bound — the value is incremented after the attempt completes. + */ + public int getAttempt() { + return info.getAttempt(); + } + + /** Time the last cancel-delivery attempt completed. */ + @Nullable + public Instant getLastAttemptCompleteTime() { + return info.hasLastAttemptCompleteTime() + ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) + : null; + } + + /** Failure from the last cancel-delivery attempt. {@code null} if no failure has occurred yet. */ + @Nullable + public Exception getLastAttemptFailure() { + return info.hasLastAttemptFailure() + ? dataConverter.failureToException(info.getLastAttemptFailure()) + : null; + } + + /** Time when the next cancel-delivery attempt is scheduled. */ + @Nullable + public Instant getNextAttemptScheduleTime() { + return info.hasNextAttemptScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) + : null; + } + + /** + * Additional context for why cancel delivery is blocked. Set only when {@link #getState()} + * indicates a blocked state. + */ + @Nullable + public String getBlockedReason() { + return Strings.emptyToNull(info.getBlockedReason()); + } + + /** The human-readable reason supplied with the original cancel request, if any. */ + @Nullable + public String getReason() { + return Strings.emptyToNull(info.getReason()); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java new file mode 100644 index 0000000000..0527aeb926 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationException.java @@ -0,0 +1,31 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.failure.TemporalException; +import javax.annotation.Nullable; + +/** Base exception for standalone Nexus operation execution failures. */ +@Experimental +public abstract class NexusOperationException extends TemporalException { + + private final String operationId; + private final @Nullable String runId; + + protected NexusOperationException( + String message, String operationId, @Nullable String runId, @Nullable Throwable cause) { + super(message, cause); + this.operationId = operationId; + this.runId = runId; + } + + /** The ID of the Nexus operation execution that caused this exception. */ + public String getOperationId() { + return operationId; + } + + /** The run ID of the Nexus operation execution, or {@code null} if not available. */ + @Nullable + public String getRunId() { + return runId; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java new file mode 100644 index 0000000000..271671cf57 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionCount.java @@ -0,0 +1,94 @@ +package io.temporal.client; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import io.temporal.internal.common.SearchAttributesUtil; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +/** Result of counting standalone Nexus operation executions. */ +@Experimental +public class NexusOperationExecutionCount { + + /** An individual aggregation group. */ + @Experimental + public static class AggregationGroup { + private final List> groupValues; + private final long count; + + /** Construct from raw payload group values; values are decoded eagerly. */ + public AggregationGroup(long count, List groupValues) { + this.groupValues = + groupValues.stream().map(SearchAttributesUtil::decode).collect(Collectors.toList()); + this.count = count; + } + + /** Values of the group, decoded from search attribute payloads. */ + public List> getGroupValues() { + return groupValues; + } + + /** Count of operations in this group. */ + public long getCount() { + return count; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + AggregationGroup that = (AggregationGroup) o; + return count == that.count && Objects.equals(groupValues, that.groupValues); + } + + @Override + public int hashCode() { + return Objects.hash(groupValues, count); + } + + @Override + public String toString() { + return "AggregationGroup{groupValues=" + groupValues + ", count=" + count + '}'; + } + } + + private final long count; + private final List groups; + + public NexusOperationExecutionCount(long count, List groups) { + this.count = count; + this.groups = Collections.unmodifiableList(groups); + } + + /** Total number of operation executions matching the query. */ + public long getCount() { + return count; + } + + /** Aggregation groups returned by the service. Empty if no grouping was requested. */ + @Nonnull + public List getGroups() { + return groups; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NexusOperationExecutionCount that = (NexusOperationExecutionCount) o; + return count == that.count && Objects.equals(groups, that.groups); + } + + @Override + public int hashCode() { + return Objects.hash(count, groups); + } + + @Override + public String toString() { + return "NexusOperationExecutionCount{count=" + count + ", groups=" + groups + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java new file mode 100644 index 0000000000..50fd5837ba --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java @@ -0,0 +1,280 @@ +package io.temporal.client; + +import com.google.common.base.Strings; +import io.temporal.api.enums.v1.PendingNexusOperationState; +import io.temporal.api.nexus.v1.NexusOperationExecutionInfo; +import io.temporal.api.workflowservice.v1.DescribeNexusOperationExecutionResponse; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.SearchAttributesUtil; +import java.lang.reflect.Type; +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Detailed information about a standalone Nexus operation execution, returned by {@link + * UntypedNexusOperationHandle#describe()}. + */ +@Experimental +public final class NexusOperationExecutionDescription extends NexusOperationExecutionMetadata { + + private final DescribeNexusOperationExecutionResponse response; + private final NexusOperationExecutionInfo info; + private final DataConverter dataConverter; + + public NexusOperationExecutionDescription( + DescribeNexusOperationExecutionResponse response, + DataConverter dataConverter, + String namespace) { + super( + null, + response.getInfo().getOperationId(), + Strings.emptyToNull(response.getInfo().getRunId()), + Strings.emptyToNull(response.getInfo().getEndpoint()), + Strings.emptyToNull(response.getInfo().getService()), + Strings.emptyToNull(response.getInfo().getOperation()), + response.getInfo().hasScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime()) + : null, + response.getInfo().hasCloseTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime()) + : null, + response.getInfo().getStatus(), + SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes()), + response.getInfo().getStateTransitionCount(), + response.getInfo().hasExecutionDuration() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getExecutionDuration()) + : null); + this.response = response; + this.info = response.getInfo(); + this.dataConverter = dataConverter; + } + + /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ + @Nonnull + public DescribeNexusOperationExecutionResponse getRawResponse() { + return response; + } + + /** The raw protobuf info returned by the server for this operation execution. */ + @Nonnull + public NexusOperationExecutionInfo getRawInfo() { + return info; + } + + /** Current attempt number for the start request (starts at 1). */ + public int getAttempt() { + return info.getAttempt(); + } + + /** + * Detailed run state (e.g. scheduled, started, backing off). Only meaningful when {@link + * #getStatus()} is {@code NEXUS_OPERATION_EXECUTION_STATUS_RUNNING}. + */ + @Nonnull + public PendingNexusOperationState getRunState() { + return info.getState(); + } + + /** Total time the caller is willing to wait for the operation to complete, including retries. */ + @Nullable + public Duration getScheduleToCloseTimeout() { + return info.hasScheduleToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToCloseTimeout()) + : null; + } + + /** Maximum time the start request may wait before being delivered to the handler. */ + @Nullable + public Duration getScheduleToStartTimeout() { + return info.hasScheduleToStartTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToStartTimeout()) + : null; + } + + /** Maximum time for a single start-request attempt. */ + @Nullable + public Duration getStartToCloseTimeout() { + return info.hasStartToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(info.getStartToCloseTimeout()) + : null; + } + + /** Scheduled time plus schedule-to-close timeout. */ + @Nullable + public Instant getExpirationTime() { + return info.hasExpirationTime() + ? ProtobufTimeUtils.toJavaInstant(info.getExpirationTime()) + : null; + } + + /** Time the last start-request attempt completed (succeeded or failed). */ + @Nullable + public Instant getLastAttemptCompleteTime() { + return info.hasLastAttemptCompleteTime() + ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) + : null; + } + + /** Failure from the last start-request attempt. {@code null} if no failure has occurred. */ + @Nullable + public Exception getLastAttemptFailure() { + return info.hasLastAttemptFailure() + ? dataConverter.failureToException(info.getLastAttemptFailure()) + : null; + } + + /** Time when the next start-request attempt will be scheduled. */ + @Nullable + public Instant getNextAttemptScheduleTime() { + return info.hasNextAttemptScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) + : null; + } + + /** Cancellation details if cancellation was requested; {@code null} otherwise. */ + @Nullable + public NexusOperationCancellationInfo getCancellationInfo() { + return info.hasCancellationInfo() + ? new NexusOperationCancellationInfo(info.getCancellationInfo(), dataConverter) + : null; + } + + /** + * Additional context for why the operation is blocked. Set only when {@link #getRunState()} is + * {@code BLOCKED}. + */ + @Nullable + public String getBlockedReason() { + return Strings.emptyToNull(info.getBlockedReason()); + } + + /** + * Server-generated request ID used as an idempotency token when submitting the start request to + * the operation handler. + */ + @Nullable + public String getHandlerRequestId() { + return Strings.emptyToNull(info.getRequestId()); + } + + /** Operation token returned by the handler; set only for asynchronous operations after start. */ + @Nullable + public String getOperationToken() { + return Strings.emptyToNull(info.getOperationToken()); + } + + /** Identity of the client that started this operation. */ + @Nullable + public String getIdentity() { + return Strings.emptyToNull(info.getIdentity()); + } + + /** + * Fixed summary attached when the operation was started, decoded from {@code UserMetadata}. + * Decoded on each call; cache the result if called frequently. + */ + @Nullable + public String getStaticSummary() { + if (!info.hasUserMetadata() || !info.getUserMetadata().hasSummary()) { + return null; + } + return dataConverter.fromPayload( + info.getUserMetadata().getSummary(), String.class, String.class); + } + + /** + * Fixed details attached when the operation was started, decoded from {@code UserMetadata}. + * Decoded on each call; cache the result if called frequently. + */ + @Nullable + public String getStaticDetails() { + if (!info.hasUserMetadata() || !info.getUserMetadata().hasDetails()) { + return null; + } + return dataConverter.fromPayload( + info.getUserMetadata().getDetails(), String.class, String.class); + } + + /** + * Whether the operation input payload is present on this description. Set only when {@link + * UntypedNexusOperationHandle#describe()} was called with {@code includeInput=true}. + */ + public boolean hasInput() { + return response.hasInput(); + } + + /** + * Deserializes the operation input into the given type. Returns {@link Optional#empty()} if no + * input is present (either the operation was started without one or {@code includeInput} was + * false on the describe call). + * + * @param valueType the class to deserialize the input into + */ + public Optional getInput(Class valueType) { + return getInput(valueType, valueType); + } + + /** + * Deserializes the operation input into the given generic type. Returns {@link Optional#empty()} + * if no input is present. + * + * @param valueType the class to deserialize the input into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getInput(Class valueType, Type genericType) { + if (!response.hasInput()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayload(response.getInput(), valueType, genericType)); + } + + /** + * Whether the operation's success result is present. Set only when {@link + * UntypedNexusOperationHandle#describe()} was called with {@code includeOutcome=true} and the + * operation completed successfully. + */ + public boolean hasResult() { + return response.hasResult(); + } + + /** + * Deserializes the operation's success result. Returns {@link Optional#empty()} if no result is + * present (operation still running, completed with a failure, or {@code includeOutcome} was + * false). + * + * @param valueType the class to deserialize the result into + */ + public Optional getResult(Class valueType) { + return getResult(valueType, valueType); + } + + /** + * Deserializes the operation's success result into the given generic type. Returns {@link + * Optional#empty()} if no result is present. + * + * @param valueType the class to deserialize the result into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getResult(Class valueType, Type genericType) { + if (!response.hasResult()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayload(response.getResult(), valueType, genericType)); + } + + /** + * Operation failure as a thrown-style exception. Returns {@code null} if the operation did not + * complete with a failure or if {@code includeOutcome} was false on the describe call. + */ + @Nullable + public Exception getFailure() { + return response.hasFailure() ? dataConverter.failureToException(response.getFailure()) : null; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java new file mode 100644 index 0000000000..f5e68792f6 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionMetadata.java @@ -0,0 +1,217 @@ +package io.temporal.client; + +import com.google.common.base.Strings; +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 @Nullable String endpoint; + private final @Nullable String service; + private final @Nullable String operation; + private final @Nullable Instant scheduledTime; + private final @Nullable Instant closeTime; + private final NexusOperationExecutionStatus status; + private final SearchAttributes searchAttributes; + private final long stateTransitionCount; + private final @Nullable Duration executionDuration; + + NexusOperationExecutionMetadata( + @Nullable NexusOperationExecutionListInfo rawListInfo, + String operationId, + @Nullable String runId, + @Nullable String endpoint, + @Nullable String service, + @Nullable String operation, + @Nullable 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) { + return new NexusOperationExecutionMetadata( + info, + info.getOperationId(), + Strings.emptyToNull(info.getRunId()), + Strings.emptyToNull(info.getEndpoint()), + Strings.emptyToNull(info.getService()), + Strings.emptyToNull(info.getOperation()), + info.hasScheduleTime() ? ProtobufTimeUtils.toJavaInstant(info.getScheduleTime()) : null, + info.hasCloseTime() ? ProtobufTimeUtils.toJavaInstant(info.getCloseTime()) : null, + info.getStatus(), + SearchAttributesUtil.decodeTyped(info.getSearchAttributes()), + info.getStateTransitionCount(), + info.hasExecutionDuration() + ? ProtobufTimeUtils.toJavaDuration(info.getExecutionDuration()) + : null); + } + + /** + * The raw protobuf list info from the server. Only present when this instance was created via + * {@link #fromListInfo}. + */ + @Nullable + public NexusOperationExecutionListInfo getRawListInfo() { + return rawListInfo; + } + + /** The user-assigned identifier for this operation. */ + @Nonnull + public String getOperationId() { + return operationId; + } + + /** The server-assigned run ID for this operation execution. May be {@code null}. */ + @Nullable + public String getRunId() { + return runId; + } + + /** The Nexus endpoint name this operation targets. {@code null} if the server omitted it. */ + @Nullable + public String getEndpoint() { + return endpoint; + } + + /** The Nexus service name on the endpoint. {@code null} if the server omitted it. */ + @Nullable + public String getService() { + return service; + } + + /** The Nexus operation name within the service. {@code null} if the server omitted it. */ + @Nullable + public String getOperation() { + return operation; + } + + /** + * Time when the operation was originally scheduled via a {@code StartNexusOperation} request. + * {@code null} if the server omitted it. + */ + @Nullable + public Instant getScheduledTime() { + return scheduledTime; + } + + /** Time the operation transitioned to a terminal status. {@code null} while still running. */ + @Nullable + public Instant getCloseTime() { + return closeTime; + } + + /** General status of the operation execution. */ + @Nonnull + public NexusOperationExecutionStatus getStatus() { + return status; + } + + /** Search attributes attached to this operation execution. */ + @Nonnull + public SearchAttributes getSearchAttributes() { + return searchAttributes; + } + + /** Server-tracked count of state transitions; updated on terminal status. */ + public long getStateTransitionCount() { + return stateTransitionCount; + } + + /** Close time minus scheduled time. {@code null} while still running. */ + @Nullable + public Duration getExecutionDuration() { + return executionDuration; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NexusOperationExecutionMetadata that = (NexusOperationExecutionMetadata) o; + return stateTransitionCount == that.stateTransitionCount + && Objects.equals(operationId, that.operationId) + && Objects.equals(runId, that.runId) + && Objects.equals(endpoint, that.endpoint) + && Objects.equals(service, that.service) + && Objects.equals(operation, that.operation) + && Objects.equals(scheduledTime, that.scheduledTime) + && Objects.equals(closeTime, that.closeTime) + && status == that.status + && Objects.equals(searchAttributes, that.searchAttributes) + && Objects.equals(executionDuration, that.executionDuration); + } + + @Override + public int hashCode() { + return Objects.hash( + operationId, + runId, + endpoint, + service, + operation, + scheduledTime, + closeTime, + status, + searchAttributes, + stateTransitionCount, + executionDuration); + } + + @Override + public String toString() { + return "NexusOperationExecutionMetadata{" + + "operationId='" + + operationId + + "', runId='" + + runId + + "', endpoint='" + + endpoint + + "', service='" + + service + + "', operation='" + + operation + + "', status=" + + status + + ", scheduledTime=" + + scheduledTime + + ", closeTime=" + + closeTime + + ", executionDuration=" + + executionDuration + + ", searchAttributes=" + + searchAttributes + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java new file mode 100644 index 0000000000..446f0a5c22 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationFailedException.java @@ -0,0 +1,17 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Thrown by {@link UntypedNexusOperationHandle#getResult} when the standalone Nexus operation was + * not successful. The original cause can be retrieved via {@link #getCause()}. + */ +@Experimental +public final class NexusOperationFailedException extends NexusOperationException { + + public NexusOperationFailedException( + String message, String operationId, @Nullable String runId, @Nullable Throwable cause) { + super(message, operationId, runId, cause); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java new file mode 100644 index 0000000000..232740f418 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandle.java @@ -0,0 +1,83 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +/** + * A typed handle to a standalone Nexus operation execution. Extends {@link + * UntypedNexusOperationHandle} with typed result methods bound to a known result type. + * + *

Obtain an instance via {@link NexusServiceClient} or by wrapping an {@link + * UntypedNexusOperationHandle} (returned by {@link NexusClient#getHandle(String, String)}) with + * {@link #fromUntyped(UntypedNexusOperationHandle, Class)}. + * + * @param the result type of the Nexus operation + * @see UntypedNexusOperationHandle + * @see NexusServiceClient + * @see NexusClient + */ +@Experimental +public interface NexusOperationHandle extends UntypedNexusOperationHandle { + + /** + * Wraps an {@link UntypedNexusOperationHandle} with a known result type. + * + * @param handle the untyped handle to wrap + * @param resultClass the class to deserialize the result into + * @return a typed handle + */ + static NexusOperationHandle fromUntyped( + UntypedNexusOperationHandle handle, Class resultClass) { + return fromUntyped(handle, resultClass, null); + } + + /** + * Wraps an {@link UntypedNexusOperationHandle} with a known result type for generic types. Pass a + * non-null {@code resultType} when the result is a generic type whose parameters cannot be + * captured by {@link Class} alone (e.g. {@code List}). + * + * @param handle the untyped handle to wrap + * @param resultClass the class to deserialize the result into + * @param resultType the generic type; may be {@code null} + * @return a typed handle + */ + static NexusOperationHandle fromUntyped( + UntypedNexusOperationHandle handle, Class resultClass, @Nullable Type resultType) { + return new NexusOperationHandleImpl<>(handle, resultClass, resultType); + } + + /** + * Blocks until the Nexus operation completes and returns the typed result. + * + * @throws NexusOperationException if the operation failed, timed out, or was cancelled + */ + R getResult(); + + /** + * Blocks until the Nexus operation completes and returns the typed result, or throws if the + * client-side timeout expires first. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @throws NexusOperationException if the operation failed, timed out on the server, or was + * cancelled + * @throws TimeoutException if {@code timeout} expires before the operation completes + */ + R getResult(long timeout, TimeUnit unit) throws TimeoutException; + + /** Returns a future that completes when the Nexus operation completes with the typed result. */ + CompletableFuture getResultAsync(); + + /** + * Returns a future that completes with the typed result, or completes exceptionally with a {@link + * TimeoutException} if {@code timeout} elapses before the operation completes. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + */ + CompletableFuture getResultAsync(long timeout, TimeUnit unit); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java new file mode 100644 index 0000000000..4c886fd18c --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationHandleImpl.java @@ -0,0 +1,124 @@ +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; + +/** + * Package-private wrapper that adds typed result methods to an {@link UntypedNexusOperationHandle}, + * implementing {@link NexusOperationHandle}{@code }. Created via {@link + * NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class)} or {@link + * NexusOperationHandle#fromUntyped(UntypedNexusOperationHandle, Class, Type)}. + */ +final class NexusOperationHandleImpl implements NexusOperationHandle { + + private final UntypedNexusOperationHandle delegate; + private final Class resultClass; + private final @Nullable Type resultType; + + NexusOperationHandleImpl( + UntypedNexusOperationHandle delegate, Class resultClass, @Nullable Type resultType) { + this.delegate = delegate; + this.resultClass = resultClass; + this.resultType = resultType; + } + + @Override + public R getResult() { + return delegate.getResult(resultClass, resultType); + } + + @Override + public R getResult(long timeout, TimeUnit unit) throws TimeoutException { + return delegate.getResult(timeout, unit, resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync() { + return delegate.getResultAsync(resultClass, resultType); + } + + @Override + public CompletableFuture getResultAsync(long timeout, TimeUnit unit) { + return delegate.getResultAsync(timeout, unit, resultClass, resultType); + } + + @Override + public String getNexusOperationId() { + return delegate.getNexusOperationId(); + } + + @Override + public @Nullable String getNexusOperationRunId() { + return delegate.getNexusOperationRunId(); + } + + @Override + public T getResult(Class clazz) { + return delegate.getResult(clazz); + } + + @Override + public T getResult(Class clazz, @Nullable Type type) { + return delegate.getResult(clazz, type); + } + + @Override + public T getResult(long timeout, TimeUnit unit, Class clazz) throws TimeoutException { + return delegate.getResult(timeout, unit, clazz, null); + } + + @Override + public T getResult(long timeout, TimeUnit unit, Class clazz, @Nullable Type type) + throws TimeoutException { + return delegate.getResult(timeout, unit, clazz, type); + } + + @Override + public CompletableFuture getResultAsync(Class clazz) { + return delegate.getResultAsync(clazz); + } + + @Override + public CompletableFuture getResultAsync(Class clazz, @Nullable Type type) { + return delegate.getResultAsync(clazz, type); + } + + @Override + public CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class clazz) { + return delegate.getResultAsync(timeout, unit, clazz, null); + } + + @Override + public CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class clazz, @Nullable Type type) { + return delegate.getResultAsync(timeout, unit, clazz, type); + } + + @Override + public NexusOperationExecutionDescription describe() { + return delegate.describe(); + } + + @Override + public void cancel() { + delegate.cancel(); + } + + @Override + public void cancel(@Nullable String reason) { + delegate.cancel(reason); + } + + @Override + public void terminate() { + delegate.terminate(); + } + + @Override + public void terminate(@Nullable String reason) { + delegate.terminate(reason); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java new file mode 100644 index 0000000000..c25adda353 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationNotFoundException.java @@ -0,0 +1,31 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Thrown when a Nexus operation with the given ID is not known to the Temporal service or is in an + * incorrect state to perform the requested operation. + * + *

Examples of possible causes: + * + *

    + *
  • operation ID doesn't exist + *
  • operation was purged from the service after reaching its retention limit + *
  • attempt to cancel/terminate/delete an operation that is already closed + *
+ */ +@Experimental +public final class NexusOperationNotFoundException extends NexusOperationException { + + public NexusOperationNotFoundException( + String operationId, @Nullable String runId, @Nullable Throwable cause) { + super( + "Nexus operation not found: operationId='" + + operationId + + (runId != null ? "', runId='" + runId + "'" : "'"), + operationId, + runId, + cause); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java new file mode 100644 index 0000000000..4629e297a8 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClient.java @@ -0,0 +1,130 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.workflow.Functions; +import java.util.concurrent.CompletableFuture; + +/** + * Typed client for invoking standalone Nexus operations on a specific service interface {@code T}. + * + *

Operations are dispatched via method references on {@code T} (or equivalent {@link + * Functions.Func2} / {@link Functions.Func1} lambdas); 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. + * + *

Usage

+ * + *

Given a Nexus service interface: + * + *

{@code
+ * @Service
+ * public interface GreeterService {
+ *   @Operation String greet(String name);     // input + output
+ *   @Operation String now();                  // no input, output
+ *   @Operation Void log(String message);      // input, no output
+ * }
+ * }
+ * + *

Build a client and dispatch by method reference: + * + *

{@code
+ * NexusClient nexusClient = NexusClient.newInstance(workflowServiceStubs);
+ * NexusServiceClient client =
+ *     nexusClient.newNexusServiceClient(GreeterService.class, "greeter-endpoint");
+ *
+ * StartNexusOperationOptions options = StartNexusOperationOptions.newBuilder()
+ *     .setId(UUID.randomUUID().toString())
+ *     .build();
+ *
+ * // Operation that takes an input (Func2 overload):
+ * String hi = client.execute(GreeterService::greet, options, "Ada");
+ *
+ * // Operation with no input (Func1 overload):
+ * String t = client.execute(GreeterService::now, options);
+ *
+ * // Operation that returns Void: the same overloads work, R is just Void.
+ * client.execute(GreeterService::log, options, "hello");
+ *
+ * // Get a handle instead of blocking:
+ * NexusOperationHandle handle = client.start(GreeterService::greet, options, "Ada");
+ * String result = handle.getResult();
+ *
+ * // Run asynchronously:
+ * CompletableFuture future =
+ *     client.executeAsync(GreeterService::greet, options, "Ada");
+ * }
+ * + * @param the Nexus service interface this client is bound to + * @see NexusClient + * @see UntypedNexusServiceClient + */ +@Experimental +public interface NexusServiceClient extends UntypedNexusServiceClient { + + /** + * Executes an operation synchronously with per-call options. + * + * @param operation a method reference on {@code T} identifying the operation + * @param options per-call options controlling timeouts, search attributes, etc. + * @param input the operation input + * @return the operation result + * @throws NexusOperationException if the operation failed, timed out, or was cancelled + */ + R execute(Functions.Func2 operation, StartNexusOperationOptions options, U input); + + /** + * Starts an operation with per-call options and returns a typed handle. + * + * @param operation a method reference on {@code T} identifying the operation + * @param options per-call options controlling timeouts, search attributes, etc. + * @param input the operation input + * @return a typed handle bound to the started operation + */ + NexusOperationHandle start( + Functions.Func2 operation, StartNexusOperationOptions options, U input); + + /** + * Async variant of {@link #execute(Functions.Func2, StartNexusOperationOptions, Object)}. Returns + * a {@link CompletableFuture} that completes with the typed result, or completes exceptionally if + * the operation fails. + * + * @param operation a method reference on {@code T} identifying the operation + * @param options per-call options controlling timeouts, search attributes, etc. + * @param input the operation input + */ + CompletableFuture executeAsync( + Functions.Func2 operation, StartNexusOperationOptions options, U input); + + /** + * Executes a no-input operation synchronously with per-call options. Use this overload for Nexus + * operations declared without an input parameter on {@code T} (e.g. {@code R operation()}). + * + * @param operation a method reference on {@code T} identifying the no-input operation + * @param options per-call options controlling timeouts, search attributes, etc. + * @return the operation result + * @throws NexusOperationException if the operation failed, timed out, or was cancelled + */ + R execute(Functions.Func1 operation, StartNexusOperationOptions options); + + /** + * Starts a no-input operation with per-call options and returns a typed handle. Use this overload + * for Nexus operations declared without an input parameter on {@code T}. + * + * @param operation a method reference on {@code T} identifying the no-input operation + * @param options per-call options controlling timeouts, search attributes, etc. + * @return a typed handle bound to the started operation + */ + NexusOperationHandle start( + Functions.Func1 operation, StartNexusOperationOptions options); + + /** + * Async variant of {@link #execute(Functions.Func1, StartNexusOperationOptions)} for no-input + * operations. Returns a {@link CompletableFuture} that completes with the typed result, or + * completes exceptionally if the operation fails. + * + * @param operation a method reference on {@code T} identifying the no-input operation + * @param options per-call options controlling timeouts, search attributes, etc. + */ + CompletableFuture executeAsync( + Functions.Func1 operation, StartNexusOperationOptions options); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java new file mode 100644 index 0000000000..d827bbdcc7 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -0,0 +1,109 @@ +package io.temporal.client; + +import io.nexusrpc.OperationDefinition; +import io.nexusrpc.ServiceDefinition; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.internal.util.MethodExtractor; +import io.temporal.workflow.Functions; +import java.lang.reflect.Method; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; + +/** + * Typed Nexus service client. Extracts the operation name from a {@link Functions.Func2} 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; + private final ServiceDefinition serviceDef; + + NexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + Class serviceInterface, + String endpoint, + NexusClientOptions options) { + this( + invoker, + serviceInterface, + ServiceDefinition.fromClass(serviceInterface), + endpoint, + options); + } + + private NexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + Class serviceInterface, + ServiceDefinition serviceDef, + String endpoint, + NexusClientOptions options) { + super(invoker, endpoint, serviceDef.getName(), options); + this.serviceInterface = serviceInterface; + this.serviceDef = serviceDef; + } + + @Override + public NexusOperationHandle start( + Functions.Func2 operation, StartNexusOperationOptions options, U input) { + Method method = MethodExtractor.extract(serviceInterface, operation); + return startResolved(method, input, options); + } + + @Override + public R execute( + Functions.Func2 operation, StartNexusOperationOptions options, U input) { + return start(operation, options, input).getResult(); + } + + @Override + public CompletableFuture executeAsync( + Functions.Func2 operation, StartNexusOperationOptions options, U input) { + return start(operation, options, input).getResultAsync(); + } + + @Override + public NexusOperationHandle start( + Functions.Func1 operation, StartNexusOperationOptions options) { + Method method = MethodExtractor.extract(serviceInterface, operation); + return startResolved(method, null, options); + } + + @Override + public R execute(Functions.Func1 operation, StartNexusOperationOptions options) { + return start(operation, options).getResult(); + } + + @Override + public CompletableFuture executeAsync( + Functions.Func1 operation, StartNexusOperationOptions options) { + return start(operation, options).getResultAsync(); + } + + /** + * Shared back-end for the typed start variants: resolves the method to its Nexus {@code + * OperationDefinition}, issues the start RPC, and wraps the resulting untyped handle in a typed + * one. {@code input} may be {@code null} for no-input operations. + */ + private NexusOperationHandle startResolved( + Method method, @Nullable Object input, StartNexusOperationOptions options) { + OperationDefinition opDef = + serviceDef.getOperations().values().stream() + .filter(o -> method.getName().equals(o.getMethodName())) + .findFirst() + .orElseThrow( + () -> + new IllegalArgumentException( + "Method " + + method.getName() + + " is not a Nexus operation on " + + serviceInterface.getName())); + @SuppressWarnings("unchecked") + Class resultClass = (Class) method.getReturnType(); + UntypedNexusOperationHandle untyped = start(opDef.getName(), options, input); + return NexusOperationHandle.fromUntyped(untyped, resultClass, method.getGenericReturnType()); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java new file mode 100644 index 0000000000..aeb68092f4 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/StartNexusOperationOptions.java @@ -0,0 +1,235 @@ +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.Objects; +import javax.annotation.Nonnull; +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 @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.summary = options.summary; + this.idReusePolicy = options.idReusePolicy; + this.idConflictPolicy = options.idConflictPolicy; + } + + /** + * Required. Unique identifier for this operation within its namespace. {@link #build()} throws + * {@link IllegalStateException} if {@code setId} was never called. + */ + public Builder setId(@Nonnull String id) { + Objects.requireNonNull(id, "id"); + if (id.trim().isEmpty()) { + throw new IllegalArgumentException("id must not be blank"); + } + 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; + } + + /** 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() { + if (id == null || id.trim().isEmpty()) { + throw new IllegalStateException( + "StartNexusOperationOptions.Builder.setId(...) must be called with a non-blank id " + + "before build(); the SDK does not generate operation IDs."); + } + return new StartNexusOperationOptions(this); + } + } + + private final @Nonnull String id; + private final @Nullable Duration scheduleToCloseTimeout; + private final @Nullable Duration scheduleToStartTimeout; + private final @Nullable Duration startToCloseTimeout; + private final @Nullable SearchAttributes typedSearchAttributes; + 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.summary = builder.summary; + this.idReusePolicy = builder.idReusePolicy; + this.idConflictPolicy = builder.idConflictPolicy; + } + + public Builder toBuilder() { + return new Builder(this); + } + + /** + * The required operation ID. Guaranteed non-null and non-blank — {@link Builder#build} rejects + * any options where {@link Builder#setId} was not called or was passed a blank value. + */ + @Nonnull + 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; + } + + @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(summary, that.summary) + && idReusePolicy == that.idReusePolicy + && idConflictPolicy == that.idConflictPolicy; + } + + @Override + public int hashCode() { + return Objects.hash( + id, + scheduleToCloseTimeout, + scheduleToStartTimeout, + startToCloseTimeout, + typedSearchAttributes, + summary, + idReusePolicy, + idConflictPolicy); + } + + @Override + public String toString() { + return "StartNexusOperationOptions{" + + "id='" + + id + + "', scheduleToCloseTimeout=" + + scheduleToCloseTimeout + + ", scheduleToStartTimeout=" + + scheduleToStartTimeout + + ", startToCloseTimeout=" + + startToCloseTimeout + + ", typedSearchAttributes=" + + typedSearchAttributes + + ", summary='" + + summary + + "', idReusePolicy=" + + idReusePolicy + + ", idConflictPolicy=" + + idConflictPolicy + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java new file mode 100644 index 0000000000..4fc641c9b2 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusOperationHandle.java @@ -0,0 +1,149 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +/** + * An untyped handle to a standalone Nexus operation execution. Use this to get the result, + * describe, cancel, or terminate the operation when the result type is not known at compile time. + * + *

Obtain an instance via {@link NexusClient#getHandle(String, String)} or as the untyped + * projection of a handle returned by {@link NexusServiceClient}. + * + * @see NexusOperationHandle + * @see NexusClient + */ +@Experimental +public interface UntypedNexusOperationHandle { + + /** The caller-assigned operation ID for this execution. Always non-null. */ + String getNexusOperationId(); + + /** + * The server-assigned run ID for this operation execution. Present when the handle was returned + * by {@code start} or when {@link NexusClient#getHandle(String, String)} was called with an + * explicit run ID. + */ + @Nullable + String getNexusOperationRunId(); + + /** + * Blocks until the standalone Nexus operation completes and returns the typed result. Polls the + * server via long-polling. + * + * @param resultClass the class to deserialize the result into + * @throws NexusOperationException if the operation failed, timed out, or was cancelled; the + * concrete subtype reflects the underlying failure + */ + R getResult(Class resultClass); + + /** + * Blocks until the standalone Nexus operation completes and returns the typed result. Use this + * overload for generic return types (e.g. {@code List}). + * + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + * @throws NexusOperationException if the operation failed, timed out, or was cancelled; the + * concrete subtype reflects the underlying failure + */ + R getResult(Class resultClass, @Nullable Type resultType); + + /** + * Blocks until the standalone Nexus operation completes and returns the typed result, or throws + * if the client-side timeout expires before the operation completes. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + * @throws NexusOperationException if the operation failed, timed out on the server, or was + * cancelled + * @throws TimeoutException if the client-side {@code timeout} expires before the operation + * completes + */ + R getResult(long timeout, TimeUnit unit, Class resultClass) throws TimeoutException; + + /** + * Blocks until the standalone Nexus operation completes and returns the typed result, or throws + * if the client-side timeout expires. Use this overload for generic return types (e.g. {@code + * List}). + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + * @throws NexusOperationException if the operation failed, timed out on the server, or was + * cancelled + * @throws TimeoutException if the client-side {@code timeout} expires before the operation + * completes + */ + R getResult(long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException; + + /** + * Returns a future that completes when the operation completes and resolves to the typed result. + * + * @param resultClass the class to deserialize the result into + */ + CompletableFuture getResultAsync(Class resultClass); + + /** + * Returns a future that completes when the operation completes and resolves to the typed result. + * Use this overload for generic return types (e.g. {@code List}). + * + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + */ + CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType); + + /** + * Returns a future that completes when the operation completes, or fails with {@link + * TimeoutException} if the operation does not complete within the specified timeout. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + */ + CompletableFuture getResultAsync(long timeout, TimeUnit unit, Class resultClass); + + /** + * Returns a future for generic return types with a timeout. + * + * @param timeout maximum time to wait + * @param unit unit of {@code timeout} + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization; may be {@code null} + */ + CompletableFuture getResultAsync( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType); + + /** + * Describes the current state of the Nexus operation execution. + * + * @return detailed information about the operation + */ + NexusOperationExecutionDescription describe(); + + /** Requests cancellation of the Nexus operation. */ + void cancel(); + + /** + * Requests cancellation of the Nexus operation with an optional reason. + * + * @param reason human-readable reason for cancellation, may be {@code null} + */ + void cancel(@Nullable String reason); + + /** Terminates the Nexus operation immediately, regardless of its current state. */ + void terminate(); + + /** + * Terminates the Nexus operation immediately with a reason. + * + * @param reason human-readable reason for termination, may be {@code null} + */ + void terminate(@Nullable String reason); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java new file mode 100644 index 0000000000..be56f19bf4 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClient.java @@ -0,0 +1,64 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.lang.reflect.Type; +import javax.annotation.Nullable; + +/** + * Untyped client for invoking standalone Nexus operations by operation-name string. Use this when + * the operation contract is not available as a Java service interface at compile time. For a typed + * variant, see {@link NexusServiceClient}. + * + * @see NexusServiceClient + * @see NexusClient + */ +@Experimental +public interface UntypedNexusServiceClient { + + /** + * Starts a Nexus operation by name and returns an untyped handle for tracking its execution. + * + * @param operation the operation name as registered on the service + * @param options per-call options controlling timeouts, search attributes, etc. + * @param arg the operation input; may be {@code null} + * @return an untyped handle bound to the started operation + */ + UntypedNexusOperationHandle start( + String operation, StartNexusOperationOptions options, @Nullable Object arg); + + /** + * Executes a Nexus operation synchronously by name, blocking until it completes. + * + * @param operation the operation name as registered on the service + * @param resultClass the class to deserialize the result into + * @param options per-call options controlling timeouts, search attributes, etc. + * @param arg the operation input; may be {@code null} + * @return the deserialized operation result + * @throws NexusOperationException if the operation failed, timed out, or was cancelled + */ + R execute( + String operation, + Class resultClass, + StartNexusOperationOptions options, + @Nullable Object arg); + + /** + * Executes a Nexus operation synchronously by name with an explicit generic-result {@link Type}. + * Use this overload when the result is a generic type whose parameters cannot be captured by + * {@link Class} alone (e.g. {@code List}). + * + * @param operation the operation name as registered on the service + * @param resultClass the class to deserialize the result into + * @param resultType the generic type to use for deserialization + * @param options per-call options controlling timeouts, search attributes, etc. + * @param arg the operation input; may be {@code null} + * @return the deserialized operation result + * @throws NexusOperationException if the operation failed, timed out, or was cancelled + */ + R execute( + String operation, + Class resultClass, + Type resultType, + StartNexusOperationOptions options, + @Nullable Object arg); +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java new file mode 100644 index 0000000000..0750d22250 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -0,0 +1,85 @@ +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.internal.client.NexusOperationHandleImpl; +import java.lang.reflect.Type; +import java.util.Collections; +import javax.annotation.Nullable; + +/** + * Untyped Nexus service client. Holds the {@link NexusClientCallsInterceptor invoker}, target + * endpoint, service name, and data converter, and translates operation-name calls into start RPCs + * routed through the interceptor chain. + */ +@Experimental +class UntypedNexusServiceClientImpl implements UntypedNexusServiceClient { + + private final NexusClientCallsInterceptor invoker; + private final String endpoint; + private final String serviceName; + private final DataConverter dataConverter; + + UntypedNexusServiceClientImpl( + NexusClientCallsInterceptor invoker, + String endpoint, + String serviceName, + NexusClientOptions clientOptions) { + if (invoker == null || endpoint == null || serviceName == null || clientOptions == null) { + throw new IllegalArgumentException( + "invoker, endpoint, serviceName, and clientOptions are all required"); + } + this.invoker = invoker; + this.endpoint = endpoint; + this.serviceName = serviceName; + this.dataConverter = clientOptions.getDataConverter(); + } + + @Override + public UntypedNexusOperationHandle start( + String operation, StartNexusOperationOptions options, @Nullable Object arg) { + Payload payload = serializeInput(arg); + StartNexusOperationExecutionInput input = + new StartNexusOperationExecutionInput( + endpoint, serviceName, operation, payload, options, Collections.emptyMap()); + StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); + return new NexusOperationHandleImpl(output.getOperationId(), output.getRunId(), invoker); + } + + @Override + public R execute( + String operation, + Class resultClass, + StartNexusOperationOptions options, + @Nullable Object arg) { + return execute(operation, resultClass, null, options, arg); + } + + @Override + public R execute( + String operation, + Class resultClass, + @Nullable Type resultType, + StartNexusOperationOptions options, + @Nullable Object arg) { + UntypedNexusOperationHandle handle = start(operation, options, arg); + return NexusOperationHandle.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 0000000000..9af27865bc --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -0,0 +1,440 @@ +package io.temporal.common.interceptors; + +import io.grpc.Deadline; +import io.temporal.api.common.v1.Payload; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationExecutionMetadata; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.client.NexusOperationHandle; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.common.Experimental; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Per-call interceptor for {@link NexusClient} and {@link NexusOperationHandle} 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 { + + /** + * Starts a standalone Nexus operation. The endpoint, service, operation name, input, and + * scheduling options are carried in {@code input}. + * + * @param input endpoint, service name, operation name, encoded input, and start options + * @return output containing the operation ID, server-assigned run ID, and whether the operation + * was started by this call (vs. de-duplicated to an existing one) + */ + StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input); + + /** + * Returns a point-in-time snapshot of a standalone Nexus operation execution. + * + * @param input operation ID and optional run ID + * @return output wrapping the {@link NexusOperationExecutionDescription} + */ + DescribeNexusOperationExecutionOutput describeNexusOperationExecution( + DescribeNexusOperationExecutionInput input); + + /** + * Synchronously waits for a standalone Nexus operation to complete and returns the deserialized + * result. Implementations own the poll loop, deadline enforcement, and {@link Payload} → {@code + * R} deserialization. Blocks the calling thread for the duration. + * + *

If you implement this method, {@link #getNexusOperationResultAsync} most likely needs to be + * implemented too. + * + * @param input operation ID, optional run ID, deadline, and the expected result class and type + * @param the expected result type + * @return output wrapping the deserialized result + * @throws NexusOperationFailedException if the operation completed with a failure + * @throws TimeoutException if the deadline expires before the operation completes + * @see #getNexusOperationResultAsync + */ + GetNexusOperationResultOutput getNexusOperationResult( + GetNexusOperationResultInput input) throws TimeoutException; + + /** + * Asynchronous variant of {@link #getNexusOperationResult} that returns a future without blocking + * the calling thread. + * + *

If you implement this method, {@link #getNexusOperationResult} most likely needs to be + * implemented too. + * + * @param input operation ID, optional run ID, deadline, and the expected result class and type + * @param the expected result type + * @return a future that completes with the deserialized result, or completes exceptionally with + * {@link NexusOperationFailedException} on failure or {@link TimeoutException} on deadline + * expiry + * @see #getNexusOperationResult + */ + CompletableFuture> getNexusOperationResultAsync( + GetNexusOperationResultInput input); + + /** + * Lists standalone Nexus operation executions matching a Visibility query. The returned output + * contains a lazy {@link Stream} of deserialized {@link NexusOperationExecutionMetadata} objects; + * pages are fetched on demand as the stream is consumed. + * + * @param input Visibility query string + * @return output wrapping a lazy stream of matching operations + */ + ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input); + + /** + * Returns the count of standalone Nexus operation executions matching a Visibility query, + * optionally grouped by attribute. + * + * @param input Visibility query string + * @return output wrapping the total count and any aggregation groups + */ + CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input); + + /** + * Requests cancellation of a running standalone Nexus operation. The server forwards the cancel + * request to the operation handler, which may honour or ignore it. + * + * @param input operation ID, optional run ID, and optional human-readable cancellation reason + * @return an empty output that exists so the call can carry fields in the future + */ + RequestCancelNexusOperationExecutionOutput requestCancelNexusOperationExecution( + RequestCancelNexusOperationExecutionInput input); + + /** + * Forcefully terminates a standalone Nexus operation. Unlike cancellation, termination is + * immediate and cannot be intercepted by the operation handler. + * + * @param input operation ID, optional run ID, and optional human-readable termination reason + * @return an empty output that exists so the call can carry fields in the future + */ + TerminateNexusOperationExecutionOutput terminateNexusOperationExecution( + TerminateNexusOperationExecutionInput input); + + /** + * Deletes a closed standalone Nexus operation execution from the server's visibility store. The + * operation must already be in a terminal state. + * + * @param input operation ID and optional run ID + * @return an empty output that exists so the call can carry fields in the future + */ + DeleteNexusOperationExecutionOutput 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; + private final Map headers; + + public StartNexusOperationExecutionInput( + String endpoint, + String service, + String operation, + @Nullable Payload input, + StartNexusOperationOptions options, + Map headers) { + this.endpoint = endpoint; + this.service = service; + this.operation = operation; + this.input = input; + this.options = options; + this.headers = headers == null ? Collections.emptyMap() : headers; + } + + public String getEndpoint() { + return endpoint; + } + + public String getService() { + return service; + } + + public String getOperation() { + return operation; + } + + public Optional getInput() { + return Optional.ofNullable(input); + } + + public StartNexusOperationOptions getOptions() { + return options; + } + + /** + * Nexus protocol headers to forward to the handler. Interceptors implementing context + * propagation (tracing, baggage, etc.) populate this map by wrapping the call chain. + */ + public Map getHeaders() { + return headers; + } + } + + final class StartNexusOperationExecutionOutput { + 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; + + public DescribeNexusOperationExecutionInput(String operationId, @Nullable String runId) { + this.operationId = operationId; + this.runId = runId; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + } + + final class DescribeNexusOperationExecutionOutput { + private final NexusOperationExecutionDescription description; + + public DescribeNexusOperationExecutionOutput(NexusOperationExecutionDescription description) { + this.description = description; + } + + public NexusOperationExecutionDescription getDescription() { + return description; + } + } + + final class GetNexusOperationResultInput { + private final String operationId; + private final @Nullable String runId; + private final @Nonnull Deadline deadline; + private final Class resultClass; + private final @Nullable Type resultType; + + public GetNexusOperationResultInput( + String operationId, + @Nullable String runId, + @Nonnull Deadline deadline, + Class resultClass, + @Nullable Type resultType) { + this.operationId = operationId; + this.runId = runId; + this.deadline = deadline; + this.resultClass = resultClass; + this.resultType = resultType; + } + + public String getOperationId() { + return operationId; + } + + public Optional getRunId() { + return Optional.ofNullable(runId); + } + + @Nonnull + public Deadline getDeadline() { + return deadline; + } + + public Class getResultClass() { + return resultClass; + } + + @Nullable + public Type getResultType() { + return resultType; + } + } + + final class GetNexusOperationResultOutput { + private final R result; + + public GetNexusOperationResultOutput(R result) { + this.result = result; + } + + public R getResult() { + return result; + } + } + + final class ListNexusOperationExecutionsInput { + private final @Nullable String query; + + public ListNexusOperationExecutionsInput(@Nullable String query) { + this.query = query; + } + + public Optional getQuery() { + return Optional.ofNullable(query); + } + } + + /** + * Result of a list call. Holds a lazy {@link Stream} of deserialized {@link + * NexusOperationExecutionMetadata} objects; pages are fetched on demand as the stream is + * consumed. A {@code Stream} is single-use and must not be consumed more than once. + */ + final class ListNexusOperationExecutionsOutput { + private final Stream operations; + + public ListNexusOperationExecutionsOutput(Stream operations) { + this.operations = operations; + } + + public Stream getOperations() { + return operations; + } + } + + 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 NexusOperationExecutionCount count; + + public CountNexusOperationExecutionsOutput(NexusOperationExecutionCount count) { + this.count = count; + } + + public NexusOperationExecutionCount 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 RequestCancelNexusOperationExecutionOutput { + public RequestCancelNexusOperationExecutionOutput() { + // This output is intentionally empty and exists so it can carry fields in the future. + } + } + + 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 TerminateNexusOperationExecutionOutput { + public TerminateNexusOperationExecutionOutput() { + // This output is intentionally empty and exists so it can carry fields in the future. + } + } + + 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); + } + } + + final class DeleteNexusOperationExecutionOutput { + public DeleteNexusOperationExecutionOutput() { + // This output is intentionally empty and exists so it can carry fields in the future. + } + } +} 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 0000000000..61d84bbae4 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptorBase.java @@ -0,0 +1,73 @@ +package io.temporal.common.interceptors; + +import io.temporal.common.Experimental; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; + +/** + * 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 GetNexusOperationResultOutput getNexusOperationResult( + GetNexusOperationResultInput input) throws TimeoutException { + return next.getNexusOperationResult(input); + } + + @Override + public CompletableFuture> getNexusOperationResultAsync( + GetNexusOperationResultInput input) { + return next.getNexusOperationResultAsync(input); + } + + @Override + public ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input) { + return next.listNexusOperationExecutions(input); + } + + @Override + public CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input) { + return next.countNexusOperationExecutions(input); + } + + @Override + public RequestCancelNexusOperationExecutionOutput requestCancelNexusOperationExecution( + RequestCancelNexusOperationExecutionInput input) { + return next.requestCancelNexusOperationExecution(input); + } + + @Override + public TerminateNexusOperationExecutionOutput terminateNexusOperationExecution( + TerminateNexusOperationExecutionInput input) { + return next.terminateNexusOperationExecution(input); + } + + @Override + public DeleteNexusOperationExecutionOutput deleteNexusOperationExecution( + DeleteNexusOperationExecutionInput input) { + return 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 0000000000..3af217f3fb --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptor.java @@ -0,0 +1,24 @@ +package io.temporal.common.interceptors; + +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.common.Experimental; + +/** + * Outer interceptor for {@link NexusClient}. Implementations are registered via {@link + * NexusClientOptions.Builder#setInterceptors(java.util.List)} and consulted once during client + * construction to build the chain of {@link NexusClientCallsInterceptor}s that wraps the root + * invoker. + */ +@Experimental +public interface NexusClientInterceptor { + + /** + * Called once during {@link NexusClient} construction to build the chain of per-call + * interceptors. + * + * @param next next per-call interceptor in the chain + * @return new per-call interceptor that decorates calls to {@code next} + */ + NexusClientCallsInterceptor nexusClientCallsInterceptor(NexusClientCallsInterceptor next); +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientInterceptorBase.java new file mode 100644 index 0000000000..b964626fde --- /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/ListNexusOperationExecutionIterator.java b/temporal-sdk/src/main/java/io/temporal/internal/client/ListNexusOperationExecutionIterator.java new file mode 100644 index 0000000000..1a5ea73d82 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/ListNexusOperationExecutionIterator.java @@ -0,0 +1,55 @@ +package io.temporal.internal.client; + +import com.google.protobuf.ByteString; +import io.temporal.api.nexus.v1.NexusOperationExecutionListInfo; +import io.temporal.api.workflowservice.v1.ListNexusOperationExecutionsRequest; +import io.temporal.api.workflowservice.v1.ListNexusOperationExecutionsResponse; +import io.temporal.internal.client.external.GenericWorkflowClient; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +class ListNexusOperationExecutionIterator + extends EagerPaginator { + + private final @Nullable String query; + private final @Nonnull String namespace; + private final @Nonnull GenericWorkflowClient genericClient; + + ListNexusOperationExecutionIterator( + @Nullable String query, + @Nonnull String namespace, + @Nonnull GenericWorkflowClient genericClient) { + this.query = query; + this.namespace = Objects.requireNonNull(namespace, "namespace"); + this.genericClient = Objects.requireNonNull(genericClient, "genericClient"); + } + + @Override + protected CompletableFuture performRequest( + @Nonnull ByteString nextPageToken) { + ListNexusOperationExecutionsRequest.Builder request = + ListNexusOperationExecutionsRequest.newBuilder() + .setNamespace(namespace) + .setNextPageToken(nextPageToken); + + if (query != null) { + request.setQuery(query); + } + + return genericClient.listNexusOperationExecutionsAsync(request.build()); + } + + @Override + protected ByteString getNextPageToken(ListNexusOperationExecutionsResponse response) { + return response.getNextPageToken(); + } + + @Override + protected List toElements( + ListNexusOperationExecutionsResponse response) { + return response.getOperationsList(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java new file mode 100644 index 0000000000..732de7e49c --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java @@ -0,0 +1,139 @@ +package io.temporal.internal.client; + +import io.grpc.Deadline; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.UntypedNexusOperationHandle; +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.GetNexusOperationResultInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.GetNexusOperationResultOutput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.RequestCancelNexusOperationExecutionInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.TerminateNexusOperationExecutionInput; +import java.lang.reflect.Type; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nullable; + +/** + * Implementation of {@link UntypedNexusOperationHandle} that delegates lifecycle operations through + * the interceptor chain. + */ +public final class NexusOperationHandleImpl implements UntypedNexusOperationHandle { + + private final String operationId; + private final @Nullable String runId; + private final NexusClientCallsInterceptor interceptor; + + public NexusOperationHandleImpl( + String operationId, @Nullable String runId, NexusClientCallsInterceptor interceptor) { + if (operationId == null) { + throw new IllegalArgumentException("operationId is required"); + } + if (interceptor == null) { + throw new IllegalArgumentException("interceptor is required"); + } + this.operationId = operationId; + this.runId = runId; + this.interceptor = interceptor; + } + + @Override + public String getNexusOperationId() { + return operationId; + } + + @Override + public @Nullable String getNexusOperationRunId() { + return runId; + } + + @Override + public NexusOperationExecutionDescription describe() { + DescribeNexusOperationExecutionInput input = + new DescribeNexusOperationExecutionInput(operationId, runId); + DescribeNexusOperationExecutionOutput output = + interceptor.describeNexusOperationExecution(input); + return output.getDescription(); + } + + @Override + public void cancel() { + cancel(null); + } + + @Override + public void cancel(@Nullable String reason) { + interceptor.requestCancelNexusOperationExecution( + new RequestCancelNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public void terminate() { + terminate(null); + } + + @Override + public void terminate(@Nullable String reason) { + interceptor.terminateNexusOperationExecution( + new TerminateNexusOperationExecutionInput(operationId, runId, reason)); + } + + @Override + public R getResult(Class resultClass) { + return getResult(resultClass, null); + } + + @Override + public R getResult(Class resultClass, @Nullable Type resultType) { + try { + return getResult(Integer.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); + } catch (TimeoutException e) { + throw new RuntimeException(e); + } + } + + @Override + public CompletableFuture getResultAsync(Class resultClass) { + return getResultAsync(resultClass, null); + } + + @Override + public CompletableFuture getResultAsync(Class resultClass, @Nullable Type resultType) { + return getResultAsync(Long.MAX_VALUE, TimeUnit.MILLISECONDS, resultClass, resultType); + } + + @Override + public R getResult(long timeout, TimeUnit unit, Class resultClass) + throws TimeoutException { + return getResult(timeout, unit, resultClass, null); + } + + @Override + public R getResult( + long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) + throws TimeoutException { + GetNexusOperationResultInput input = + new GetNexusOperationResultInput<>( + operationId, runId, Deadline.after(timeout, unit), resultClass, resultType); + return interceptor.getNexusOperationResult(input).getResult(); + } + + @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) { + GetNexusOperationResultInput input = + new GetNexusOperationResultInput<>( + operationId, runId, Deadline.after(timeout, unit), resultClass, resultType); + return interceptor + .getNexusOperationResultAsync(input) + .thenApply(GetNexusOperationResultOutput::getResult); + } +} 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 0000000000..78d7bc39a7 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -0,0 +1,383 @@ +package io.temporal.internal.client; + +import com.google.common.base.Strings; +import com.google.common.collect.Iterators; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.NexusOperationWaitStage; +import io.temporal.api.errordetails.v1.NexusOperationExecutionAlreadyStartedFailure; +import io.temporal.api.failure.v1.Failure; +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.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.NexusClientOptions; +import io.temporal.client.NexusOperationAlreadyStartedException; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationExecutionMetadata; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.client.NexusOperationNotFoundException; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.internal.client.external.GenericWorkflowClient; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.WorkflowExecutionUtils; +import io.temporal.serviceclient.StatusUtils; +import java.util.Iterator; +import java.util.Objects; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeoutException; +import java.util.stream.StreamSupport; +import javax.annotation.Nullable; + +/** + * Root implementation of {@link NexusClientCallsInterceptor} that converts the SDK's Java DTOs into + * proto requests and delegates the actual gRPC calls to {@link GenericWorkflowClient}. + */ +@Experimental +public class RootNexusClientInvoker implements NexusClientCallsInterceptor { + + private final GenericWorkflowClient genericClient; + private final NexusClientOptions clientOptions; + + public RootNexusClientInvoker( + GenericWorkflowClient genericClient, NexusClientOptions clientOptions) { + this.genericClient = genericClient; + this.clientOptions = clientOptions; + } + + @Override + public StartNexusOperationExecutionOutput startNexusOperationExecution( + StartNexusOperationExecutionInput input) { + StartNexusOperationOptions options = input.getOptions(); + // The builder validates that id is non-null; this is a defense-in-depth assertion. + String operationId = Objects.requireNonNull(options.getId(), "StartNexusOperationOptions.id"); + StartNexusOperationExecutionRequest.Builder request = + StartNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setOperationId(operationId) + .setEndpoint(input.getEndpoint()) + .setService(input.getService()) + .setOperation(input.getOperation()); + // Ensure that the headers are lowercase. + input.getHeaders().forEach((k, v) -> request.putNexusHeader(k.toLowerCase(), v)); + + if (options.getScheduleToCloseTimeout() != null) { + request.setScheduleToCloseTimeout( + 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(), null, clientOptions.getDataConverter()); + if (metadata != null) { + request.setUserMetadata(metadata); + } + } + + StartNexusOperationExecutionResponse response; + try { + response = genericClient.startNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.ALREADY_EXISTS) { + NexusOperationExecutionAlreadyStartedFailure detail = + StatusUtils.getFailure(e, NexusOperationExecutionAlreadyStartedFailure.class); + if (detail != null) { + String runId = Strings.emptyToNull(detail.getRunId()); + throw new NexusOperationAlreadyStartedException( + operationId, input.getOperation(), runId, e); + } + } + throw e; + } + return new StartNexusOperationExecutionOutput( + operationId, response.getRunId(), response.getStarted()); + } + + @Override + public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( + DescribeNexusOperationExecutionInput input) { + DescribeNexusOperationExecutionRequest request = buildDescribeRequest(input); + DescribeNexusOperationExecutionResponse response; + try { + response = genericClient.describeNexusOperationExecution(request); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } + return new DescribeNexusOperationExecutionOutput( + new NexusOperationExecutionDescription( + response, clientOptions.getDataConverter(), clientOptions.getNamespace())); + } + + private DescribeNexusOperationExecutionRequest buildDescribeRequest( + DescribeNexusOperationExecutionInput input) { + // Describe defaults: outcome is included so callers can read the success/failure of completed + // operations; input is omitted to keep responses small. These are SDK-internal decisions and + // not exposed through the interceptor surface. + DescribeNexusOperationExecutionRequest.Builder request = + DescribeNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setOperationId(input.getOperationId()) + .setIncludeInput(false) + .setIncludeOutcome(true); + input.getRunId().ifPresent(request::setRunId); + return request.build(); + } + + @Override + public GetNexusOperationResultOutput getNexusOperationResult( + GetNexusOperationResultInput input) throws TimeoutException { + String operationId = input.getOperationId(); + String runId = input.getRunId().orElse(null); + while (true) { + PollNexusOperationExecutionResponse response; + try { + response = + genericClient.pollNexusOperationExecution(buildPollRequest(input), input.getDeadline()); + } catch (StatusRuntimeException e) { + if (input.getDeadline().isExpired() + && Status.Code.DEADLINE_EXCEEDED.equals(e.getStatus().getCode())) { + throw new TimeoutException("getResult timed out before the operation completed"); + } + throw mapNotFound(operationId, runId, e); + } + if (response.getWaitStage() == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return extractResult(operationId, runId, response, input); + } + } + } + + @Override + public CompletableFuture> getNexusOperationResultAsync( + GetNexusOperationResultInput input) { + return pollAsyncUntilClosed(input) + .thenApply( + response -> + extractResult( + input.getOperationId(), input.getRunId().orElse(null), response, input)); + } + + private CompletableFuture pollAsyncUntilClosed( + GetNexusOperationResultInput input) { + String operationId = input.getOperationId(); + String runId = input.getRunId().orElse(null); + return genericClient + .pollNexusOperationExecutionAsync(buildPollRequest(input), input.getDeadline()) + .handle( + (response, err) -> { + if (err == null) { + if (response.getWaitStage() + == NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) { + return CompletableFuture.completedFuture(response); + } + return pollAsyncUntilClosed(input); + } + CompletableFuture failed = + new CompletableFuture<>(); + Throwable cause = err instanceof CompletionException ? err.getCause() : err; + if (input.getDeadline().isExpired() + && cause instanceof StatusRuntimeException + && Status.Code.DEADLINE_EXCEEDED.equals( + ((StatusRuntimeException) cause).getStatus().getCode())) { + failed.completeExceptionally( + new TimeoutException("getResult timed out before the operation completed")); + } else if (cause instanceof StatusRuntimeException) { + failed.completeExceptionally( + mapNotFound(operationId, runId, (StatusRuntimeException) cause)); + } else { + failed.completeExceptionally(err); + } + return failed; + }) + .thenCompose(f -> f); + } + + private PollNexusOperationExecutionRequest buildPollRequest( + GetNexusOperationResultInput input) { + PollNexusOperationExecutionRequest.Builder request = + PollNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setOperationId(input.getOperationId()) + // Poll always waits for the operation to reach a terminal state; intermediate stages + // are not exposed through the interceptor surface. + .setWaitStage(NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED); + input.getRunId().ifPresent(request::setRunId); + return request.build(); + } + + private GetNexusOperationResultOutput extractResult( + String operationId, + @Nullable String runId, + PollNexusOperationExecutionResponse response, + GetNexusOperationResultInput input) { + if (response.hasFailure()) { + Failure failure = response.getFailure(); + throw new NexusOperationFailedException( + "Nexus operation failed: operationId='" + operationId + "'", + operationId, + runId, + clientOptions.getDataConverter().failureToException(failure)); + } + if (!response.hasResult()) { + throw new NexusOperationFailedException( + "Nexus operation '" + + operationId + + "' is closed but the poll response carried neither a result nor a failure", + operationId, + runId, + new IllegalStateException( + "malformed PollNexusOperationExecutionResponse: outcome oneof is not set")); + } + Payload payload = response.getResult(); + R deserialized = + clientOptions + .getDataConverter() + .fromPayload( + payload, + input.getResultClass(), + input.getResultType() != null ? input.getResultType() : input.getResultClass()); + return new GetNexusOperationResultOutput<>(deserialized); + } + + @Override + public ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input) { + + ListNexusOperationExecutionIterator iterator = + new ListNexusOperationExecutionIterator( + input.getQuery().orElse(null), clientOptions.getNamespace(), genericClient); + iterator.init(); + Iterator wrappedIterator = + Iterators.transform(iterator, NexusOperationExecutionMetadata::fromListInfo); + + final int characteristics = Spliterator.ORDERED | Spliterator.NONNULL | Spliterator.IMMUTABLE; + return new ListNexusOperationExecutionsOutput( + StreamSupport.stream( + Spliterators.spliteratorUnknownSize(wrappedIterator, characteristics), false)); + } + + @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 NexusOperationExecutionCount.AggregationGroup(g.getCount(), g.getGroupValuesList())); + } + return new CountNexusOperationExecutionsOutput( + new NexusOperationExecutionCount(response.getCount(), groups)); + } + + @Override + public RequestCancelNexusOperationExecutionOutput requestCancelNexusOperationExecution( + RequestCancelNexusOperationExecutionInput input) { + RequestCancelNexusOperationExecutionRequest.Builder request = + RequestCancelNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setOperationId(input.getOperationId()); + input.getRunId().ifPresent(request::setRunId); + input.getReason().ifPresent(request::setReason); + try { + genericClient.requestCancelNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } + return new RequestCancelNexusOperationExecutionOutput(); + } + + @Override + public TerminateNexusOperationExecutionOutput terminateNexusOperationExecution( + TerminateNexusOperationExecutionInput input) { + TerminateNexusOperationExecutionRequest.Builder request = + TerminateNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setOperationId(input.getOperationId()); + input.getRunId().ifPresent(request::setRunId); + input.getReason().ifPresent(request::setReason); + try { + genericClient.terminateNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } + return new TerminateNexusOperationExecutionOutput(); + } + + @Override + public DeleteNexusOperationExecutionOutput deleteNexusOperationExecution( + DeleteNexusOperationExecutionInput input) { + DeleteNexusOperationExecutionRequest.Builder request = + DeleteNexusOperationExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setOperationId(input.getOperationId()); + input.getRunId().ifPresent(request::setRunId); + try { + genericClient.deleteNexusOperationExecution(request.build()); + } catch (StatusRuntimeException e) { + throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); + } + return new DeleteNexusOperationExecutionOutput(); + } + + /** + * Maps a {@link StatusRuntimeException} with {@code NOT_FOUND} status to a typed {@link + * NexusOperationNotFoundException}; otherwise returns the original exception unchanged so the + * caller can rethrow. + */ + private static RuntimeException mapNotFound( + String operationId, @Nullable String runId, StatusRuntimeException e) { + if (e.getStatus().getCode() == Status.Code.NOT_FOUND) { + return new NexusOperationNotFoundException(operationId, runId, e); + } + return e; + } +} 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 317c2300b9..a81fa253a0 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,33 @@ CompletableFuture listWorkflowExecutionsAsync( DescribeWorkflowExecutionResponse describeWorkflowExecution( DescribeWorkflowExecutionRequest request); + StartNexusOperationExecutionResponse startNexusOperationExecution( + @Nonnull StartNexusOperationExecutionRequest request); + + DescribeNexusOperationExecutionResponse describeNexusOperationExecution( + @Nonnull DescribeNexusOperationExecutionRequest request); + + PollNexusOperationExecutionResponse pollNexusOperationExecution( + @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + + CompletableFuture pollNexusOperationExecutionAsync( + @Nonnull PollNexusOperationExecutionRequest request, @Nonnull Deadline deadline); + + CompletableFuture listNexusOperationExecutionsAsync( + @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 58ad1e8f12..f74d1b6e37 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,122 @@ public DescribeWorkflowExecutionResponse describeWorkflowExecution( grpcRetryerOptions); } + @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) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .describeNexusOperationExecution(request), + grpcRetryerOptions); + } + + @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 CompletableFuture listNexusOperationExecutionsAsync( + @Nonnull ListNexusOperationExecutionsRequest request) { + return grpcRetryer.retryWithResultAsync( + asyncThrottlerExecutor, + () -> + toCompletableFuture( + service + .futureStub() + .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); + } + private static CompletableFuture toCompletableFuture( ListenableFuture listenableFuture) { CompletableFuture result = new CompletableFuture<>(); diff --git a/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java new file mode 100644 index 0000000000..3eb2475475 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java @@ -0,0 +1,46 @@ +package io.temporal.client; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.mock; + +import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.NexusClientInterceptor; +import java.util.Collections; +import org.junit.Test; + +public class NexusClientOptionsTest { + + @Test + public void testDefaultNamespaceIsDefault() { + NexusClientOptions opts = NexusClientOptions.newBuilder().build(); + assertEquals("default", opts.getNamespace()); + } + + @Test + public void testDefaultIdentityIsNotNull() { + NexusClientOptions opts = NexusClientOptions.newBuilder().build(); + assertNotNull(opts.getIdentity()); + assertFalse(opts.getIdentity().isEmpty()); + } + + @Test + public void testNewBuilderFromOptionsCopiesAllFields() { + NexusClientInterceptor interceptor = mock(NexusClientInterceptor.class); + DataConverter dc = mock(DataConverter.class); + + NexusClientOptions original = + NexusClientOptions.newBuilder() + .setNamespace("ns") + .setIdentity("id") + .setDataConverter(dc) + .setInterceptors(Collections.singletonList(interceptor)) + .build(); + + NexusClientOptions copy = NexusClientOptions.newBuilder(original).build(); + + assertEquals(original.getNamespace(), copy.getNamespace()); + assertEquals(original.getIdentity(), copy.getIdentity()); + assertSame(original.getDataConverter(), copy.getDataConverter()); + assertEquals(original.getInterceptors(), copy.getInterceptors()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java new file mode 100644 index 0000000000..b838b8e1c4 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/StartNexusOperationOptionsTest.java @@ -0,0 +1,66 @@ +package io.temporal.client; + +import org.junit.Assert; +import org.junit.Test; + +/** + * Pure unit tests for {@link StartNexusOperationOptions.Builder} input validation. ID is required — + * callers must supply a non-blank value via {@link StartNexusOperationOptions.Builder#setId} and + * the SDK does not invent one on their behalf. + */ +public class StartNexusOperationOptionsTest { + + @Test + public void buildThrowsWhenIdNotSet() { + try { + StartNexusOperationOptions.newBuilder().build(); + Assert.fail("expected IllegalStateException when id is unset"); + } catch (IllegalStateException expected) { + Assert.assertTrue( + "error message should mention setId, got: " + expected.getMessage(), + expected.getMessage() != null && expected.getMessage().contains("setId")); + } + } + + @Test + public void setIdRejectsNull() { + try { + StartNexusOperationOptions.newBuilder().setId(null); + Assert.fail("expected NullPointerException when setId is called with null"); + } catch (NullPointerException expected) { + // expected + } + } + + @Test + public void setIdRejectsEmpty() { + try { + StartNexusOperationOptions.newBuilder().setId(""); + Assert.fail("expected IllegalArgumentException when setId is called with an empty string"); + } catch (IllegalArgumentException expected) { + Assert.assertTrue( + "error message should mention blank, got: " + expected.getMessage(), + expected.getMessage() != null && expected.getMessage().contains("blank")); + } + } + + @Test + public void setIdRejectsWhitespaceOnly() { + try { + StartNexusOperationOptions.newBuilder().setId(" \t "); + Assert.fail( + "expected IllegalArgumentException when setId is called with a whitespace-only id"); + } catch (IllegalArgumentException expected) { + Assert.assertTrue( + "error message should mention blank, got: " + expected.getMessage(), + expected.getMessage() != null && expected.getMessage().contains("blank")); + } + } + + @Test + public void buildSucceedsWithNonEmptyId() { + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder().setId("my-id").build(); + Assert.assertEquals("my-id", options.getId()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java new file mode 100644 index 0000000000..c778091b4c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusAsyncApiTest.java @@ -0,0 +1,232 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.client.NexusOperationHandle; +import io.temporal.client.NexusServiceClient; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Coverage tests for the {@link CompletableFuture}-returning surface on the standalone Nexus + * client: {@link NexusServiceClient#executeAsync executeAsync} on the typed service client, plus + * the {@code getResultAsync} overloads on both {@link NexusOperationHandle} and {@link + * UntypedNexusOperationHandle}. Each overload is asserted against the existing sync echo handler so + * the Java async API is exercised without depending on server-side async completion. + */ +public class NexusAsyncApiTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + } + + // --- NexusServiceClient.executeAsync --- + + @Test + public void serviceClientExecuteAsyncReturnsResult() throws Exception { + String result = + buildServiceClient() + .executeAsync( + TestNexusServices.TestNexusService1::operation, newOptionsWithId(), "hello") + .get(); + + Assert.assertEquals("echo:hello", result); + } + + @Test + public void serviceClientExecuteAsyncWithOptionsReturnsResult() throws Exception { + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + + String result = + buildServiceClient() + .executeAsync(TestNexusServices.TestNexusService1::operation, options, "world") + .get(); + + Assert.assertEquals("echo:world", result); + } + + // --- NexusOperationHandle (typed) getResultAsync overloads --- + + @Test + public void typedHandleGetResultAsyncReturnsResult() throws Exception { + NexusOperationHandle handle = + buildServiceClient() + .start(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), "typed"); + + String result = handle.getResultAsync().get(); + + Assert.assertEquals("echo:typed", result); + } + + @Test + public void typedHandleGetResultAsyncWithTimeoutReturnsResult() throws Exception { + NexusOperationHandle handle = + buildServiceClient() + .start(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), "typed-tm"); + + // The 60s argument here exists to satisfy the API signature being exercised; the test rule's + // global timeout will fail the test long before this value matters. + String result = handle.getResultAsync(60, TimeUnit.SECONDS).get(); + + Assert.assertEquals("echo:typed-tm", result); + } + + // --- UntypedNexusOperationHandle getResultAsync overloads --- + + @Test + public void untypedHandleGetResultAsyncByClassReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped"); + + String result = handle.getResultAsync(String.class).get(); + + Assert.assertEquals("echo:untyped", result); + } + + @Test + public void untypedHandleGetResultAsyncByClassAndTypeReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped-gen"); + + String result = handle.getResultAsync(String.class, String.class).get(); + + Assert.assertEquals("echo:untyped-gen", result); + } + + @Test + public void untypedHandleGetResultAsyncWithTimeoutByClassReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped-tm"); + + String result = handle.getResultAsync(60, TimeUnit.SECONDS, String.class).get(); + + Assert.assertEquals("echo:untyped-tm", result); + } + + @Test + public void untypedHandleGetResultAsyncWithTimeoutByClassAndTypeReturnsResult() throws Exception { + UntypedNexusOperationHandle handle = startUntyped("untyped-tm-gen"); + + String result = handle.getResultAsync(60, TimeUnit.SECONDS, String.class, String.class).get(); + + Assert.assertEquals("echo:untyped-tm-gen", result); + } + + // --- Failure path --- + + @Test + public void executeAsyncPropagatesOperationFailure() throws Exception { + CompletableFuture future = + buildServiceClient() + .executeAsync( + TestNexusServices.TestNexusService1::operation, + newOptionsWithId(), + EchoNexusServiceImpl.FAIL_PREFIX + "boom"); + + try { + future.get(); + Assert.fail("expected future to complete exceptionally"); + } catch (ExecutionException e) { + // The JDK wraps the underlying exception in ExecutionException — that's expected. The SDK + // MUST NOT introduce any further layer between this wrapper and the + // NexusOperationFailedException; the chain below ExecutionException must be identical to the + // synchronous getResult() path so CompletableFuture handling doesn't smuggle extra wrappers. + Throwable cause = e.getCause(); + Assert.assertNotNull("expected ExecutionException to wrap a cause", cause); + Assert.assertTrue( + "expected NexusOperationFailedException directly under ExecutionException, got " + + cause.getClass().getSimpleName(), + cause instanceof NexusOperationFailedException); + NexusOperationFailedException nexusFailure = (NexusOperationFailedException) cause; + Assert.assertNotNull(nexusFailure.getOperationId()); + Assert.assertTrue( + "expected outer message to reference the operation ID, got: " + nexusFailure.getMessage(), + nexusFailure.getMessage() != null + && nexusFailure.getMessage().contains(nexusFailure.getOperationId())); + + // Inner cause: ApplicationFailure with the handler's exact failure text and type + // "OperationError" — same shape as the sync getResult() path. The async path must not + // alter the cause chain. + Throwable inner = nexusFailure.getCause(); + Assert.assertNotNull("expected NexusOperationFailedException to wrap an inner cause", inner); + Assert.assertTrue( + "expected inner cause to be ApplicationFailure, got " + inner.getClass().getSimpleName(), + inner instanceof ApplicationFailure); + ApplicationFailure appFailure = (ApplicationFailure) inner; + Assert.assertEquals("OperationError", appFailure.getType()); + Assert.assertEquals("intentional failure: FAIL:boom", appFailure.getOriginalMessage()); + Assert.assertFalse( + "OperationException.failed(...) currently translates to a retryable ApplicationFailure", + appFailure.isNonRetryable()); + Assert.assertNull( + "expected no further nested cause for a bare OperationException.failed(msg)", + appFailure.getCause()); + } + } + + // --- helpers --- + + private NexusServiceClient buildServiceClient() { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + NexusClient nexusClient = + NexusClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + return nexusClient.newNexusServiceClient( + TestNexusServices.TestNexusService1.class, endpoint.getSpec().getName()); + } + + private UntypedNexusOperationHandle startUntyped(String input) { + NexusClient client = testWorkflowRule.getNexusClient(); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + return svcClient.start("operation", newOptionsWithId(), input); + } + + /** Builds a minimal {@link StartNexusOperationOptions} with a unique id. */ + private static StartNexusOperationOptions newOptionsWithId() { + return StartNexusOperationOptions.newBuilder().setId(UUID.randomUUID().toString()).build(); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } +} 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 0000000000..e786c68f4a --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientInterceptorChainTest.java @@ -0,0 +1,115 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientImpl; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.common.interceptors.NexusClientCallsInterceptorBase; +import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestWorkflows; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.Assert; +import org.junit.Before; +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).build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + } + + @Test + public void registeredInterceptorsAreCalledInOrder() { + List calls = Collections.synchronizedList(new ArrayList<>()); + NexusClientInterceptor first = next -> new RecordingCallsInterceptor("first", next, calls); + NexusClientInterceptor second = next -> new RecordingCallsInterceptor("second", next, calls); + + NexusClient client = + NexusClientImpl.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setInterceptors(Arrays.asList(first, second)) + .build()); + + // Stream is lazy; consume it to force a single page fetch through the interceptor chain. + long ignoredListCount = client.listNexusOperationExecutions(null).count(); + NexusOperationExecutionCount ignoredCount = client.countNexusOperationExecutions(null); + Assert.assertNotNull(ignoredCount); + Assert.assertTrue(ignoredListCount >= 0); + + // [first, second] -> second wraps first wraps root. + // A call enters second, descends to first, then root, returns through first then second. + Assert.assertEquals( + Arrays.asList( + "second:list:before", + "first:list:before", + "first:list:after", + "second:list:after", + "second:count:before", + "first:count:before", + "first:count:after", + "second:count:after"), + calls); + } + + static class RecordingCallsInterceptor extends NexusClientCallsInterceptorBase { + private final String name; + private final List calls; + + RecordingCallsInterceptor(String name, NexusClientCallsInterceptor next, List calls) { + super(next); + this.name = name; + this.calls = calls; + } + + @Override + public ListNexusOperationExecutionsOutput listNexusOperationExecutions( + ListNexusOperationExecutionsInput input) { + calls.add(name + ":list:before"); + try { + return super.listNexusOperationExecutions(input); + } finally { + calls.add(name + ":list:after"); + } + } + + @Override + public CountNexusOperationExecutionsOutput countNexusOperationExecutions( + CountNexusOperationExecutionsInput input) { + calls.add(name + ":count:before"); + try { + return super.countNexusOperationExecutions(input); + } finally { + calls.add(name + ":count:after"); + } + } + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java new file mode 100644 index 0000000000..e5f3b36f3e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -0,0 +1,250 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.client.NexusOperationExecutionMetadata; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class NexusClientTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(NexusClientTest.PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + } + + @Test + public void listNexusOperationExecutions() { + // Just run a basic test to see if it works + // runStandaloneNexusOperation tests this more thoroughly + NexusClient client = testWorkflowRule.getNexusClient(); + + // Materialize the lazy stream to force at least one page fetch and ensure no exceptions. + 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 + // runStandaloneNexusOperation tests this more thoroughly + countNexusOperations(); + } + + // A helper function to get the count and do a few validation tests around it + public long countNexusOperations() { + NexusClient client = testWorkflowRule.getNexusClient(); + + 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 { + long initialCount = countNexusOperations(); + + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + String inputValue = "ping-" + UUID.randomUUID(); + NexusClient client = testWorkflowRule.getNexusClient(); + + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); + String operationId = handle.getNexusOperationId(); + + // Block on the handle until the operation completes; the echoed result implies the + // handler received our input. + String result = handle.getResult(60, TimeUnit.SECONDS, String.class); + Assert.assertEquals("echo:" + inputValue, result); + + // Poll the list until our operationId appears. This also tests that the list operation + // works correctly. + 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()); + // Make sure the count went up. + Assert.assertTrue(countNexusOperations() > initialCount); + } + + @Test + public void listNexusOperationExecutionsWithQueryFiltersResults() throws Exception { + // Run a known operation through to completion, then assert that an OperationId-scoped query + // narrows the list to exactly that one row. Uses a built-in visibility field (OperationId), so + // the async search-attribute registration race that affects custom SAs doesn't apply. + String operationId = startAndAwaitSyncOperation("list-query"); + NexusClient client = testWorkflowRule.getNexusClient(); + + // Sync on the unfiltered list first so the visibility index has indexed our operation; the + // filtered query reads from the same index. + Assert.assertNotNull( + "expected operation to appear in visibility before filtered query", + waitForListedOperation(client, operationId, Duration.ofSeconds(15))); + + String query = "OperationId='" + operationId + "'"; + List results = + client.listNexusOperationExecutions(query).collect(Collectors.toList()); + + // OperationId is unique server-side, so the filter must produce exactly one row — proving the + // query string actually narrowed results rather than being a no-op passthrough. + Assert.assertEquals("expected exactly one match for query: " + query, 1, results.size()); + Assert.assertEquals(operationId, results.get(0).getOperationId()); + } + + @Test + public void countNexusOperationExecutionsWithQueryFiltersResults() throws Exception { + String operationId = startAndAwaitSyncOperation("count-query"); + NexusClient client = testWorkflowRule.getNexusClient(); + + Assert.assertNotNull( + "expected operation to appear in visibility before filtered count", + waitForListedOperation(client, operationId, Duration.ofSeconds(15))); + + String query = "OperationId='" + operationId + "'"; + NexusOperationExecutionCount count = client.countNexusOperationExecutions(query); + + Assert.assertEquals("expected exactly one match for query: " + query, 1L, count.getCount()); + } + + /** + * Starts a sync echo operation with a unique input, blocks until it completes, and returns the + * operation ID. Used by the filtered list/count tests to obtain a known operation to query for. + */ + private String startAndAwaitSyncOperation(String label) throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusOperationHandle handle = + svcClient.start("operation", opts, label + "-" + UUID.randomUUID()); + handle.getResult(60, TimeUnit.SECONDS, String.class); + return handle.getNexusOperationId(); + } + + @Test + public void untypedExecuteByClassReturnsResult() { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + + String result = + svcClient.execute( + "operation", + String.class, + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "untyped-exec"); + + Assert.assertEquals("echo:untyped-exec", result); + } + + @Test + public void untypedExecuteByClassAndTypeReturnsResult() { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svcClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + + // The Type overload exists for generic results (e.g. List); exercising it with the same + // class/type here proves the path is wired through to the data converter. + String result = + svcClient.execute( + "operation", + String.class, + String.class, + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "untyped-exec-typed"); + + Assert.assertEquals("echo:untyped-exec-typed", result); + } + + private NexusOperationExecutionMetadata waitForListedOperation( + NexusClient client, String operationId, Duration timeout) throws InterruptedException { + long deadlineNanos = System.nanoTime() + timeout.toNanos(); + 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; + } + + 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/NexusOperationHandleTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java new file mode 100644 index 0000000000..11d76f0b1e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusOperationHandleTest.java @@ -0,0 +1,356 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.temporal.api.enums.v1.NexusOperationExecutionStatus; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusOperationException; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.client.NexusOperationHandle; +import io.temporal.client.NexusOperationNotFoundException; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests for {@link UntypedNexusOperationHandle} per-execution lifecycle methods returned by {@link + * NexusClient#getHandle(String, String)}: {@code describe()}, {@code cancel()}/{@code + * cancel(reason)}, and {@code terminate()}/{@code terminate(reason)}. + */ +public class NexusOperationHandleTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + } + + @Test + public void describeReturnsDescriptionForStartedOperation() { + UntypedNexusOperationHandle handle = startOperation(); + + NexusOperationExecutionDescription description = handle.describe(); + + Assert.assertNotNull(description); + Assert.assertNotNull(description.getRunId()); + Assert.assertEquals(handle.getNexusOperationRunId(), description.getRunId()); + Assert.assertNotNull(description.getRawResponse()); + } + + @Test + public void describeReturnsTerminalStateAfterSyncOperationCompletes() { + // Drive a sync echo through to completion, then assert describe surfaces the terminal state. + UntypedNexusOperationHandle handle = startOperation(); + String expected = handle.getResult(String.class); + + NexusOperationExecutionDescription description = handle.describe(); + + Assert.assertEquals( + NexusOperationExecutionStatus.NEXUS_OPERATION_EXECUTION_STATUS_COMPLETED, + description.getStatus()); + Assert.assertNotNull("expected closeTime once terminal", description.getCloseTime()); + Assert.assertNotNull( + "expected executionDuration once terminal", description.getExecutionDuration()); + // describe() defaults to includeOutcome=true, so the success payload should be present. + Assert.assertTrue( + "expected description.hasResult() after a successful sync operation", + description.hasResult()); + Assert.assertEquals(expected, description.getResult(String.class).orElse(null)); + Assert.assertNull("expected no failure on a successful operation", description.getFailure()); + } + + @Test + public void describeThrowsForUnknownOperationId() { + // Mint an operation ID that the server has never seen; describe must surface the typed + // NOT_FOUND-mapped exception rather than a raw gRPC status. + String bogusOperationId = "does-not-exist-" + UUID.randomUUID(); + UntypedNexusOperationHandle handle = + testWorkflowRule.getNexusClient().getHandle(bogusOperationId, null); + + try { + handle.describe(); + Assert.fail("expected NexusOperationNotFoundException for an unknown operation ID"); + } catch (NexusOperationNotFoundException expected) { + Assert.assertEquals(bogusOperationId, expected.getOperationId()); + } + } + + @Test + public void describeWithoutRunIdTargetsLatest() { + UntypedNexusOperationHandle started = startOperation(); + // Re-bind a handle with no pinned run ID — server should resolve to the latest run. + UntypedNexusOperationHandle handle = + testWorkflowRule.getNexusClient().getHandle(started.getNexusOperationId(), null); + + NexusOperationExecutionDescription description = handle.describe(); + + Assert.assertNotNull(description); + Assert.assertEquals(started.getNexusOperationRunId(), description.getRunId()); + } + + // The cancel call just requests the handler to cancel. + // It doesn't automatically cancel. So we are testing not that it + // cancelled the operation, but checking the number of cancel + // invokations the test server received to make sure it increments. + @Test + public void cancelSucceedsForStartedOperation() { + int before = EchoNexusServiceImpl.cancelInvocations.get(); + startPendingOperation().cancel(); + assertCancelDelivered(before); + } + + @Test + public void cancelWithReasonSucceedsForStartedOperation() { + int before = EchoNexusServiceImpl.cancelInvocations.get(); + startPendingOperation().cancel("test-cancel-reason"); + assertCancelDelivered(before); + } + + @Test + public void cancelWithNullReasonSucceeds() { + int before = EchoNexusServiceImpl.cancelInvocations.get(); + startPendingOperation().cancel(null); + assertCancelDelivered(before); + } + + @Test + public void getResultWithTimeoutFiresWhenOperationStaysPending() { + // Start an async-pending operation that never completes on its own; the client-side + // getResult(timeout, unit) overload must surface TimeoutException once the local budget + // expires. + UntypedNexusOperationHandle handle = startPendingOperation(); + try { + handle.getResult(1, java.util.concurrent.TimeUnit.SECONDS, String.class); + Assert.fail("expected TimeoutException when getResult's client-side budget expires"); + } catch (java.util.concurrent.TimeoutException expected) { + // expected — terminate the operation so it doesn't outlive the test + handle.terminate("cleanup-after-timeout-test"); + } + } + + @Test + public void getResultAsyncWithTimeoutFiresWhenOperationStaysPending() { + // Mirror of the sync test for the CompletableFuture surface: the returned future must + // complete exceptionally with TimeoutException once the supplied timeout expires. + UntypedNexusOperationHandle handle = startPendingOperation(); + java.util.concurrent.CompletableFuture future = + handle.getResultAsync(1, java.util.concurrent.TimeUnit.SECONDS, String.class); + try { + future.get(); + Assert.fail("expected getResultAsync future to complete exceptionally with TimeoutException"); + } catch (java.util.concurrent.ExecutionException e) { + Assert.assertTrue( + "expected TimeoutException, got " + + (e.getCause() == null ? "null" : e.getCause().getClass().getSimpleName()), + e.getCause() instanceof java.util.concurrent.TimeoutException); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } finally { + handle.terminate("cleanup-after-timeout-test"); + } + } + + /** + * Polls the handler's invocation counter to confirm the cancel RPC reached the worker and the + * handler's {@code cancel(...)} callback ran (the dispatch is asynchronous — server schedules a + * cancel task, worker polls it, then the callback fires). + * + *

The 8-second budget sits just under the rule's default {@code DEFAULT_TEST_TIMEOUT_SECONDS = + * 10}, so a missed delivery fails with the descriptive message below rather than the rule's + * generic JUnit timeout. + */ + private static void assertCancelDelivered(int countBeforeCancel) { + long deadlineNanos = System.nanoTime() + Duration.ofSeconds(8).toNanos(); + while (EchoNexusServiceImpl.cancelInvocations.get() <= countBeforeCancel + && System.nanoTime() < deadlineNanos) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + Assert.assertTrue( + "cancel RPC was not delivered to the handler within the poll budget", + EchoNexusServiceImpl.cancelInvocations.get() > countBeforeCancel); + } + + @Test + public void terminateSucceedsForStartedOperation() { + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate(); + assertTerminalFailure(handle); + } + + @Test + public void terminateTransitionsOperationToTerminatedStatus() { + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate("status-assertion"); + // assertTerminalFailure proves getResult observed terminality; describe must agree that the + // server-side status was specifically TERMINATED (not CANCELED/FAILED/TIMED_OUT). + assertTerminalFailure(handle); + NexusOperationExecutionDescription description = handle.describe(); + Assert.assertEquals( + NexusOperationExecutionStatus.NEXUS_OPERATION_EXECUTION_STATUS_TERMINATED, + description.getStatus()); + Assert.assertNotNull(description.getCloseTime()); + } + + @Test + public void terminateWithReasonSucceedsForStartedOperation() { + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate("test-terminate-reason"); + assertTerminalFailure(handle); + } + + @Test + public void terminateWithNullReasonSucceeds() { + UntypedNexusOperationHandle handle = startPendingOperation(); + handle.terminate(null); + assertTerminalFailure(handle); + } + + /** + * Starts an operation whose handler returns an async-started result without ever completing, so + * the lifecycle RPCs have a non-terminal operation to act on. + */ + private UntypedNexusOperationHandle startPendingOperation() { + return startOperation(EchoNexusServiceImpl.ASYNC_PREFIX + UUID.randomUUID()); + } + + /** + * Terminate is forceful and immediate per the proto contract; the server transitions the + * operation to TERMINATED regardless of handler state, so {@code getResult} promptly throws + * {@link NexusOperationFailedException}. Uses the no-timeout {@code getResult(Class)} overload; + * the rule's global test timeout caps how long we wait. + */ + private static void assertTerminalFailure(UntypedNexusOperationHandle handle) { + try { + handle.getResult(String.class); + Assert.fail("expected getResult to throw after the operation was terminated"); + } catch (NexusOperationFailedException expected) { + // The TerminatedFailure shows up either on this exception's message or via getCause(). + } + } + + @Test + public void getResultReturnsTypedResultForSyncOperation() { + String result = NexusOperationHandle.fromUntyped(startOperation(), String.class).getResult(); + + Assert.assertNotNull(result); + Assert.assertTrue("expected echo: prefix, got: " + result, result.startsWith("echo:ping-")); + } + + @Test + public void getResultUntypedReturnsResultForSyncOperation() { + String result = startOperation().getResult(String.class); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); + } + + @Test + public void getResultAsyncReturnsTypedResultForSyncOperation() throws Exception { + String result = + NexusOperationHandle.fromUntyped(startOperation(), String.class) + .getResultAsync() + .get(60, java.util.concurrent.TimeUnit.SECONDS); + + Assert.assertNotNull(result); + Assert.assertTrue(result.startsWith("echo:ping-")); + } + + private UntypedNexusOperationHandle startOperation() { + return startOperation(null); + } + + private UntypedNexusOperationHandle startOperation( + @javax.annotation.Nullable String inputOverride) { + NexusClient client = testWorkflowRule.getNexusClient(); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + String inputValue = + inputOverride != null ? inputOverride : "ping-handle-test-" + UUID.randomUUID(); + + UntypedNexusServiceClient svcClient = + client.newUntypedNexusServiceClient( + endpoint.getSpec().getName(), + TestNexusServices.TestNexusService1.class.getSimpleName()); + StartNexusOperationOptions opts = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + UntypedNexusOperationHandle handle = svcClient.start("operation", opts, inputValue); + + Assert.assertNotNull("expected start to return a run ID", handle.getNexusOperationRunId()); + return handle; + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + @Test + public void getResultPropagatesOperationFailure() { + UntypedNexusOperationHandle handle = startOperation(EchoNexusServiceImpl.FAIL_PREFIX + "boom"); + String operationId = handle.getNexusOperationId(); + + try { + handle.getResult(String.class); + Assert.fail("expected getResult to throw because the operation handler failed"); + } catch (NexusOperationException e) { + // Outer: NexusOperationFailedException carrying the failed operation's ID. + Assert.assertTrue( + "expected NexusOperationFailedException, got " + e.getClass().getSimpleName(), + e instanceof NexusOperationFailedException); + Assert.assertEquals(operationId, e.getOperationId()); + Assert.assertTrue( + "expected outer message to reference the operation ID, got: " + e.getMessage(), + e.getMessage() != null && e.getMessage().contains(operationId)); + + // Cause: ApplicationFailure produced by NexusTaskHandlerImpl when it converts the + // handler-thrown OperationException.failed(...) into a TemporalFailure + // (ApplicationFailure.newFailureWithCause(message, "OperationError", null)). Lock down the + // exact shape so any drift in that conversion surfaces here. + Throwable cause = e.getCause(); + Assert.assertNotNull("expected NexusOperationFailedException to wrap a cause", cause); + Assert.assertTrue( + "expected cause to be ApplicationFailure, got " + cause.getClass().getSimpleName(), + cause instanceof ApplicationFailure); + ApplicationFailure appFailure = (ApplicationFailure) cause; + Assert.assertEquals("OperationError", appFailure.getType()); + Assert.assertEquals("intentional failure: FAIL:boom", appFailure.getOriginalMessage()); + Assert.assertFalse( + "OperationException.failed(...) currently translates to a retryable ApplicationFailure", + appFailure.isNonRetryable()); + Assert.assertNull( + "expected no further nested cause for a bare OperationException.failed(msg)", + appFailure.getCause()); + } + } +} 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 0000000000..395a0be2bf --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusServiceClientTest.java @@ -0,0 +1,259 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationHandle; +import io.temporal.client.NexusServiceClient; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * End-to-end tests for {@link NexusServiceClient}: typed start/execute via {@link + * io.temporal.workflow.Functions.Func2} method references. + */ +public class NexusServiceClientTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation( + new EchoNexusServiceImpl(), + new VoidInputServiceImpl(), + new VoidReturnServiceImpl(), + new VoidServiceImpl()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + } + + @Test + public void executeReturnsTypedResult() { + NexusServiceClient client = + buildServiceClient(testWorkflowRule.getNexusEndpoint()); + + String result = + client.execute(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), "hello"); + + Assert.assertEquals("echo:hello", result); + } + + @Test + public void startReturnsTypedHandleAndPollsResult() { + NexusServiceClient client = + buildServiceClient(testWorkflowRule.getNexusEndpoint()); + + NexusOperationHandle handle = + client.start(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), "world"); + + Assert.assertNotNull(handle.getNexusOperationId()); + Assert.assertEquals("echo:world", handle.getResult()); + } + + @Test + public void executeWithOptionsReturnsResult() { + // Covers the 3-arg execute(op, options, input) overload — exercises a non-default + // scheduleToCloseTimeout in addition to the required id. + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + + String result = + buildServiceClient(testWorkflowRule.getNexusEndpoint()) + .execute(TestNexusServices.TestNexusService1::operation, options, "with-opts"); + + Assert.assertEquals("echo:with-opts", result); + } + + @Test + public void startWithExplicitIdHonoursId() { + String explicitId = "explicit-id-" + UUID.randomUUID(); + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setId(explicitId) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + + NexusOperationHandle handle = + buildServiceClient(testWorkflowRule.getNexusEndpoint()) + .start(TestNexusServices.TestNexusService1::operation, options, "id-test"); + + Assert.assertEquals( + "explicit ID supplied via StartNexusOperationOptions.setId must round-trip on the handle", + explicitId, + handle.getNexusOperationId()); + // Sanity-check: the operation still completes normally with the explicit ID. + Assert.assertEquals("echo:id-test", handle.getResult()); + } + + @Test + public void clientSummaryReachesServer() { + NexusServiceClient client = + buildServiceClient(testWorkflowRule.getNexusEndpoint()); + + StartNexusOperationOptions startOptions = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setSummary("per-call-summary") + .build(); + NexusOperationHandle handle = + client.start(TestNexusServices.TestNexusService1::operation, startOptions, "world"); + + // Describe round-trips the operation through the server, proving the summary was actually + // persisted on the server-side record rather than just forwarded through the local interceptor + // chain. + UntypedNexusOperationHandle untyped = + testWorkflowRule.getNexusClient().getHandle(handle.getNexusOperationId(), null); + NexusOperationExecutionDescription description = untyped.describe(); + Assert.assertEquals("per-call-summary", description.getStaticSummary()); + } + + // ── No-input / void-return shape coverage ──────────────────────────────────────────────── + + @Test + public void executeWithNoInputReturnsResult() { + // Exercises the Func1 overload — Nexus operations declared as `R operation()` with + // no input parameter. + NexusServiceClient client = + buildServiceClientFor(TestNexusServices.TestNexusServiceVoidInput.class); + + String result = + client.execute(TestNexusServices.TestNexusServiceVoidInput::operation, newOptionsWithId()); + + Assert.assertEquals("void-input-result", result); + } + + @Test + public void executeWithVoidReturnCompletes() { + // Exercises the existing Func2 path — Nexus operations declared as + // `Void operation(input)`. No new overload needed; the SDK already handles Void as a result + // type. This test pins that contract. + // + // Because the result is `null`, asserting only that the return value is null is too weak — + // an operation that never ran would also produce null. The handler bumps a counter so we + // know it actually executed. + NexusServiceClient client = + buildServiceClientFor(TestNexusServices.TestNexusServiceVoidReturn.class); + int before = VoidReturnServiceImpl.invocations.get(); + + Void result = + client.execute( + TestNexusServices.TestNexusServiceVoidReturn::operation, newOptionsWithId(), "ignored"); + + Assert.assertNull(result); + Assert.assertEquals( + "expected the void-return handler to be invoked exactly once", + before + 1, + VoidReturnServiceImpl.invocations.get()); + } + + @Test + public void executeWithNoInputAndVoidReturnCompletes() { + // Exercises the Func1 overload — Nexus operations declared as `Void operation()` + // with neither input nor a useful return value. + NexusServiceClient client = + buildServiceClientFor(TestNexusServices.TestNexusServiceVoid.class); + + Void result = + client.execute(TestNexusServices.TestNexusServiceVoid::operation, newOptionsWithId()); + + Assert.assertNull(result); + } + + // A search-attribute round-trip via describe() would naturally belong here, but the rule's + // `registerSearchAttribute(...)` is asynchronous on the server side and races the test — + // calling `start(...)` immediately afterwards fails with "no mapping defined for search + // attribute" until the namespace's Visibility index catches up. Reintroduce once the rule + // (or the test) synchronously waits for the mapping to propagate. + + /** Builds a minimal {@link StartNexusOperationOptions} with a unique id. */ + private static StartNexusOperationOptions newOptionsWithId() { + return StartNexusOperationOptions.newBuilder().setId(UUID.randomUUID().toString()).build(); + } + + private NexusServiceClient buildServiceClient( + Endpoint endpoint) { + return buildServiceClientFor(TestNexusServices.TestNexusService1.class); + } + + /** Builds a typed client for an arbitrary Nexus service interface against the rule's endpoint. */ + private NexusServiceClient buildServiceClientFor(Class serviceClass) { + NexusClient nexusClient = + NexusClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + return nexusClient.newNexusServiceClient( + serviceClass, testWorkflowRule.getNexusEndpoint().getSpec().getName()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + /** Handler for the no-input, has-output service {@code TestNexusServiceVoidInput}. */ + @ServiceImpl(service = TestNexusServices.TestNexusServiceVoidInput.class) + public static class VoidInputServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync((ctx, details, input) -> "void-input-result"); + } + } + + /** + * Handler for the has-input, void-return service {@code TestNexusServiceVoidReturn}. Counts + * invocations so {@link #executeWithVoidReturnCompletes} can prove the handler actually ran (a + * null return value alone is ambiguous between "handler ran and returned Void" and "handler never + * ran"). + */ + @ServiceImpl(service = TestNexusServices.TestNexusServiceVoidReturn.class) + public static class VoidReturnServiceImpl { + static final java.util.concurrent.atomic.AtomicInteger invocations = + new java.util.concurrent.atomic.AtomicInteger(); + + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, input) -> { + invocations.incrementAndGet(); + return null; + }); + } + } + + /** Handler for the no-input, void-return service {@code TestNexusServiceVoid}. */ + @ServiceImpl(service = TestNexusServices.TestNexusServiceVoid.class) + public static class VoidServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync((ctx, details, input) -> null); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java new file mode 100644 index 0000000000..ec95b8a47c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java @@ -0,0 +1,141 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.failure.CanceledFailure; +import io.temporal.nexus.Nexus; +import io.temporal.nexus.WorkflowRunOperation; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies that {@link UntypedNexusOperationHandle#cancel()} from a standalone client propagates + * through the server to the handler workflow backing a Nexus operation. Mirrors {@code + * sdk-go/test/nexus_test.go TestNexusWorkflowRunOperation}: start a Nexus operation backed by a + * workflow that awaits forever, cancel via the standalone client handle, then assert the backing + * workflow ends with {@link CanceledFailure}. + */ +public class StandaloneNexusClientCancelTest { + + static final AtomicReference capturedWorkflowId = new AtomicReference<>(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(CancelTargetWorkflowImpl.class) + .setNexusServiceImplementation(new CancelTargetNexusServiceImpl()) + .build(); + + @Before + public void requireStandaloneNexusSupportAndReset() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + capturedWorkflowId.set(null); + } + + @Test + public void cancelPropagatesToBackingWorkflow() throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svc = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), CancelTargetNexusService.class.getSimpleName()); + + UntypedNexusOperationHandle handle = + svc.start( + "operation", + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "ignored"); + + String workflowId = waitForWorkflowIdCaptured(Duration.ofSeconds(8)); + + handle.cancel("standalone-client-cancel-test"); + + WorkflowStub stub = testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(workflowId); + try { + stub.getResult(Void.class); + Assert.fail("expected backing workflow to terminate with cancellation"); + } catch (WorkflowFailedException expected) { + Throwable cause = expected.getCause(); + Assert.assertTrue( + "expected cause to be CanceledFailure, got " + + (cause == null ? "null" : cause.getClass().getSimpleName()), + cause instanceof CanceledFailure); + } + } + + private static String waitForWorkflowIdCaptured(Duration budget) throws InterruptedException { + long deadlineNanos = System.nanoTime() + budget.toNanos(); + while (capturedWorkflowId.get() == null && System.nanoTime() < deadlineNanos) { + Thread.sleep(100); + } + String id = capturedWorkflowId.get(); + Assert.assertNotNull( + "handler workflow did not start (workflowId never captured) within " + budget, id); + return id; + } + + @WorkflowInterface + public interface CancelTargetWorkflow { + @WorkflowMethod + Void execute(String ignored); + } + + public static class CancelTargetWorkflowImpl implements CancelTargetWorkflow { + @Override + public Void execute(String ignored) { + capturedWorkflowId.set(Workflow.getInfo().getWorkflowId()); + Workflow.await(() -> false); + return null; + } + } + + @Service + public interface CancelTargetNexusService { + @Operation + Void operation(String ignored); + } + + @ServiceImpl(service = CancelTargetNexusService.class) + public static class CancelTargetNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return WorkflowRunOperation.fromWorkflowMethod( + (context, details, input) -> + Nexus.getOperationContext() + .getWorkflowClient() + .newWorkflowStub( + CancelTargetWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("cancel-target-" + details.getRequestId()) + .build()) + ::execute); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java new file mode 100644 index 0000000000..679609fd29 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java @@ -0,0 +1,89 @@ +package io.temporal.internal.client; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +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.workflowservice.v1.PollNexusOperationExecutionRequest; +import io.temporal.api.workflowservice.v1.PollNexusOperationExecutionResponse; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.GetNexusOperationResultInput; +import io.temporal.common.interceptors.NexusClientCallsInterceptor.GetNexusOperationResultOutput; +import io.temporal.internal.client.external.GenericWorkflowClient; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.Test; + +/** + * Server-free unit tests for {@link RootNexusClientInvoker} poll-result extraction. The end-to-end + * Nexus suites are gated on an external service; these mock {@link GenericWorkflowClient} so the + * outcome handling (result / failure / null-result / malformed) is covered in standard CI. + */ +public class RootNexusClientInvokerTest { + + private final GenericWorkflowClient genericClient = mock(GenericWorkflowClient.class); + private final RootNexusClientInvoker invoker = + new RootNexusClientInvoker(genericClient, NexusClientOptions.getDefaultInstance()); + + private static GetNexusOperationResultInput input() { + return new GetNexusOperationResultInput<>( + "op-1", null, Deadline.after(10, TimeUnit.SECONDS), String.class, String.class); + } + + private void stubClosedPoll(PollNexusOperationExecutionResponse.Builder response) { + when(genericClient.pollNexusOperationExecution( + any(PollNexusOperationExecutionRequest.class), any(Deadline.class))) + .thenReturn( + response + .setWaitStage(NexusOperationWaitStage.NEXUS_OPERATION_WAIT_STAGE_CLOSED) + .build()); + } + + @Test + public void closedWithResultReturnsDeserializedValue() throws Exception { + Payload payload = GlobalDataConverter.get().toPayload("hello").get(); + stubClosedPoll(PollNexusOperationExecutionResponse.newBuilder().setResult(payload)); + + GetNexusOperationResultOutput out = invoker.getNexusOperationResult(input()); + + Assert.assertEquals("hello", out.getResult()); + } + + @Test + public void closedWithNullResultPayloadReturnsNull() throws Exception { + // A null / Void success arrives as a PRESENT, null-encoded result payload that deserializes to + // null — distinct from the no-outcome malformed case below. + Payload nullPayload = GlobalDataConverter.get().toPayload(null).get(); + stubClosedPoll(PollNexusOperationExecutionResponse.newBuilder().setResult(nullPayload)); + + GetNexusOperationResultOutput out = invoker.getNexusOperationResult(input()); + + Assert.assertNull(out.getResult()); + } + + @Test + public void closedWithFailureThrows() { + stubClosedPoll( + PollNexusOperationExecutionResponse.newBuilder() + .setFailure(Failure.newBuilder().setMessage("boom").build())); + + Assert.assertThrows( + NexusOperationFailedException.class, () -> invoker.getNexusOperationResult(input())); + } + + @Test + public void closedWithNoOutcomeThrows() throws Exception { + // A closed operation must carry either a result or a failure; neither set is a malformed + // response and must surface as an error rather than a silent null. + stubClosedPoll(PollNexusOperationExecutionResponse.newBuilder()); + + Assert.assertThrows( + NexusOperationFailedException.class, () -> invoker.getNexusOperationResult(input())); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java b/temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java new file mode 100644 index 0000000000..7b1961fa0e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/shared/EchoNexusServiceImpl.java @@ -0,0 +1,74 @@ +package io.temporal.workflow.shared; + +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.OperationStartDetails; +import io.nexusrpc.handler.OperationStartResult; +import io.nexusrpc.handler.ServiceImpl; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Shared {@link TestNexusServices.TestNexusService1} implementation used by the standalone Nexus + * client tests. Behaviour is driven entirely by the input string: + * + *

    + *
  • An input starting with {@link #FAIL_PREFIX} causes {@code start} to throw an {@link + * OperationException#failed} so callers see a non-retryable handler failure. + *
  • An input starting with {@link #ASYNC_PREFIX} causes {@code start} to return an + * async-started result with a synthetic operation token; the operation stays in {@code + * RUNNING} until something terminal (cancel that takes effect, terminate, schedule-to-close) + * transitions it. + *
  • Any other input is echoed back as {@code "echo:" + input}. + *
+ * + *

Cancel callbacks increment {@link #cancelInvocations} so tests can assert cancel-RPC delivery + * end-to-end. The counter is process-wide; tests that care should capture a baseline before the + * cancel and assert the post-cancel value is strictly greater. + */ +@ServiceImpl(service = TestNexusServices.TestNexusService1.class) +public class EchoNexusServiceImpl { + + /** Inputs starting with this prefix make {@code start} throw, exercising the failure path. */ + public static final String FAIL_PREFIX = "FAIL:"; + + /** + * Inputs starting with this prefix make {@code start} return an async-started result without ever + * completing the operation. Used by cancel/terminate tests so the operation stays in {@code + * RUNNING} long enough for the lifecycle RPC to be observed. + */ + public static final String ASYNC_PREFIX = "ASYNC:"; + + /** + * Incremented every time the worker invokes the handler's {@code cancel(...)} callback. Tests + * that want to assert end-to-end cancel-RPC delivery (client → server → worker) read the value + * before the cancel, issue the cancel, then poll until this counter exceeds the baseline. + */ + public static final AtomicInteger cancelInvocations = new AtomicInteger(); + + @OperationImpl + public OperationHandler operation() { + return new OperationHandler() { + @Override + public OperationStartResult start( + OperationContext context, OperationStartDetails details, String input) + throws OperationException { + if (input != null && input.startsWith(FAIL_PREFIX)) { + throw OperationException.failed("intentional failure: " + input); + } + if (input != null && input.startsWith(ASYNC_PREFIX)) { + return OperationStartResult.async("token-" + UUID.randomUUID()); + } + return OperationStartResult.sync("echo:" + (input == null ? "" : input)); + } + + @Override + public void cancel(OperationContext context, OperationCancelDetails details) { + cancelInvocations.incrementAndGet(); + } + }; + } +} 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 f28075db6a..ba45f52518 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowMutableStateImpl.java @@ -37,7 +37,6 @@ import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; import io.temporal.api.update.v1.*; import io.temporal.api.workflow.v1.*; -import io.temporal.api.workflow.v1.OnConflictOptions; import io.temporal.api.workflowservice.v1.*; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.failure.ServerFailure; @@ -622,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; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java index e88a7ec3d2..a1de5e0d3f 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java @@ -13,6 +13,8 @@ import io.temporal.api.history.v1.History; import io.temporal.api.history.v1.HistoryEvent; import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowQueryException; @@ -260,6 +262,18 @@ public Endpoint getNexusEndpoint() { return testWorkflowRule.getNexusEndpoint(); } + /** + * Returns a {@link NexusClient} bound to this rule's namespace and service stubs. Use for tests + * that exercise the standalone Nexus client surface. + */ + public NexusClient getNexusClient() { + return NexusClient.newInstance( + getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(getWorkflowClient().getOptions().getNamespace()) + .build()); + } + public Worker getWorker() { return testWorkflowRule.getWorker(); } From 27cfa7dc35b3e2eeec17cdb429d94df7ac2554dd Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Thu, 11 Jun 2026 09:14:06 -0700 Subject: [PATCH 008/107] Add Temporal Nexus Operation Handler (#2842) Add Temporal Nexus Operation Handler --- .../nexus/NexusStartWorkflowHelper.java | 81 +++ ...perationToken.java => OperationToken.java} | 9 +- .../internal/nexus/OperationTokenUtil.java | 40 +- .../nexus/CancelWorkflowRunInput.java | 23 + .../temporal/nexus/TemporalNexusClient.java | 510 ++++++++++++++++++ .../nexus/TemporalNexusClientImpl.java | 268 +++++++++ .../nexus/TemporalOperationCancelContext.java | 88 +++ .../nexus/TemporalOperationHandler.java | 126 +++++ .../nexus/TemporalOperationResult.java | 91 ++++ .../nexus/TemporalOperationStartContext.java | 117 ++++ .../nexus/WorkflowRunOperationImpl.java | 57 +- .../internal/nexus/WorkflowRunTokenTest.java | 38 +- .../nexus/AsyncWorkflowOperationTest.java | 4 +- .../nexus/GenericHandlerCancelTest.java | 137 +++++ .../nexus/GenericHandlerDoubleStartTest.java | 120 +++++ .../nexus/GenericHandlerSyncResultTest.java | 64 +++ .../nexus/GenericHandlerTypedProcTest.java | 134 +++++ .../GenericHandlerTypedStartWorkflowTest.java | 135 +++++ ...enericHandlerUntypedStartWorkflowTest.java | 77 +++ 19 files changed, 2032 insertions(+), 87 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java rename temporal-sdk/src/main/java/io/temporal/internal/nexus/{WorkflowRunOperationToken.java => OperationToken.java} (82%) create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/CancelWorkflowRunInput.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationCancelContext.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationResult.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationStartContext.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java new file mode 100644 index 0000000000..5cd24018f9 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java @@ -0,0 +1,81 @@ +package io.temporal.internal.nexus; + +import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; +import static io.temporal.internal.common.NexusUtil.nexusProtoLinkToLink; + +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.api.common.v1.Link; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.internal.client.NexusStartWorkflowRequest; +import io.temporal.internal.client.NexusStartWorkflowResponse; +import java.net.URISyntaxException; +import java.util.function.Function; + +/** + * Shared helper for starting a workflow from a Nexus operation and attaching workflow links to the + * operation context. Used by both {@code WorkflowRunOperationImpl} and {@code + * TemporalNexusClientImpl}. + */ +public class NexusStartWorkflowHelper { + + /** + * Starts a workflow via the provided invoker function, attaches workflow links to the operation + * context, and returns the response. + * + * @param ctx the operation context (links will be attached as a side-effect) + * @param details the operation start details containing requestId, callback, links + * @param invoker function that starts the workflow given a {@link NexusStartWorkflowRequest} + * @return the {@link NexusStartWorkflowResponse} containing the operation token and workflow + * execution + */ + public static NexusStartWorkflowResponse startWorkflowAndAttachLinks( + OperationContext ctx, + OperationStartDetails details, + Function invoker) { + InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); + + NexusStartWorkflowRequest nexusRequest = + new NexusStartWorkflowRequest( + details.getRequestId(), + details.getCallbackUrl(), + details.getCallbackHeaders(), + nexusCtx.getTaskQueue(), + details.getLinks()); + + NexusStartWorkflowResponse response = invoker.apply(nexusRequest); + WorkflowExecution workflowExec = response.getWorkflowExecution(); + + // If the start workflow response returned a link use it, otherwise + // create the link information about the new workflow and return to the caller. + Link.WorkflowEvent workflowEventLink = + nexusCtx.getStartWorkflowResponseLink().hasWorkflowEvent() + ? nexusCtx.getStartWorkflowResponseLink().getWorkflowEvent() + : null; + if (workflowEventLink == null) { + workflowEventLink = + Link.WorkflowEvent.newBuilder() + .setNamespace(nexusCtx.getNamespace()) + .setWorkflowId(workflowExec.getWorkflowId()) + .setRunId(workflowExec.getRunId()) + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) + .build(); + } + io.temporal.api.nexus.v1.Link nexusLink = workflowEventToNexusLink(workflowEventLink); + if (nexusLink != null) { + try { + ctx.addLinks(nexusProtoLinkToLink(nexusLink)); + } catch (URISyntaxException e) { + throw new HandlerException(HandlerException.ErrorType.INTERNAL, "failed to parse URI", e); + } + } + + return response; + } + + private NexusStartWorkflowHelper() {} +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/WorkflowRunOperationToken.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java similarity index 82% rename from temporal-sdk/src/main/java/io/temporal/internal/nexus/WorkflowRunOperationToken.java rename to temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java index 2c8d1acb87..4bd5635e93 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/WorkflowRunOperationToken.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java @@ -3,7 +3,8 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; -public class WorkflowRunOperationToken { +/** Deserialized representation of a Nexus operation token. */ +public class OperationToken { @JsonProperty("v") @JsonInclude(JsonInclude.Include.NON_NULL) private final Integer version; @@ -17,7 +18,7 @@ public class WorkflowRunOperationToken { @JsonProperty("wid") private final String workflowId; - public WorkflowRunOperationToken( + public OperationToken( @JsonProperty("t") Integer type, @JsonProperty("ns") String namespace, @JsonProperty("wid") String workflowId, @@ -28,8 +29,8 @@ public WorkflowRunOperationToken( this.version = version; } - public WorkflowRunOperationToken(String namespace, String workflowId) { - this.type = OperationTokenType.WORKFLOW_RUN; + public OperationToken(OperationTokenType type, String namespace, String workflowId) { + this.type = type; this.namespace = namespace; this.workflowId = workflowId; this.version = null; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java index 1f4869bdc4..737a84aad4 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java @@ -15,33 +15,45 @@ public class OperationTokenUtil { private static final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); /** - * Load a workflow run operation token from an operation token. + * Load and validate an operation token without asserting the token type. Use this for cancel + * dispatch where the token type determines the cancel behavior. * - * @throws IllegalArgumentException if the operation token is invalid + * @throws IllegalArgumentException if the operation token is malformed or has invalid structure */ - public static WorkflowRunOperationToken loadWorkflowRunOperationToken(String operationToken) { - WorkflowRunOperationToken token; + public static OperationToken loadOperationToken(String operationToken) { + OperationToken token; try { - JavaType reference = mapper.getTypeFactory().constructType(WorkflowRunOperationToken.class); + JavaType reference = mapper.getTypeFactory().constructType(OperationToken.class); token = mapper.readValue(decoder.decode(operationToken), reference); } catch (Exception e) { throw new IllegalArgumentException("Failed to parse operation token: " + e.getMessage()); } - if (!token.getType().equals(OperationTokenType.WORKFLOW_RUN)) { - throw new IllegalArgumentException( - "Invalid workflow run token: incorrect operation token type: " + token.getType()); - } if (token.getVersion() != null && token.getVersion() != 0) { - throw new IllegalArgumentException("Invalid workflow run token: unexpected version field"); + throw new IllegalArgumentException("Invalid operation token: unexpected version field"); } if (Strings.isNullOrEmpty(token.getWorkflowId())) { - throw new IllegalArgumentException("Invalid workflow run token: missing workflow ID (wid)"); + throw new IllegalArgumentException("Invalid operation token: missing workflow ID (wid)"); + } + return token; + } + + /** + * Load a workflow run operation token, asserting that the token type is {@link + * OperationTokenType#WORKFLOW_RUN}. + * + * @throws IllegalArgumentException if the operation token is invalid or not a workflow run token + */ + public static OperationToken loadWorkflowRunOperationToken(String operationToken) { + OperationToken token = loadOperationToken(operationToken); + if (!token.getType().equals(OperationTokenType.WORKFLOW_RUN)) { + throw new IllegalArgumentException( + "Invalid workflow run token: incorrect operation token type: " + token.getType()); } return token; } /** - * Attempt to extract the workflow Id from an operation token. + * Extract the workflow ID from a workflow run operation token. * * @throws IllegalArgumentException if the operation token is invalid */ @@ -52,7 +64,9 @@ public static String loadWorkflowIdFromOperationToken(String operationToken) { /** Generate a workflow run operation token from a workflow ID and namespace. */ public static String generateWorkflowRunOperationToken(String workflowId, String namespace) throws JsonProcessingException { - String json = ow.writeValueAsString(new WorkflowRunOperationToken(namespace, workflowId)); + String json = + ow.writeValueAsString( + new OperationToken(OperationTokenType.WORKFLOW_RUN, namespace, workflowId)); return encoder.encodeToString(json.getBytes()); } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/CancelWorkflowRunInput.java b/temporal-sdk/src/main/java/io/temporal/nexus/CancelWorkflowRunInput.java new file mode 100644 index 0000000000..e92edef0b2 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/CancelWorkflowRunInput.java @@ -0,0 +1,23 @@ +package io.temporal.nexus; + +import io.temporal.common.Experimental; +import java.util.Objects; + +/** + * Input to {@link TemporalOperationHandler#cancelWorkflowRun} describing the workflow run to + * cancel. + */ +@Experimental +public final class CancelWorkflowRunInput { + + private final String workflowId; + + public CancelWorkflowRunInput(String workflowId) { + this.workflowId = Objects.requireNonNull(workflowId); + } + + /** Returns the workflow ID extracted from the operation token. */ + public String getWorkflowId() { + return workflowId; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java new file mode 100644 index 0000000000..4eed1fe350 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java @@ -0,0 +1,510 @@ +package io.temporal.nexus; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.common.Experimental; +import io.temporal.workflow.Functions; +import java.lang.reflect.Type; + +/** + * Nexus-aware client wrapping {@link WorkflowClient}. Provides methods for interacting with + * Temporal from within a Nexus operation handler. + * + *

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

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

{@code
+ * @OperationImpl
+ * public OperationHandler startTransfer() {
+ *   return TemporalOperationHandler.create((context, client, input) -> {
+ *     return client.startWorkflow(
+ *         TransferWorkflow.class,
+ *         TransferWorkflow::transfer, input.getFromAccount(), input.getToAccount(),
+ *         WorkflowOptions.newBuilder()
+ *             .setWorkflowId("transfer-" + input.getTransferId())
+ *             .build());
+ *   });
+ * }
+ * }
+ * + *

For synchronous operations, use {@link #getWorkflowClient()} directly and return a {@link + * TemporalOperationResult#sync} result. For example, to send a signal: + * + *

{@code
+ * @OperationImpl
+ * public OperationHandler cancelOrder() {
+ *   return TemporalOperationHandler.create((context, client, input) -> {
+ *     client.getWorkflowClient()
+ *         .newUntypedWorkflowStub("order-" + input.getOrderId())
+ *         .signal("requestCancellation", input);
+ *     return TemporalOperationResult.sync(null);
+ *   });
+ * }
+ * }
+ */ +@Experimental +public interface TemporalNexusClient { + + /** Returns the underlying {@link WorkflowClient} for advanced use cases. */ + WorkflowClient getWorkflowClient(); + + /** + * Starts a zero-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::run, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, Functions.Func1 workflowMethod, WorkflowOptions options); + + /** + * Starts a one-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::processOrder, input, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func2 workflowMethod, + A1 arg1, + WorkflowOptions options); + + /** + * Starts a two-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::run, arg1, arg2, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func3 workflowMethod, + A1 arg1, + A2 arg2, + WorkflowOptions options); + + /** + * Starts a three-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::run, arg1, arg2, arg3, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func4 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + WorkflowOptions options); + + /** + * Starts a four-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::run, arg1, arg2, arg3, arg4, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param arg4 fourth workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the type of the fourth workflow argument + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func5 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + WorkflowOptions options); + + /** + * Starts a five-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::run, arg1, arg2, arg3, arg4, arg5, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param arg4 fourth workflow argument + * @param arg5 fifth workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the type of the fourth workflow argument + * @param the type of the fifth workflow argument + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func6 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + WorkflowOptions options); + + /** + * Starts a six-argument workflow that returns a value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::run, arg1, arg2, arg3, arg4, arg5, arg6, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param arg4 fourth workflow argument + * @param arg5 fifth workflow argument + * @param arg6 sixth workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the type of the fourth workflow argument + * @param the type of the fifth workflow argument + * @param the type of the sixth workflow argument + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func7 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + WorkflowOptions options); + + /** + * Starts a zero-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, Functions.Proc1 workflowMethod, WorkflowOptions options); + + /** + * Starts a one-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, input, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc2 workflowMethod, + A1 arg1, + WorkflowOptions options); + + /** + * Starts a two-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, arg1, arg2, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc3 workflowMethod, + A1 arg1, + A2 arg2, + WorkflowOptions options); + + /** + * Starts a three-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, arg1, arg2, arg3, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc4 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + WorkflowOptions options); + + /** + * Starts a four-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, arg1, arg2, arg3, arg4, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param arg4 fourth workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the type of the fourth workflow argument + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc5 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + WorkflowOptions options); + + /** + * Starts a five-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, arg1, arg2, arg3, arg4, arg5, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param arg4 fourth workflow argument + * @param arg5 fifth workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the type of the fourth workflow argument + * @param the type of the fifth workflow argument + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc6 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + WorkflowOptions options); + + /** + * Starts a six-argument workflow with no return value. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(MyWorkflow.class, MyWorkflow::execute, arg1, arg2, arg3, arg4, arg5, arg6, options)
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowMethod unbound method reference to the workflow method + * @param arg1 first workflow argument + * @param arg2 second workflow argument + * @param arg3 third workflow argument + * @param arg4 fourth workflow argument + * @param arg5 fifth workflow argument + * @param arg6 sixth workflow argument + * @param options workflow start options (must include workflowId) + * @param the workflow interface type + * @param the type of the first workflow argument + * @param the type of the second workflow argument + * @param the type of the third workflow argument + * @param the type of the fourth workflow argument + * @param the type of the fifth workflow argument + * @param the type of the sixth workflow argument + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc7 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + WorkflowOptions options); + + /** + * Starts a workflow using an untyped workflow type name. + * + *

Example: + * + *

{@code
+   * client.startWorkflow("MyWorkflow", String.class, options, input)
+   * }
+ * + * @param workflowType the workflow type name string + * @param resultClass the expected result class + * @param options workflow start options (must include workflowId) + * @param args workflow arguments + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + String workflowType, Class resultClass, WorkflowOptions options, Object... args); + + /** + * Starts a workflow using an untyped workflow type name, with both a result class and a generic + * {@link Type}. Use this overload when the workflow returns a generic type (e.g. {@code + * List}) so the result can be deserialized correctly. + * + *

Example: + * + *

{@code
+   * client.startWorkflow(
+   *     "MyWorkflow",
+   *     List.class,
+   *     new TypeToken>() {}.getType(),
+   *     options,
+   *     input)
+   * }
+ * + * @param workflowType the workflow type name string + * @param resultClass the expected result class + * @param resultType the expected result {@link Type} (carries generic parameters) + * @param options workflow start options (must include workflowId) + * @param args workflow arguments + * @param the workflow return type + * @return an async {@link TemporalOperationResult} with the workflow-run operation token + */ + TemporalOperationResult startWorkflow( + String workflowType, + Class resultClass, + Type resultType, + WorkflowOptions options, + Object... args); +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java new file mode 100644 index 0000000000..43e29e18f5 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java @@ -0,0 +1,268 @@ +package io.temporal.nexus; + +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.Experimental; +import io.temporal.internal.client.NexusStartWorkflowResponse; +import io.temporal.internal.nexus.NexusStartWorkflowHelper; +import io.temporal.workflow.Functions; +import java.lang.reflect.Type; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Package-private implementation of {@link TemporalNexusClient}. */ +@Experimental +final class TemporalNexusClientImpl implements TemporalNexusClient { + + private final WorkflowClient client; + private final OperationContext operationContext; + private final OperationStartDetails operationStartDetails; + private final AtomicBoolean asyncOperationStarted = new AtomicBoolean(false); + + TemporalNexusClientImpl( + WorkflowClient client, + OperationContext operationContext, + OperationStartDetails operationStartDetails) { + this.client = Objects.requireNonNull(client); + this.operationContext = Objects.requireNonNull(operationContext); + this.operationStartDetails = Objects.requireNonNull(operationStartDetails); + } + + @Override + public WorkflowClient getWorkflowClient() { + return client; + } + + // ---------- Returning (Func) overloads ---------- + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, Functions.Func1 workflowMethod, WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn(WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func2 workflowMethod, + A1 arg1, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub, arg1))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func3 workflowMethod, + A1 arg1, + A2 arg2, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub, arg1, arg2))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func4 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub, arg1, arg2, arg3))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func5 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod( + () -> workflowMethod.apply(stub, arg1, arg2, arg3, arg4))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func6 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod( + () -> workflowMethod.apply(stub, arg1, arg2, arg3, arg4, arg5))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Func7 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod( + () -> workflowMethod.apply(stub, arg1, arg2, arg3, arg4, arg5, arg6))); + } + + // ---------- Void (Proc) overloads ---------- + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, Functions.Proc1 workflowMethod, WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn(WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc2 workflowMethod, + A1 arg1, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub, arg1))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc3 workflowMethod, + A1 arg1, + A2 arg2, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub, arg1, arg2))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc4 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod(() -> workflowMethod.apply(stub, arg1, arg2, arg3))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc5 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod( + () -> workflowMethod.apply(stub, arg1, arg2, arg3, arg4))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc6 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod( + () -> workflowMethod.apply(stub, arg1, arg2, arg3, arg4, arg5))); + } + + @Override + public TemporalOperationResult startWorkflow( + Class workflowClass, + Functions.Proc7 workflowMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + WorkflowOptions options) { + T stub = client.newWorkflowStub(workflowClass, options); + return invokeAndReturn( + WorkflowHandle.fromWorkflowMethod( + () -> workflowMethod.apply(stub, arg1, arg2, arg3, arg4, arg5, arg6))); + } + + // ---------- Untyped ---------- + + @Override + public TemporalOperationResult startWorkflow( + String workflowType, Class resultClass, WorkflowOptions options, Object... args) { + return startWorkflow(workflowType, resultClass, null, options, args); + } + + @Override + public TemporalOperationResult startWorkflow( + String workflowType, + Class resultClass, + Type resultType, + WorkflowOptions options, + Object... args) { + WorkflowStub stub = client.newUntypedWorkflowStub(workflowType, options); + WorkflowHandle handle = WorkflowHandle.fromWorkflowStub(stub, resultClass, args); + return invokeAndReturn(handle); + } + + private TemporalOperationResult invokeAndReturn(WorkflowHandle handle) { + if (!asyncOperationStarted.compareAndSet(false, true)) { + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + new IllegalStateException( + "Only one async operation can be started per operation handler invocation. " + + "Use getWorkflowClient() for additional workflow interactions.")); + } + try { + NexusStartWorkflowResponse response = + NexusStartWorkflowHelper.startWorkflowAndAttachLinks( + operationContext, + operationStartDetails, + request -> handle.getInvoker().invoke(request)); + return TemporalOperationResult.async(response.getOperationToken()); + } catch (Throwable t) { + // Reset on failure so that if startWorkflowAndAttachLinks throws, + // the handler can retry without being blocked by the guard. + asyncOperationStarted.set(false); + throw t; + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationCancelContext.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationCancelContext.java new file mode 100644 index 0000000000..963dc2981b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationCancelContext.java @@ -0,0 +1,88 @@ +package io.temporal.nexus; + +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationMethodCancellationListener; +import io.temporal.common.Experimental; +import java.time.Instant; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Context for a Nexus cancel operation request, passed to {@link + * TemporalOperationHandler#cancelWorkflowRun}. + */ +@Experimental +public final class TemporalOperationCancelContext { + + private final OperationContext operationContext; + private final OperationCancelDetails operationCancelDetails; + + TemporalOperationCancelContext( + OperationContext operationContext, OperationCancelDetails operationCancelDetails) { + this.operationContext = Objects.requireNonNull(operationContext); + this.operationCancelDetails = Objects.requireNonNull(operationCancelDetails); + } + + /** Returns the service name for this operation. */ + public String getService() { + return operationContext.getService(); + } + + /** Returns the operation name. */ + public String getOperation() { + return operationContext.getOperation(); + } + + /** Returns the headers for this cancel request. The returned map is case-insensitive. */ + public Map getHeaders() { + return operationContext.getHeaders(); + } + + /** + * Returns the deadline for the operation handler method. This is the time by which the method + * should complete. This is not the operation's deadline. + */ + public @Nullable Instant getDeadline() { + return operationContext.getDeadline(); + } + + /** Returns the operation token identifying the operation to cancel. */ + public String getOperationToken() { + return operationCancelDetails.getOperationToken(); + } + + /** + * True if the handler method has been cancelled. Note, this is method cancellation, unrelated to + * operation cancellation. + */ + public boolean isMethodCancelled() { + return operationContext.isMethodCancelled(); + } + + /** + * Reason the handler method was cancelled, or null if not cancelled. Note, this is method + * cancellation, unrelated to operation cancellation. + */ + public @Nullable String getMethodCancellationReason() { + return operationContext.getMethodCancellationReason(); + } + + /** + * Add a listener for method cancellation. The listener is invoked immediately before this method + * returns if the method is already cancelled. The listener must not block and must not be + * registered from within another cancellation listener. + */ + public void addMethodCancellationListener(OperationMethodCancellationListener listener) { + operationContext.addMethodCancellationListener(listener); + } + + /** + * Remove a method cancellation listener, if present. Must not be called from within another + * cancellation listener. + */ + public void removeMethodCancellationListener(OperationMethodCancellationListener listener) { + operationContext.removeMethodCancellationListener(listener); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java new file mode 100644 index 0000000000..6a01d11fc6 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java @@ -0,0 +1,126 @@ +package io.temporal.nexus; + +import io.nexusrpc.handler.*; +import io.temporal.client.WorkflowClient; +import io.temporal.common.Experimental; +import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.OperationToken; +import io.temporal.internal.nexus.OperationTokenType; +import io.temporal.internal.nexus.OperationTokenUtil; + +/** + * Generic Nexus operation handler backed by Temporal. Implements {@link OperationHandler} and + * provides a composable way to map Temporal operations (start workflow, etc.) to Nexus operations. + * + *

Usage example: + * + *

{@code
+ * @OperationImpl
+ * public OperationHandler startTransfer() {
+ *   return TemporalOperationHandler.create((context, client, input) -> {
+ *     return client.startWorkflow(
+ *         TransferWorkflow.class,
+ *         TransferWorkflow::transfer, input.getFromAccount(), input.getToAccount(),
+ *         WorkflowOptions.newBuilder()
+ *             .setWorkflowId("transfer-" + input.getTransferId())
+ *             .build());
+ *   });
+ * }
+ * }
+ * + *

This class supports subclassing to customize cancel behavior. Override {@link + * #cancelWorkflowRun} to change how workflow-run cancellations are handled. The {@link #start} and + * {@link #cancel} methods should not be overridden — they contain the core dispatch logic. + * + * @param the input type + * @param the result type + */ +@Experimental +public class TemporalOperationHandler implements OperationHandler { + + /** + * Handler invoked when a Nexus start operation request is received. + * + * @param the input type + * @param the result type + */ + @FunctionalInterface + public interface StartHandler { + TemporalOperationResult apply( + TemporalOperationStartContext context, TemporalNexusClient client, T input); + } + + private final StartHandler startHandler; + + protected TemporalOperationHandler(StartHandler startHandler) { + this.startHandler = startHandler; + } + + /** + * Creates a {@link TemporalOperationHandler} from a start handler. Subclass and override {@link + * #cancelWorkflowRun} to customize cancel behavior. + * + * @param startHandler the handler to invoke on start operation requests + * @return an operation handler backed by the given start handler + */ + public static TemporalOperationHandler create(StartHandler startHandler) { + return new TemporalOperationHandler<>(startHandler); + } + + @Override + public final OperationStartResult start( + OperationContext ctx, OperationStartDetails details, T input) { + InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); + TemporalNexusClient client = + new TemporalNexusClientImpl(nexusCtx.getWorkflowClient(), ctx, details); + + TemporalOperationStartContext startContext = new TemporalOperationStartContext(ctx, details); + TemporalOperationResult result = startHandler.apply(startContext, client, input); + + if (result.isSync()) { + return OperationStartResult.newSyncBuilder(result.getSyncResult()).build(); + } else if (result.isAsync()) { + return OperationStartResult.newAsyncBuilder(result.getAsyncOperationToken()).build(); + } else { + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, + new IllegalStateException("TemporalOperationResult must be either sync or async")); + } + } + + @Override + public final void cancel(OperationContext ctx, OperationCancelDetails details) { + OperationToken token; + try { + token = OperationTokenUtil.loadOperationToken(details.getOperationToken()); + } catch (IllegalArgumentException e) { + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, "failed to parse operation token", e); + } + + TemporalOperationCancelContext cancelContext = new TemporalOperationCancelContext(ctx, details); + if (token.getType() == OperationTokenType.WORKFLOW_RUN) { + cancelWorkflowRun(cancelContext, new CancelWorkflowRunInput(token.getWorkflowId())); + } else { + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + new IllegalArgumentException("unsupported operation token type: " + token.getType())); + } + } + + /** + * Called when a cancel request is received for a workflow-run token (type=1). Override to + * customize cancel behavior. + * + *

Default behavior: cancels the underlying workflow. + * + * @param context the cancel context + * @param input describes the workflow run to cancel + */ + protected void cancelWorkflowRun( + TemporalOperationCancelContext context, CancelWorkflowRunInput input) { + WorkflowClient client = CurrentNexusOperationContext.get().getWorkflowClient(); + client.newUntypedWorkflowStub(input.getWorkflowId()).cancel(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationResult.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationResult.java new file mode 100644 index 0000000000..68e41f3492 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationResult.java @@ -0,0 +1,91 @@ +package io.temporal.nexus; + +import com.google.common.base.Strings; +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Unified result type for Temporal-backed Nexus operations. Encapsulates either a synchronous + * result or an async operation token. + * + *

Use {@link #sync(Object)} for operations that complete immediately (e.g., signals). Use {@link + * #async(String)} for operations that return an operation token for async completion (e.g., start + * workflow). + */ +@Experimental +public final class TemporalOperationResult { + + private final boolean isSync; + @Nullable private final R syncResult; + @Nullable private final String asyncOperationToken; + + private TemporalOperationResult( + boolean isSync, @Nullable R syncResult, @Nullable String asyncOperationToken) { + this.isSync = isSync; + this.syncResult = syncResult; + this.asyncOperationToken = asyncOperationToken; + } + + /** + * Creates a synchronous result. + * + * @param value the result value, may be null + * @return a sync result wrapping the given value + */ + public static TemporalOperationResult sync(@Nullable R value) { + return new TemporalOperationResult<>(true, value, null); + } + + /** + * Creates an asynchronous result backed by an operation token. + * + * @param operationToken the operation token identifying the async operation + * @return an async result wrapping the given token + * @throws IllegalArgumentException if operationToken is null or empty + */ + public static TemporalOperationResult async(String operationToken) { + if (Strings.isNullOrEmpty(operationToken)) { + throw new IllegalArgumentException("operationToken must not be null or empty"); + } + return new TemporalOperationResult<>(false, null, operationToken); + } + + /** Returns true if this is a synchronous result. */ + public boolean isSync() { + return isSync; + } + + /** Returns true if this is an asynchronous result backed by an operation token. */ + public boolean isAsync() { + return !isSync; + } + + /** + * Returns the synchronous result value. + * + * @return the sync result value (may be null if the operation produced a null sync value) + * @throws IllegalStateException if this is an async result; check {@link #isSync()} first + */ + @Nullable + public R getSyncResult() { + if (!isSync) { + throw new IllegalStateException( + "getSyncResult() called on async result; use getAsyncOperationToken() instead"); + } + return syncResult; + } + + /** + * Returns the async operation token. + * + * @return the operation token + * @throws IllegalStateException if this is a sync result; check {@link #isAsync()} first + */ + public String getAsyncOperationToken() { + if (isSync) { + throw new IllegalStateException( + "getAsyncOperationToken() called on sync result; use getSyncResult() instead"); + } + return asyncOperationToken; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationStartContext.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationStartContext.java new file mode 100644 index 0000000000..493ccd5071 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationStartContext.java @@ -0,0 +1,117 @@ +package io.temporal.nexus; + +import io.nexusrpc.Link; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationMethodCancellationListener; +import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.common.Experimental; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.jspecify.annotations.Nullable; + +/** + * Context for a Nexus start operation request, passed to {@link + * TemporalOperationHandler.StartHandler} alongside the {@link TemporalNexusClient} and input. + */ +@Experimental +public final class TemporalOperationStartContext { + + private final OperationContext operationContext; + private final OperationStartDetails operationStartDetails; + + TemporalOperationStartContext( + OperationContext operationContext, OperationStartDetails operationStartDetails) { + this.operationContext = Objects.requireNonNull(operationContext); + this.operationStartDetails = Objects.requireNonNull(operationStartDetails); + } + + /** Returns the service name for this operation. */ + public String getService() { + return operationContext.getService(); + } + + /** Returns the operation name. */ + public String getOperation() { + return operationContext.getOperation(); + } + + /** Returns the headers for this operation request. The returned map is case-insensitive. */ + public Map getHeaders() { + return operationContext.getHeaders(); + } + + /** + * Returns the deadline for the operation handler method. This is the time by which the method + * should complete. This is not the operation's deadline. + */ + public @Nullable Instant getDeadline() { + return operationContext.getDeadline(); + } + + /** Returns the request ID for this operation. */ + public String getRequestId() { + return operationStartDetails.getRequestId(); + } + + /** + * Optional callback URL for asynchronous operations to deliver results to. If present and the + * implementation is asynchronous, the implementation should ensure this callback is invoked with + * the result upon completion. + */ + public @Nullable String getCallbackUrl() { + return operationStartDetails.getCallbackUrl(); + } + + /** Headers to use on the callback if {@link #getCallbackUrl} is used. */ + public Map getCallbackHeaders() { + return operationStartDetails.getCallbackHeaders(); + } + + /** Links sent by the caller. Handlers may use these as metadata on associated resources. */ + public List getLinks() { + return operationStartDetails.getLinks(); + } + + /** + * True if the handler method has been cancelled. Note, this is method cancellation, unrelated to + * operation cancellation. + */ + public boolean isMethodCancelled() { + return operationContext.isMethodCancelled(); + } + + /** + * Reason the handler method was cancelled, or null if not cancelled. Note, this is method + * cancellation, unrelated to operation cancellation. + */ + public @Nullable String getMethodCancellationReason() { + return operationContext.getMethodCancellationReason(); + } + + /** + * Add a listener for method cancellation. The listener is invoked immediately before this method + * returns if the method is already cancelled. The listener must not block and must not be + * registered from within another cancellation listener. + */ + public void addMethodCancellationListener(OperationMethodCancellationListener listener) { + operationContext.addMethodCancellationListener(listener); + } + + /** + * Remove a method cancellation listener, if present. Must not be called from within another + * cancellation listener. + */ + public void removeMethodCancellationListener(OperationMethodCancellationListener listener) { + operationContext.removeMethodCancellationListener(listener); + } + + /** + * Associates links with the current operation to be propagated back to the caller. Links are only + * attached on successful responses. + */ + public void addResponseLinks(Link... links) { + operationContext.addLinks(links); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/WorkflowRunOperationImpl.java b/temporal-sdk/src/main/java/io/temporal/nexus/WorkflowRunOperationImpl.java index 537235ce53..167cd4061f 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/WorkflowRunOperationImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/WorkflowRunOperationImpl.java @@ -1,20 +1,12 @@ package io.temporal.nexus; -import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; -import static io.temporal.internal.common.NexusUtil.nexusProtoLinkToLink; - import io.nexusrpc.handler.*; import io.nexusrpc.handler.OperationHandler; -import io.temporal.api.common.v1.Link; -import io.temporal.api.common.v1.WorkflowExecution; -import io.temporal.api.enums.v1.EventType; import io.temporal.client.WorkflowClient; -import io.temporal.internal.client.NexusStartWorkflowRequest; import io.temporal.internal.client.NexusStartWorkflowResponse; import io.temporal.internal.nexus.CurrentNexusOperationContext; -import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.NexusStartWorkflowHelper; import io.temporal.internal.nexus.OperationTokenUtil; -import java.net.URISyntaxException; class WorkflowRunOperationImpl implements OperationHandler { private final WorkflowHandleFactory handleFactory; @@ -26,52 +18,13 @@ class WorkflowRunOperationImpl implements OperationHandler { @Override public OperationStartResult start( OperationContext ctx, OperationStartDetails operationStartDetails, T input) { - InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); - WorkflowHandle handle = handleFactory.apply(ctx, operationStartDetails, input); - NexusStartWorkflowRequest nexusRequest = - new NexusStartWorkflowRequest( - operationStartDetails.getRequestId(), - operationStartDetails.getCallbackUrl(), - operationStartDetails.getCallbackHeaders(), - nexusCtx.getTaskQueue(), - operationStartDetails.getLinks()); - - NexusStartWorkflowResponse nexusStartWorkflowResponse = - handle.getInvoker().invoke(nexusRequest); - WorkflowExecution workflowExec = nexusStartWorkflowResponse.getWorkflowExecution(); + NexusStartWorkflowResponse response = + NexusStartWorkflowHelper.startWorkflowAndAttachLinks( + ctx, operationStartDetails, request -> handle.getInvoker().invoke(request)); - // If the start workflow response returned a link use it, otherwise - // create the link information about the new workflow and return to the caller. - Link.WorkflowEvent workflowEventLink = - nexusCtx.getStartWorkflowResponseLink().hasWorkflowEvent() - ? nexusCtx.getStartWorkflowResponseLink().getWorkflowEvent() - : null; - if (workflowEventLink == null) { - workflowEventLink = - Link.WorkflowEvent.newBuilder() - .setNamespace(nexusCtx.getNamespace()) - .setWorkflowId(workflowExec.getWorkflowId()) - .setRunId(workflowExec.getRunId()) - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - } - io.temporal.api.nexus.v1.Link nexusLink = workflowEventToNexusLink(workflowEventLink); - // Attach the link to the operation result. - OperationStartResult.Builder result = - OperationStartResult.newAsyncBuilder(nexusStartWorkflowResponse.getOperationToken()); - if (nexusLink != null) { - try { - ctx.addLinks(nexusProtoLinkToLink(nexusLink)); - } catch (URISyntaxException e) { - // Not expected as the link is constructed by the SDK. - throw new HandlerException(HandlerException.ErrorType.INTERNAL, "failed to parse URI", e); - } - } - return result.build(); + return OperationStartResult.newAsyncBuilder(response.getOperationToken()).build(); } @Override diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java index fbf14d217a..1f22fe8c2e 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java @@ -17,7 +17,8 @@ public class WorkflowRunTokenTest { @Test public void serializeWorkflowRunToken() throws JsonProcessingException { - WorkflowRunOperationToken token = new WorkflowRunOperationToken("namespace", "workflowId"); + OperationToken token = + new OperationToken(OperationTokenType.WORKFLOW_RUN, "namespace", "workflowId"); String json = ow.writeValueAsString(token); final JsonNode node = new ObjectMapper().readTree(json); System.out.println(json); @@ -32,9 +33,8 @@ public void serializeWorkflowRunToken() throws JsonProcessingException { @Test public void deserializeWorkflowRunTokenWithVersion() throws IOException { String json = "{\"t\":1,\"ns\":\"namespace\",\"wid\":\"workflowId\",\"v\":1}"; - JavaType reference = - new ObjectMapper().getTypeFactory().constructType(WorkflowRunOperationToken.class); - WorkflowRunOperationToken token = new ObjectMapper().readValue(json.getBytes(), reference); + JavaType reference = new ObjectMapper().getTypeFactory().constructType(OperationToken.class); + OperationToken token = new ObjectMapper().readValue(json.getBytes(), reference); // Assert that the serialized JSON is as expected Assert.assertEquals(OperationTokenType.WORKFLOW_RUN, token.getType()); Assert.assertEquals(new Integer(1), token.getVersion()); @@ -45,9 +45,8 @@ public void deserializeWorkflowRunTokenWithVersion() throws IOException { @Test public void deserializeWorkflowRunToken() throws IOException { String json = "{\"t\":1,\"ns\":\"namespace\",\"wid\":\"workflowId\"}"; - JavaType reference = - new ObjectMapper().getTypeFactory().constructType(WorkflowRunOperationToken.class); - WorkflowRunOperationToken token = new ObjectMapper().readValue(json.getBytes(), reference); + JavaType reference = new ObjectMapper().getTypeFactory().constructType(OperationToken.class); + OperationToken token = new ObjectMapper().readValue(json.getBytes(), reference); // Assert that the serialized JSON is as expected Assert.assertEquals(OperationTokenType.WORKFLOW_RUN, token.getType()); Assert.assertNull(null, token.getVersion()); @@ -67,8 +66,8 @@ public void failLoadOldWorkflowRunToken() { public void loadWorkflowIdFromOperationToken() { String json = "{\"t\":1,\"ns\":\"namespace\",\"wid\":\"workflowId\"}"; - WorkflowRunOperationToken token = - OperationTokenUtil.loadWorkflowRunOperationToken(encoder.encodeToString(json.getBytes())); + OperationToken token = + OperationTokenUtil.loadOperationToken(encoder.encodeToString(json.getBytes())); Assert.assertEquals("workflowId", token.getWorkflowId()); Assert.assertEquals("namespace", token.getNamespace()); Assert.assertNull(token.getVersion()); @@ -86,8 +85,7 @@ public void loadWorkflowIdFromGoOperationToken() { // across SDKs. String goOperationToken = "eyJ2IjowLCJ0IjoxLCJucyI6Im5zIiwid2lkIjoidyJ9"; - WorkflowRunOperationToken token = - OperationTokenUtil.loadWorkflowRunOperationToken(goOperationToken); + OperationToken token = OperationTokenUtil.loadOperationToken(goOperationToken); Assert.assertEquals("w", token.getWorkflowId()); Assert.assertEquals("ns", token.getNamespace()); Assert.assertEquals(Integer.valueOf(0), token.getVersion()); @@ -101,7 +99,7 @@ public void loadWorkflowIdFromBadOperationToken() { Assert.assertThrows( IllegalArgumentException.class, () -> - OperationTokenUtil.loadWorkflowRunOperationToken( + OperationTokenUtil.loadOperationToken( encoder.encodeToString(badTokenEmptyJson.getBytes()))); // Bad token, missing the "wid" field @@ -109,7 +107,7 @@ public void loadWorkflowIdFromBadOperationToken() { Assert.assertThrows( IllegalArgumentException.class, () -> - OperationTokenUtil.loadWorkflowRunOperationToken( + OperationTokenUtil.loadOperationToken( encoder.encodeToString(badTokenMissingWorkflow.getBytes()))); // Bad token, unknown version @@ -118,15 +116,23 @@ public void loadWorkflowIdFromBadOperationToken() { Assert.assertThrows( IllegalArgumentException.class, () -> - OperationTokenUtil.loadWorkflowRunOperationToken( + OperationTokenUtil.loadOperationToken( encoder.encodeToString(badTokenUnknownVersion.getBytes()))); - // Bad token, unknown version + // Bad token, unknown type (also has bad version, so loadOperationToken rejects on version) String badTokenUnknownType = "{\"t\":4,\"ns\":\"namespace\", \"wid\":\"workflowId\", \"v\":1}"; Assert.assertThrows( IllegalArgumentException.class, () -> - OperationTokenUtil.loadWorkflowRunOperationToken( + OperationTokenUtil.loadOperationToken( encoder.encodeToString(badTokenUnknownType.getBytes()))); + + // Bad token, unknown type with valid version — loadWorkflowRunOperationToken rejects on type + String badTokenWrongType = "{\"t\":4,\"ns\":\"namespace\", \"wid\":\"workflowId\"}"; + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadWorkflowRunOperationToken( + encoder.encodeToString(badTokenWrongType.getBytes()))); } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncWorkflowOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncWorkflowOperationTest.java index a0b5f1f9d9..3259c428ea 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncWorkflowOperationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncWorkflowOperationTest.java @@ -6,8 +6,8 @@ import io.temporal.client.WorkflowOptions; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.NexusOperationFailure; +import io.temporal.internal.nexus.OperationToken; import io.temporal.internal.nexus.OperationTokenUtil; -import io.temporal.internal.nexus.WorkflowRunOperationToken; import io.temporal.nexus.Nexus; import io.temporal.nexus.WorkflowRunOperation; import io.temporal.testing.WorkflowReplayer; @@ -74,7 +74,7 @@ public String execute(String input) { "Operation token should be present", asyncExec.getOperationToken().isPresent()); // Result should only be completed if the operation is completed Assert.assertFalse("Result should not be completed", asyncOpHandle.getResult().isCompleted()); - WorkflowRunOperationToken token = + OperationToken token = OperationTokenUtil.loadWorkflowRunOperationToken(asyncExec.getOperationToken().get()); Assert.assertTrue(token.getWorkflowId().startsWith(WORKFLOW_ID_PREFIX)); // Unblock the operation diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java new file mode 100644 index 0000000000..4773e6c521 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java @@ -0,0 +1,137 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.enums.v1.EventType; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.failure.CanceledFailure; +import io.temporal.internal.Signal; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class GenericHandlerCancelTest extends BaseNexusTest { + + private static final Signal opStarted = new Signal(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestCancelWorkflow.class, WaitForCancelWorkflow.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Override + protected SDKTestWorkflowRule getTestWorkflowRule() { + return testWorkflowRule; + } + + @Override + public void setUp() { + super.setUp(); + opStarted.clearSignal(); + } + + @Test + public void cancelGenericHandlerOperation() { + WorkflowStub stub = testWorkflowRule.newUntypedWorkflowStubTimeoutOptions("TestCancelWorkflow"); + stub.start(false); + try { + opStarted.waitForSignal(); + } catch (Exception e) { + Assert.fail("test timed out waiting for operation to start."); + } + stub.cancel(); + Assert.assertThrows(WorkflowFailedException.class, () -> stub.getResult(Void.class)); + // Verify the nexus operation cancel was dispatched and completed successfully. + // The parent workflow completes as cancelled (CanceledFailure), same as with + // WorkflowRunOperation — the cancel handler correctly cancels the handler workflow. + testWorkflowRule.assertHistoryEvent( + stub.getExecution().getWorkflowId(), EventType.EVENT_TYPE_NEXUS_OPERATION_CANCELED); + } + + @WorkflowInterface + public interface TestCancelWorkflowInterface { + @WorkflowMethod(name = "TestCancelWorkflow") + void execute(boolean cancelImmediately); + } + + public static class TestCancelWorkflow implements TestCancelWorkflowInterface { + @Override + public void execute(boolean cancelImmediately) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .setCancellationType(NexusOperationCancellationType.WAIT_COMPLETED) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder() + .setEndpoint(getEndpointName()) + .setOperationOptions(options) + .build(); + + TestNexusCancelService serviceStub = + Workflow.newNexusServiceStub(TestNexusCancelService.class, serviceOptions); + + NexusOperationHandle handle = + Workflow.startNexusOperation(serviceStub::operation, "cancel-test"); + handle.getExecution().get(); + opStarted.signal(); + + try { + Workflow.await(() -> false); + } catch (CanceledFailure f) { + Workflow.newDetachedCancellationScope(() -> handle.getResult().get()).run(); + } + } + } + + @WorkflowInterface + public interface WaitForCancelWorkflowInterface { + @WorkflowMethod + Void execute(String input); + } + + public static class WaitForCancelWorkflow implements WaitForCancelWorkflowInterface { + @Override + public Void execute(String input) { + try { + Workflow.await(() -> false); + } catch (CanceledFailure f) { + // workflow was cancelled as expected + } + return null; + } + } + + @Service + public interface TestNexusCancelService { + @Operation + Void operation(String input); + } + + @ServiceImpl(service = TestNexusCancelService.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startWorkflow( + WaitForCancelWorkflowInterface.class, + WaitForCancelWorkflowInterface::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("generic-cancel-test-" + context.getService()) + .build())); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java new file mode 100644 index 0000000000..9d0c4e6c9f --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java @@ -0,0 +1,120 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class GenericHandlerDoubleStartTest { + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class, BlockingWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + private static final String EXPECTED_MESSAGE = + "Only one async operation can be started per operation handler " + + "invocation. Use getWorkflowClient() for additional workflow interactions."; + + @Test + public void doubleStartThrows() { + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + + WorkflowFailedException e = + Assert.assertThrows( + WorkflowFailedException.class, + () -> workflowStub.execute(testWorkflowRule.getTaskQueue())); + + Assert.assertTrue(e.getCause() instanceof NexusOperationFailure); + NexusOperationFailure nexusFailure = (NexusOperationFailure) e.getCause(); + + Assert.assertTrue(nexusFailure.getCause() instanceof HandlerException); + HandlerException handlerException = (HandlerException) nexusFailure.getCause(); + Assert.assertEquals("handler error: " + EXPECTED_MESSAGE, handlerException.getMessage()); + + Assert.assertTrue(handlerException.getCause() instanceof ApplicationFailure); + ApplicationFailure appFailure = (ApplicationFailure) handlerException.getCause(); + Assert.assertEquals("java.lang.IllegalStateException", appFailure.getType()); + Assert.assertEquals(EXPECTED_MESSAGE, appFailure.getOriginalMessage()); + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder().setOperationOptions(options).build(); + + TestNexusServiceDoubleStart serviceStub = + Workflow.newNexusServiceStub(TestNexusServiceDoubleStart.class, serviceOptions); + return serviceStub.operation(input); + } + } + + @WorkflowInterface + public interface BlockingWorkflow { + @WorkflowMethod + String execute(String input); + } + + public static class BlockingWorkflowImpl implements BlockingWorkflow { + @Override + public String execute(String input) { + // Block forever so the nexus operation doesn't complete via callback + // before the handler error propagates + Workflow.await(() -> false); + return input; + } + } + + @Service + public interface TestNexusServiceDoubleStart { + @Operation + String operation(String input); + } + + @ServiceImpl(service = TestNexusServiceDoubleStart.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> { + // First start should succeed but the workflow blocks indefinitely + client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("double-start-first-" + context.getService()) + .build()); + // Second start should throw + return client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("double-start-second-" + context.getService()) + .build()); + }); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java new file mode 100644 index 0000000000..01eda16b01 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java @@ -0,0 +1,64 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class GenericHandlerSyncResultTest { + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void syncResultTest() { + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + String result = workflowStub.execute(testWorkflowRule.getTaskQueue()); + Assert.assertEquals("sync-hello", result); + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder().setOperationOptions(options).build(); + + TestNexusSyncService serviceStub = + Workflow.newNexusServiceStub(TestNexusSyncService.class, serviceOptions); + return serviceStub.operation("hello"); + } + } + + @Service + public interface TestNexusSyncService { + @Operation + String operation(String input); + } + + @ServiceImpl(service = TestNexusSyncService.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> TemporalOperationResult.sync("sync-" + input)); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java new file mode 100644 index 0000000000..ae3d01be32 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java @@ -0,0 +1,134 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class GenericHandlerTypedProcTest { + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + TestNexus.class, TestMultiArgWorkflowFunctions.TestMultiArgWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void typedProcStartWorkflowTest() { + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + String result = workflowStub.execute(testWorkflowRule.getTaskQueue()); + Assert.assertEquals("done", result); + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder().setOperationOptions(options).build(); + + TestNexusServiceProc serviceStub = + Workflow.newNexusServiceStub(TestNexusServiceProc.class, serviceOptions); + for (int i = 0; i < 7; i++) { + serviceStub.operation(i); + } + return "done"; + } + } + + @Service + public interface TestNexusServiceProc { + @Operation + Void operation(Integer input); + } + + @ServiceImpl(service = TestNexusServiceProc.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> { + String prefix = "generic-handler-test-proc" + input + "-"; + String workflowId = prefix + context.getService() + "-" + context.getOperation(); + WorkflowOptions options = + WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); + switch (input) { + case 0: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc.class, + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc::proc, + options); + case 1: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc::proc1, + "input", + options); + case 2: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc::proc2, + "input", + 2, + options); + case 3: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc::proc3, + "input", + 2, + 3, + options); + case 4: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc::proc4, + "input", + 2, + 3, + 4, + options); + case 5: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc::proc5, + "input", + 2, + 3, + 4, + 5, + options); + case 6: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc::proc6, + "input", + 2, + 3, + 4, + 5, + 6, + options); + default: + throw new IllegalArgumentException("unexpected input: " + input); + } + }); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java new file mode 100644 index 0000000000..49662cc6af --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java @@ -0,0 +1,135 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class GenericHandlerTypedStartWorkflowTest { + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + TestNexus.class, TestMultiArgWorkflowFunctions.TestMultiArgWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void typedStartWorkflowTests() { + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + String result = workflowStub.execute(testWorkflowRule.getTaskQueue()); + Assert.assertEquals("funcinputinput2input23input234input2345input23456", result); + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder().setOperationOptions(options).build(); + + TestNexusServiceGeneric serviceStub = + Workflow.newNexusServiceStub(TestNexusServiceGeneric.class, serviceOptions); + StringBuilder result = new StringBuilder(); + for (int i = 0; i < 7; i++) { + result.append(serviceStub.operation(i)); + } + return result.toString(); + } + } + + @Service + public interface TestNexusServiceGeneric { + @Operation + String operation(Integer input); + } + + @ServiceImpl(service = TestNexusServiceGeneric.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> { + String prefix = "generic-handler-test-func" + input + "-"; + String workflowId = prefix + context.getService() + "-" + context.getOperation(); + WorkflowOptions options = + WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); + switch (input) { + case 0: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc.class, + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc::func, + options); + case 1: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc::func1, + "input", + options); + case 2: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc::func2, + "input", + 2, + options); + case 3: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc::func3, + "input", + 2, + 3, + options); + case 4: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc::func4, + "input", + 2, + 3, + 4, + options); + case 5: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc::func5, + "input", + 2, + 3, + 4, + 5, + options); + case 6: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc::func6, + "input", + 2, + 3, + 4, + 5, + 6, + options); + default: + throw new IllegalArgumentException("unexpected input: " + input); + } + }); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java new file mode 100644 index 0000000000..1f682d4534 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java @@ -0,0 +1,77 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class GenericHandlerUntypedStartWorkflowTest { + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + TestNexus.class, TestMultiArgWorkflowFunctions.TestMultiArgWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void untypedStartWorkflowTest() { + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + String result = workflowStub.execute(testWorkflowRule.getTaskQueue()); + Assert.assertEquals("input", result); + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder().setOperationOptions(options).build(); + + TestNexusServiceUntyped serviceStub = + Workflow.newNexusServiceStub(TestNexusServiceUntyped.class, serviceOptions); + return serviceStub.operation("input"); + } + } + + @Service + public interface TestNexusServiceUntyped { + @Operation + String operation(String input); + } + + @ServiceImpl(service = TestNexusServiceUntyped.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startWorkflow( + "func1", + String.class, + WorkflowOptions.newBuilder() + .setWorkflowId( + "generic-handler-untyped-" + + context.getService() + + "-" + + context.getOperation()) + .build(), + input)); + } + } +} From c9c4bdc0931c92351dc068dab78d6a87475e253c Mon Sep 17 00:00:00 2001 From: Vikas Pandey <144092552+vikas0686@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:25:29 +0530 Subject: [PATCH 009/107] Add tests for temporal-kotlin extension APIs (#2905) * fix : Add missing test coverage for temporal-kotlin extension APIs * fix: Use WorkflowOptions DSL and assert Saga options in SagaExtTest --- .../io/temporal/client/WorkflowStubExtTest.kt | 57 ++++++++++++ .../serviceclient/RpcRetryOptionsExtTest.kt | 51 +++++++++++ .../worker/WorkerFactoryOptionsExtTest.kt | 33 +++++++ .../temporal/worker/WorkerOptionsExtTest.kt | 41 +++++++++ .../WorkflowImplementationOptionsExtTest.kt | 49 ++++++++++ .../workflow/ChildWorkflowOptionsExtTest.kt | 53 +++++++++++ .../workflow/ContinueAsNewOptionsExtTest.kt | 34 +++++++ .../io/temporal/workflow/SagaExtTest.kt | 90 +++++++++++++++++++ 8 files changed, 408 insertions(+) create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowStubExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/serviceclient/RpcRetryOptionsExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerFactoryOptionsExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerOptionsExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkflowImplementationOptionsExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/workflow/ContinueAsNewOptionsExtTest.kt create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/workflow/SagaExtTest.kt diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowStubExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowStubExtTest.kt new file mode 100644 index 0000000000..1b6d09554c --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowStubExtTest.kt @@ -0,0 +1,57 @@ +package io.temporal.client + +import io.temporal.common.converter.DefaultDataConverter +import io.temporal.common.converter.JacksonJsonPayloadConverter +import io.temporal.common.converter.KotlinObjectMapperFactory +import io.temporal.testing.internal.SDKTestWorkflowRule +import io.temporal.workflow.WorkflowInterface +import io.temporal.workflow.WorkflowMethod +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class WorkflowStubExtTest { + + @Rule + @JvmField + val testWorkflowRule: SDKTestWorkflowRule = SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestWorkflowImpl::class.java) + .setWorkflowClientOptions( + WorkflowClientOptions { + setDataConverter(DefaultDataConverter(JacksonJsonPayloadConverter(KotlinObjectMapperFactory.new()))) + } + ) + .build() + + @WorkflowInterface + interface TestWorkflow { + @WorkflowMethod + fun execute(input: String): String + } + + class TestWorkflowImpl : TestWorkflow { + override fun execute(input: String) = "result:$input" + } + + @Test + fun `getResult reified extension should return typed result`() { + val client = testWorkflowRule.workflowClient + val stub = client.newUntypedWorkflowStub( + "TestWorkflow", + WorkflowOptions { setTaskQueue(testWorkflowRule.taskQueue) } + ) + stub.start("hello") + assertEquals("result:hello", stub.getResult()) + } + + @Test + fun `getResultAsync reified extension should return typed result via CompletableFuture`() { + val client = testWorkflowRule.workflowClient + val stub = client.newUntypedWorkflowStub( + "TestWorkflow", + WorkflowOptions { setTaskQueue(testWorkflowRule.taskQueue) } + ) + stub.start("async") + assertEquals("result:async", stub.getResultAsync().get()) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/serviceclient/RpcRetryOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/serviceclient/RpcRetryOptionsExtTest.kt new file mode 100644 index 0000000000..c2db4e62d2 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/serviceclient/RpcRetryOptionsExtTest.kt @@ -0,0 +1,51 @@ +package io.temporal.serviceclient + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Duration + +class RpcRetryOptionsExtTest { + + @Test + fun `RpcRetryOptions DSL should be equivalent to builder`() { + val dslOptions = RpcRetryOptions { + setInitialInterval(Duration.ofMillis(100)) + setMaximumInterval(Duration.ofSeconds(1)) + setBackoffCoefficient(1.5) + setMaximumAttempts(5) + } + + val builderOptions = RpcRetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(100)) + .setMaximumInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(1.5) + .setMaximumAttempts(5) + .build() + + assertEquals(builderOptions, dslOptions) + } + + @Test + fun `RpcRetryOptions copy() DSL should merge override options`() { + val sourceOptions = RpcRetryOptions { + setInitialInterval(Duration.ofMillis(100)) + setMaximumInterval(Duration.ofSeconds(1)) + setBackoffCoefficient(1.5) + setMaximumAttempts(5) + } + + val overriddenOptions = sourceOptions.copy { + setInitialInterval(Duration.ofMillis(10)) + setMaximumAttempts(10) + } + + val expectedOptions = RpcRetryOptions { + setInitialInterval(Duration.ofMillis(10)) + setMaximumInterval(Duration.ofSeconds(1)) + setBackoffCoefficient(1.5) + setMaximumAttempts(10) + } + + assertEquals(expectedOptions, overriddenOptions) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerFactoryOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerFactoryOptionsExtTest.kt new file mode 100644 index 0000000000..e0d678d350 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerFactoryOptionsExtTest.kt @@ -0,0 +1,33 @@ +package io.temporal.worker + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WorkerFactoryOptionsExtTest { + + @Test + fun `WorkerFactoryOptions DSL should set fields correctly`() { + val options = WorkerFactoryOptions { + setWorkflowCacheSize(800) + setMaxWorkflowThreadCount(400) + } + + assertEquals(800, options.workflowCacheSize) + assertEquals(400, options.maxWorkflowThreadCount) + } + + @Test + fun `WorkerFactoryOptions copy() DSL should override specified fields and preserve others`() { + val sourceOptions = WorkerFactoryOptions { + setWorkflowCacheSize(800) + setMaxWorkflowThreadCount(400) + } + + val overriddenOptions = sourceOptions.copy { + setWorkflowCacheSize(1600) + } + + assertEquals(1600, overriddenOptions.workflowCacheSize) + assertEquals(400, overriddenOptions.maxWorkflowThreadCount) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerOptionsExtTest.kt new file mode 100644 index 0000000000..a62cfad8db --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkerOptionsExtTest.kt @@ -0,0 +1,41 @@ +package io.temporal.worker + +import org.junit.Assert.assertEquals +import org.junit.Test + +class WorkerOptionsExtTest { + + @Test + fun `WorkerOptions DSL should be equivalent to builder`() { + val dslOptions = WorkerOptions { + setMaxConcurrentActivityExecutionSize(10) + setMaxConcurrentWorkflowTaskExecutionSize(5) + } + + val builderOptions = WorkerOptions.newBuilder() + .setMaxConcurrentActivityExecutionSize(10) + .setMaxConcurrentWorkflowTaskExecutionSize(5) + .build() + + assertEquals(builderOptions, dslOptions) + } + + @Test + fun `WorkerOptions copy() DSL should merge override options`() { + val sourceOptions = WorkerOptions { + setMaxConcurrentActivityExecutionSize(10) + setMaxConcurrentWorkflowTaskExecutionSize(5) + } + + val overriddenOptions = sourceOptions.copy { + setMaxConcurrentActivityExecutionSize(20) + } + + val expectedOptions = WorkerOptions { + setMaxConcurrentActivityExecutionSize(20) + setMaxConcurrentWorkflowTaskExecutionSize(5) + } + + assertEquals(expectedOptions, overriddenOptions) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkflowImplementationOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkflowImplementationOptionsExtTest.kt new file mode 100644 index 0000000000..86730e11c9 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/worker/WorkflowImplementationOptionsExtTest.kt @@ -0,0 +1,49 @@ +package io.temporal.worker + +import io.temporal.activity.ActivityOptions +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Duration + +class WorkflowImplementationOptionsExtTest { + + @Test + fun `WorkflowImplementationOptions DSL should be equivalent to builder`() { + val dslOptions = WorkflowImplementationOptions { + setDefaultActivityOptions( + ActivityOptions { + setStartToCloseTimeout(Duration.ofSeconds(10)) + } + ) + } + + val builderOptions = WorkflowImplementationOptions.newBuilder() + .setDefaultActivityOptions( + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build() + ) + .build() + + assertEquals(builderOptions, dslOptions) + } + + @Test + fun `setActivityOptions vararg pairs DSL should be equivalent to map builder`() { + val activityOptions1 = ActivityOptions { setStartToCloseTimeout(Duration.ofSeconds(5)) } + val activityOptions2 = ActivityOptions { setStartToCloseTimeout(Duration.ofSeconds(10)) } + + val dslOptions = WorkflowImplementationOptions { + setActivityOptions( + "activity1" to activityOptions1, + "activity2" to activityOptions2 + ) + } + + val builderOptions = WorkflowImplementationOptions.newBuilder() + .setActivityOptions(mapOf("activity1" to activityOptions1, "activity2" to activityOptions2)) + .build() + + assertEquals(builderOptions, dslOptions) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt new file mode 100644 index 0000000000..52070395f7 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt @@ -0,0 +1,53 @@ +package io.temporal.workflow + +import io.temporal.common.RetryOptions +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Duration + +class ChildWorkflowOptionsExtTest { + + @Test + fun `ChildWorkflowOptions DSL should be equivalent to builder`() { + val dslOptions = ChildWorkflowOptions { + setTaskQueue("TestQueue") + setWorkflowRunTimeout(Duration.ofMinutes(5)) + setRetryOptions { + setInitialInterval(Duration.ofMillis(100)) + setMaximumAttempts(3) + } + } + + val builderOptions = ChildWorkflowOptions.newBuilder() + .setTaskQueue("TestQueue") + .setWorkflowRunTimeout(Duration.ofMinutes(5)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(100)) + .setMaximumAttempts(3) + .build() + ) + .build() + + assertEquals(builderOptions, dslOptions) + } + + @Test + fun `ChildWorkflowOptions copy() DSL should merge override options`() { + val sourceOptions = ChildWorkflowOptions { + setTaskQueue("TestQueue") + setWorkflowRunTimeout(Duration.ofMinutes(5)) + } + + val overriddenOptions = sourceOptions.copy { + setTaskQueue("NewQueue") + } + + val expectedOptions = ChildWorkflowOptions { + setTaskQueue("NewQueue") + setWorkflowRunTimeout(Duration.ofMinutes(5)) + } + + assertEquals(expectedOptions, overriddenOptions) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ContinueAsNewOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ContinueAsNewOptionsExtTest.kt new file mode 100644 index 0000000000..0c5a21332e --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ContinueAsNewOptionsExtTest.kt @@ -0,0 +1,34 @@ +package io.temporal.workflow + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.time.Duration + +class ContinueAsNewOptionsExtTest { + + @Test + fun `ContinueAsNewOptions DSL should set fields correctly`() { + val options = ContinueAsNewOptions { + setTaskQueue("TestQueue") + setWorkflowRunTimeout(Duration.ofMinutes(10)) + } + + assertEquals("TestQueue", options.taskQueue) + assertEquals(Duration.ofMinutes(10), options.workflowRunTimeout) + } + + @Test + fun `ContinueAsNewOptions copy() DSL should override specified fields and preserve others`() { + val sourceOptions = ContinueAsNewOptions { + setTaskQueue("TestQueue") + setWorkflowRunTimeout(Duration.ofMinutes(10)) + } + + val overriddenOptions = sourceOptions.copy { + setTaskQueue("NewQueue") + } + + assertEquals("NewQueue", overriddenOptions.taskQueue) + assertEquals(Duration.ofMinutes(10), overriddenOptions.workflowRunTimeout) + } +} diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/SagaExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/SagaExtTest.kt new file mode 100644 index 0000000000..5e80919462 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/SagaExtTest.kt @@ -0,0 +1,90 @@ +package io.temporal.workflow + +import io.temporal.activity.ActivityInterface +import io.temporal.activity.ActivityMethod +import io.temporal.activity.ActivityOptions +import io.temporal.client.WorkflowClientOptions +import io.temporal.client.WorkflowOptions +import io.temporal.common.converter.DefaultDataConverter +import io.temporal.common.converter.JacksonJsonPayloadConverter +import io.temporal.common.converter.KotlinObjectMapperFactory +import io.temporal.testing.internal.SDKTestWorkflowRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.time.Duration +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean + +class SagaExtTest { + + companion object { + val compensated = AtomicBoolean(false) + val compensationOrder = CopyOnWriteArrayList() + } + + @Rule + @JvmField + val testWorkflowRule: SDKTestWorkflowRule = SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(SagaWorkflowImpl::class.java) + .setActivityImplementations(CompensationActivityImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setDataConverter(DefaultDataConverter(JacksonJsonPayloadConverter(KotlinObjectMapperFactory.new()))) + .build() + ) + .build() + + @ActivityInterface + interface CompensationActivity { + @ActivityMethod + fun compensate(step: Int) + } + + class CompensationActivityImpl : CompensationActivity { + override fun compensate(step: Int) { + compensated.set(true) + compensationOrder.add(step) + } + } + + @WorkflowInterface + interface SagaWorkflow { + @WorkflowMethod + fun execute() + } + + class SagaWorkflowImpl : SagaWorkflow { + override fun execute() { + val activity = Workflow.newActivityStub( + CompensationActivity::class.java, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(5)).build() + ) + // setParallelCompensation(false) means compensations run in reverse-add order (LIFO) + val saga = Saga { setParallelCompensation(false) } + try { + saga.addCompensation { activity.compensate(1) } + saga.addCompensation { activity.compensate(2) } + throw RuntimeException("simulated failure") + } catch (e: Exception) { + saga.compensate() + } + } + } + + @Test + fun `Saga DSL extension should build Saga with options and run compensations`() { + compensated.set(false) + compensationOrder.clear() + val client = testWorkflowRule.workflowClient + val stub = client.newWorkflowStub( + SagaWorkflow::class.java, + WorkflowOptions { setTaskQueue(testWorkflowRule.taskQueue) } + ) + stub.execute() + assertTrue("compensation should have been called", compensated.get()) + // setParallelCompensation(false) runs compensations in reverse (LIFO) order + assertEquals("compensations should run in reverse order", listOf(2, 1), compensationOrder.toList()) + } +} From 2bc7d9b376376554967d7ab3fff9b27d4cd06745 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 12 Jun 2026 12:52:44 -0700 Subject: [PATCH 010/107] Use constants for all failure_reason metrics (#2914) --- .../temporal/internal/worker/NexusWorker.java | 15 +++--- .../internal/worker/WorkflowWorker.java | 12 +++-- ...nisticWorkflowPolicyBlockWorkflowTest.java | 5 +- .../nexus/OperationFailMetricTest.java | 48 +++++++++++++------ .../nexus/SyncClientOperationTest.java | 2 +- .../workflow/nexus/SyncOperationFailTest.java | 2 +- .../io/temporal/serviceclient/MetricsTag.java | 10 ++++ 7 files changed, 64 insertions(+), 30 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java index ac364a747e..a09993c037 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java @@ -350,27 +350,30 @@ private boolean handleNexusTask(NexusTask task, Scope metricsScope) { && result.getResponse().getStartOperation().hasFailure()) { failed = true; Failure f = result.getResponse().getStartOperation().getFailure(); - String operationState; + String taskFailureValue; if (f.hasApplicationFailureInfo()) { - operationState = "failed"; + taskFailureValue = MetricsTag.TASK_FAILURE_VALUE_OPERATION_FAILED; } else { - operationState = "canceled"; + taskFailureValue = MetricsTag.TASK_FAILURE_VALUE_OPERATION_CANCELED; } metricsScope - .tagged(Collections.singletonMap(TASK_FAILURE_TYPE, "operation_" + operationState)) + .tagged(Collections.singletonMap(TASK_FAILURE_TYPE, taskFailureValue)) .counter(MetricsType.NEXUS_EXEC_FAILED_COUNTER) .inc(1); } } catch (TimeoutException e) { log.warn("Nexus task timed out while processing", e); metricsScope - .tagged(Collections.singletonMap(TASK_FAILURE_TYPE, "timeout")) + .tagged( + Collections.singletonMap(TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_TIMEOUT)) .counter(MetricsType.NEXUS_EXEC_FAILED_COUNTER) .inc(1); return true; } catch (Throwable e) { metricsScope - .tagged(Collections.singletonMap(TASK_FAILURE_TYPE, "internal_sdk_error")) + .tagged( + Collections.singletonMap( + TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_INTERNAL_SDK_ERROR)) .counter(MetricsType.NEXUS_EXEC_FAILED_COUNTER) .inc(1); // handler.handle if expected to never throw an exception and return result diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index fbb82e467b..d6aa835a29 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -548,13 +548,13 @@ public void handle(WorkflowTask task) throws Exception { String taskFailureType; switch (taskFailedCause) { case WORKFLOW_TASK_FAILED_CAUSE_NON_DETERMINISTIC_ERROR: - taskFailureType = "NonDeterminismError"; + taskFailureType = MetricsTag.TASK_FAILURE_VALUE_NON_DETERMINISM_ERROR; break; case WORKFLOW_TASK_FAILED_CAUSE_GRPC_MESSAGE_TOO_LARGE: - taskFailureType = "GrpcMessageTooLarge"; + taskFailureType = MetricsTag.TASK_FAILURE_VALUE_GRPC_MESSAGE_TOO_LARGE; break; default: - taskFailureType = "WorkflowError"; + taskFailureType = MetricsTag.TASK_FAILURE_VALUE_WORKFLOW_ERROR; } Scope workflowTaskFailureScope = workflowTypeScope.tagged(ImmutableMap.of(TASK_FAILURE_TYPE, taskFailureType)); @@ -616,10 +616,12 @@ private WorkflowTaskHandler.Result handleTask( if (e instanceof NonDeterministicException) { workflowTaskFailureScope = workflowTaskFailureScope.tagged( - ImmutableMap.of(TASK_FAILURE_TYPE, "NonDeterminismError")); + ImmutableMap.of( + TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_NON_DETERMINISM_ERROR)); } else { workflowTaskFailureScope = - workflowTaskFailureScope.tagged(ImmutableMap.of(TASK_FAILURE_TYPE, "WorkflowError")); + workflowTaskFailureScope.tagged( + ImmutableMap.of(TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_WORKFLOW_ERROR)); } // more detailed logging that we can do here is already done inside `handler` workflowTaskFailureScope diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/determinism/NonDeterministicWorkflowPolicyBlockWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/determinism/NonDeterministicWorkflowPolicyBlockWorkflowTest.java index 29c2f73b1b..bebab3cf41 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/determinism/NonDeterministicWorkflowPolicyBlockWorkflowTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/determinism/NonDeterministicWorkflowPolicyBlockWorkflowTest.java @@ -15,6 +15,7 @@ import io.temporal.common.reporter.TestStatsReporter; import io.temporal.failure.TimeoutFailure; import io.temporal.internal.sync.WorkflowMethodThreadNameStrategy; +import io.temporal.serviceclient.MetricsTag; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.worker.MetricsType; import io.temporal.worker.NonDeterministicException; @@ -91,8 +92,8 @@ public void testNonDeterministicWorkflowPolicyBlockWorkflow() { "TestWorkflowStringArg", "worker_type", "WorkflowWorker", - "failure_reason", - "NonDeterminismError"), + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_NON_DETERMINISM_ERROR), (i) -> i >= 2); } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationFailMetricTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationFailMetricTest.java index 5bfc79c773..c822036df3 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationFailMetricTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationFailMetricTest.java @@ -98,7 +98,9 @@ public void failOperationMetrics() { Assert.assertEquals("intentional failure", applicationFailure.getOriginalMessage()); Map execFailedTags = - getOperationTags().put(MetricsTag.TASK_FAILURE_TYPE, "operation_failed").buildKeepingLast(); + getOperationTags() + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_OPERATION_FAILED) + .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), () -> { @@ -134,7 +136,7 @@ public void cancelOperationMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "operation_canceled") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_OPERATION_CANCELED) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -172,7 +174,9 @@ public void failOperationApplicationErrorMetrics() { Assert.assertEquals("foo", applicationFailure.getDetails().get(String.class)); Map execFailedTags = - getOperationTags().put(MetricsTag.TASK_FAILURE_TYPE, "operation_failed").buildKeepingLast(); + getOperationTags() + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_OPERATION_FAILED) + .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), () -> { @@ -214,7 +218,7 @@ public void cancelOperationApplicationErrorMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "operation_canceled") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_OPERATION_CANCELED) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -250,7 +254,9 @@ public void failOperationMessageApplicationErrorMetrics() { Assert.assertEquals("foo", applicationFailure.getDetails().get(String.class)); Map execFailedTags = - getOperationTags().put(MetricsTag.TASK_FAILURE_TYPE, "operation_failed").buildKeepingLast(); + getOperationTags() + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_OPERATION_FAILED) + .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), () -> { @@ -281,7 +287,9 @@ public void failHandlerBadRequestMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_BAD_REQUEST") + .put( + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -314,7 +322,9 @@ public void failHandlerBadRequestNoCauseMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_BAD_REQUEST") + .put( + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -348,7 +358,9 @@ public void failHandlerAppBadRequestMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_BAD_REQUEST") + .put( + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -385,7 +397,9 @@ public void failHandlerMessageAppBadRequestMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_BAD_REQUEST") + .put( + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -418,7 +432,9 @@ public void failHandlerAlreadyStartedMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_BAD_REQUEST") + .put( + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -443,7 +459,7 @@ public void failHandlerRetryableApplicationFailureMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_INTERNAL") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -485,7 +501,7 @@ public void failHandlerNonRetryableApplicationFailureMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_INTERNAL") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -507,7 +523,9 @@ public void failHandlerSleepMetrics() throws InterruptedException { Assert.assertThrows(WorkflowFailedException.class, () -> workflowStub.execute("sleep")); Map execFailedTags = - getOperationTags().put(MetricsTag.TASK_FAILURE_TYPE, "timeout").buildKeepingLast(); + getOperationTags() + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_TIMEOUT) + .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), () -> { @@ -529,7 +547,7 @@ public void failHandlerErrorMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_INTERNAL") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), @@ -564,7 +582,7 @@ public void handlerErrorNonRetryableMetrics() { Map execFailedTags = getOperationTags() - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_INTERNAL") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(3), diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncClientOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncClientOperationTest.java index 4eb5e9868f..14ad5a632a 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncClientOperationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncClientOperationTest.java @@ -96,7 +96,7 @@ public void syncClientOperationFail() { Map execFailedTags = ImmutableMap.builder() .putAll(operationTags) - .put(MetricsTag.TASK_FAILURE_TYPE, "handler_error_INTERNAL") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL) .buildKeepingLast(); reporter.assertCounter(MetricsType.NEXUS_EXEC_FAILED_COUNTER, execFailedTags, 1); } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncOperationFailTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncOperationFailTest.java index 494f1ae0ae..b37114d392 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncOperationFailTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SyncOperationFailTest.java @@ -56,7 +56,7 @@ public void failSyncOperation() { .put(MetricsTag.TASK_QUEUE, testWorkflowRule.getTaskQueue()) .put(MetricsTag.NEXUS_SERVICE, "TestNexusService1") .put(MetricsTag.NEXUS_OPERATION, "operation") - .put(MetricsTag.TASK_FAILURE_TYPE, "operation_failed") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_OPERATION_FAILED) .buildKeepingLast(); Eventually.assertEventually( Duration.ofSeconds(1), diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java index b4d4ad7ad6..e41533e612 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java @@ -24,6 +24,16 @@ public class MetricsTag { public static final String EXCEPTION = "exception"; public static final String OPERATION_NAME = "operation"; public static final String TASK_FAILURE_TYPE = "failure_reason"; + public static final String TASK_FAILURE_VALUE_NON_DETERMINISM_ERROR = "NonDeterminismError"; + public static final String TASK_FAILURE_VALUE_GRPC_MESSAGE_TOO_LARGE = "GrpcMessageTooLarge"; + public static final String TASK_FAILURE_VALUE_WORKFLOW_ERROR = "WorkflowError"; + public static final String TASK_FAILURE_VALUE_OPERATION_FAILED = "operation_failed"; + public static final String TASK_FAILURE_VALUE_OPERATION_CANCELED = "operation_canceled"; + public static final String TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST = + "handler_error_BAD_REQUEST"; + public static final String TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL = "handler_error_INTERNAL"; + public static final String TASK_FAILURE_VALUE_TIMEOUT = "timeout"; + public static final String TASK_FAILURE_VALUE_INTERNAL_SDK_ERROR = "internal_sdk_error"; public static final String POLLER_TYPE = "poller_type"; /** Used to pass metrics scope to the interceptor */ From 7390e05bc9219f179887082f4a67fd74402e2833 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Mon, 15 Jun 2026 12:48:58 -0400 Subject: [PATCH 011/107] feat(extstore): add initial extstore types (#2900) * Add initial extstore types. * Synthesize extstore driver if only one driver is given with no selector to remove nullable types. * Remove nullable payloadSizeThreshold. 0 means store everything. * Update extstore builder to use checkState instead of checkArguments. * Require extstore drivers to give an explicit type. Relying on defaults could be unstable. * Add equals and hash conformance for extstore types. * Add a convenience overload for setDrivers for varargs. * Add comments to storage driver info classes. * Make extstore threshold package level rather than private. * Add extstore test for last-setDriver-wins scenarion and update doc comments. * Refactor storage driver context to be interface. * rename ExternalStorage to ExternalStorageOptions. * remove duplicate ExternalStorage class. --- .../storage/ExternalStorageOptions.java | 108 ++++++++++++++++ .../payload/storage/StorageDriver.java | 44 +++++++ .../storage/StorageDriverActivityInfo.java | 76 +++++++++++ .../payload/storage/StorageDriverClaim.java | 44 +++++++ .../storage/StorageDriverRetrieveContext.java | 12 ++ .../storage/StorageDriverSelector.java | 18 +++ .../storage/StorageDriverStoreContext.java | 20 +++ .../storage/StorageDriverTargetInfo.java | 11 ++ .../storage/StorageDriverWorkflowInfo.java | 76 +++++++++++ .../storage/ExternalStorageOptionsTest.java | 120 ++++++++++++++++++ 10 files changed, 529 insertions(+) create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java create mode 100644 temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java new file mode 100644 index 0000000000..1486fb76b0 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java @@ -0,0 +1,108 @@ +package io.temporal.payload.storage; + +import com.google.common.base.Preconditions; +import io.temporal.common.Experimental; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Configuration for offloading large payloads to external storage. */ +@Experimental +public final class ExternalStorageOptions { + static final int DEFAULT_PAYLOAD_SIZE_THRESHOLD = 256 * 1024; + + public static Builder newBuilder() { + return new Builder(); + } + + private final @Nonnull List drivers; + private final @Nonnull StorageDriverSelector driverSelector; + private final int payloadSizeThreshold; + + private ExternalStorageOptions( + @Nonnull List drivers, + @Nonnull StorageDriverSelector driverSelector, + int payloadSizeThreshold) { + this.drivers = Collections.unmodifiableList(new ArrayList<>(drivers)); + this.driverSelector = driverSelector; + this.payloadSizeThreshold = payloadSizeThreshold; + } + + @Nonnull + public List getDrivers() { + return drivers; + } + + @Nonnull + public StorageDriverSelector getDriverSelector() { + return driverSelector; + } + + /** + * Minimum payload size in bytes before external storage is considered. {@code 0} stores all + * payloads. Defaults to 256 KiB. + */ + public int getPayloadSizeThreshold() { + return payloadSizeThreshold; + } + + public static final class Builder { + private List drivers = Collections.emptyList(); + private StorageDriverSelector driverSelector; + private int payloadSizeThreshold = ExternalStorageOptions.DEFAULT_PAYLOAD_SIZE_THRESHOLD; + + private Builder() {} + + /** + * At least one driver is required. When more than one is set, a selector is also required. If + * this is called multiple times, the last one wins and previous drivers are overwritten. + */ + public Builder setDrivers(@Nonnull List drivers) { + this.drivers = Objects.requireNonNull(drivers, "drivers"); + return this; + } + + /** Convenience for registering a single driver; no selector is needed in this case. */ + public Builder setDriver(@Nonnull StorageDriver driver) { + return setDrivers(Collections.singletonList(Objects.requireNonNull(driver, "driver"))); + } + + /** Required when more than one driver is registered; with a single driver it is optional. */ + public Builder setDriverSelector(@Nullable StorageDriverSelector driverSelector) { + this.driverSelector = driverSelector; + return this; + } + + /** Set to {@code 0} to store all payloads. Defaults to 256 KiB. */ + public Builder setPayloadSizeThreshold(int payloadSizeThreshold) { + this.payloadSizeThreshold = payloadSizeThreshold; + return this; + } + + public ExternalStorageOptions build() { + Preconditions.checkState(!drivers.isEmpty(), "At least one driver must be provided"); + Preconditions.checkState( + payloadSizeThreshold >= 0, "payloadSizeThreshold must be greater than or equal to zero"); + Set names = new HashSet<>(); + for (StorageDriver driver : drivers) { + String name = driver.getName(); + Preconditions.checkState( + names.add(name), "Multiple drivers registered with name '%s'", name); + } + Preconditions.checkState( + drivers.size() == 1 || driverSelector != null, + "driverSelector must be specified when more than one driver is registered"); + StorageDriverSelector selector = driverSelector; + if (selector == null) { + StorageDriver driver = drivers.get(0); + selector = (context, payload) -> driver; + } + return new ExternalStorageOptions(drivers, selector, payloadSizeThreshold); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java new file mode 100644 index 0000000000..3239250759 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java @@ -0,0 +1,44 @@ +package io.temporal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +/** Stores and retrieves payloads in an external storage system. */ +@Experimental +public interface StorageDriver { + /** + * Name of this driver instance, unique among the drivers registered in a single {@link + * ExternalStorageOptions}. Used as the routing key recorded in a stored payload's reference and + * resolved back to this driver on retrieval. + */ + @Nonnull + String getName(); + + /** + * Stable, implementation-level identifier for this driver, the same across all instances of the + * driver class and ideally across SDKs (e.g. {@code "aws.s3driver"}). Used for metrics and worker + * heartbeat reporting, so it must not be derived from anything that changes between versions or + * refactors. + */ + @Nonnull + String getType(); + + /** + * Stores {@code payloads} and returns one {@link StorageDriverClaim} per payload, in the same + * order. The returned list must be the same length as {@code payloads}. + */ + @Nonnull + CompletableFuture> store( + @Nonnull StorageDriverStoreContext context, @Nonnull List payloads); + + /** + * Retrieves the payloads identified by {@code claims} and returns one {@link Payload} per claim, + * in the same order. The returned list must be the same length as {@code claims}. + */ + @Nonnull + CompletableFuture> retrieve( + @Nonnull StorageDriverRetrieveContext context, @Nonnull List claims); +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java new file mode 100644 index 0000000000..52b2d9e0d5 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverActivityInfo.java @@ -0,0 +1,76 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Identity of the activity a payload is being stored on behalf of. Provided to a {@link + * StorageDriver} via {@link StorageDriverStoreContext#getTarget()}. All fields except {@code + * namespace} are best-effort and may be {@code null} when not available at store time. + */ +@Experimental +public final class StorageDriverActivityInfo implements StorageDriverTargetInfo { + private final @Nonnull String namespace; + private final @Nullable String id; + private final @Nullable String runId; + private final @Nullable String type; + + /** + * @param namespace the activity's namespace; must not be {@code null} + * @param id the activity ID, or {@code null} if not available + * @param runId the activity run ID (standalone activities), or {@code null} if not available + * @param type the activity type name, or {@code null} if not available + */ + public StorageDriverActivityInfo( + @Nonnull String namespace, + @Nullable String id, + @Nullable String runId, + @Nullable String type) { + this.namespace = Objects.requireNonNull(namespace, "namespace"); + this.id = id; + this.runId = runId; + this.type = type; + } + + @Nonnull + public String getNamespace() { + return namespace; + } + + @Nullable + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nullable + public String getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StorageDriverActivityInfo)) { + return false; + } + StorageDriverActivityInfo that = (StorageDriverActivityInfo) o; + return namespace.equals(that.namespace) + && Objects.equals(id, that.id) + && Objects.equals(runId, that.runId) + && Objects.equals(type, that.type); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, id, runId, type); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java new file mode 100644 index 0000000000..21a9cc6f9f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverClaim.java @@ -0,0 +1,44 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** + * Driver-defined reference to an externally stored payload, used to retrieve it later. + * + * @see StorageDriver + */ +@Experimental +public final class StorageDriverClaim { + private final @Nonnull Map claimData; + + public StorageDriverClaim(@Nonnull Map claimData) { + this.claimData = + Collections.unmodifiableMap(new HashMap<>(Objects.requireNonNull(claimData, "claimData"))); + } + + @Nonnull + public Map getClaimData() { + return claimData; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StorageDriverClaim)) { + return false; + } + return claimData.equals(((StorageDriverClaim) o).claimData); + } + + @Override + public int hashCode() { + return claimData.hashCode(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java new file mode 100644 index 0000000000..77f11c750d --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java @@ -0,0 +1,12 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; + +/** + * Context passed to {@link StorageDriver#retrieve}. + * + *

Implemented by the SDK and passed to the driver. Driver authors do not implement this in + * production code, only when constructing instances for their own tests. + */ +@Experimental +public interface StorageDriverRetrieveContext {} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java new file mode 100644 index 0000000000..431622e2fa --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java @@ -0,0 +1,18 @@ +package io.temporal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Chooses which {@link StorageDriver} stores a given payload. */ +@Experimental +@FunctionalInterface +public interface StorageDriverSelector { + /** + * Returns the driver to store {@code payload}, which must be one of the drivers registered in the + * {@link ExternalStorageOptions}, or {@code null} to leave the payload stored inline. + */ + @Nullable + StorageDriver selectDriver(@Nonnull StorageDriverStoreContext context, @Nonnull Payload payload); +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java new file mode 100644 index 0000000000..723655bf12 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java @@ -0,0 +1,20 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Context passed to {@link StorageDriver#store} and {@link StorageDriverSelector}. + * + *

Implemented by the SDK and passed to the driver. Driver authors do not implement this in + * production code, only when constructing instances for their own tests. + */ +@Experimental +public interface StorageDriverStoreContext { + /** + * Identity of the workflow or activity the payload is being stored for, or {@code null} when it + * is not available. + */ + @Nullable + StorageDriverTargetInfo getTarget(); +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java new file mode 100644 index 0000000000..f70bc08ed1 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverTargetInfo.java @@ -0,0 +1,11 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; + +/** + * Identity of the workflow or activity a payload is being stored on behalf of. Provided on a + * best-effort basis on the storing side only; some fields may be absent. Implemented by {@link + * StorageDriverWorkflowInfo} and {@link StorageDriverActivityInfo}. + */ +@Experimental +public interface StorageDriverTargetInfo {} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java new file mode 100644 index 0000000000..455025a8b0 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverWorkflowInfo.java @@ -0,0 +1,76 @@ +package io.temporal.payload.storage; + +import io.temporal.common.Experimental; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Identity of the workflow a payload is being stored on behalf of. Provided to a {@link + * StorageDriver} via {@link StorageDriverStoreContext#getTarget()}. All fields except {@code + * namespace} are best-effort and may be {@code null} when not available at store time. + */ +@Experimental +public final class StorageDriverWorkflowInfo implements StorageDriverTargetInfo { + private final @Nonnull String namespace; + private final @Nullable String id; + private final @Nullable String runId; + private final @Nullable String type; + + /** + * @param namespace the workflow's namespace; must not be {@code null} + * @param id the workflow ID, or {@code null} if not available + * @param runId the workflow run ID, or {@code null} if not available + * @param type the workflow type name, or {@code null} if not available + */ + public StorageDriverWorkflowInfo( + @Nonnull String namespace, + @Nullable String id, + @Nullable String runId, + @Nullable String type) { + this.namespace = Objects.requireNonNull(namespace, "namespace"); + this.id = id; + this.runId = runId; + this.type = type; + } + + @Nonnull + public String getNamespace() { + return namespace; + } + + @Nullable + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nullable + public String getType() { + return type; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof StorageDriverWorkflowInfo)) { + return false; + } + StorageDriverWorkflowInfo that = (StorageDriverWorkflowInfo) o; + return namespace.equals(that.namespace) + && Objects.equals(id, that.id) + && Objects.equals(runId, that.runId) + && Objects.equals(type, that.type); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, id, runId, type); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java new file mode 100644 index 0000000000..bc2ed1bc5b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java @@ -0,0 +1,120 @@ +package io.temporal.payload.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +import io.temporal.api.common.v1.Payload; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +public class ExternalStorageOptionsTest { + + private static StorageDriverStoreContext storeContext(StorageDriverTargetInfo target) { + return new StorageDriverStoreContext() { + @Override + public StorageDriverTargetInfo getTarget() { + return target; + } + }; + } + + private static StorageDriver driver(String name) { + return new StorageDriver() { + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + }; + } + + @Test + public void singleDriverNoSelectorSynthesizesSelector() { + StorageDriver a = driver("a"); + ExternalStorageOptions storage = ExternalStorageOptions.newBuilder().setDriver(a).build(); + assertEquals(1, storage.getDrivers().size()); + StorageDriverSelector selector = storage.getDriverSelector(); + assertNotNull(selector); + assertSame(a, selector.selectDriver(storeContext(null), Payload.getDefaultInstance())); + } + + @Test + public void multipleDriversWithSelectorIsValid() { + StorageDriver a = driver("a"); + ExternalStorageOptions storage = + ExternalStorageOptions.newBuilder() + .setDrivers(Arrays.asList(a, driver("b"))) + .setDriverSelector((context, payload) -> a) + .build(); + assertEquals(2, storage.getDrivers().size()); + assertNotNull(storage.getDriverSelector()); + } + + @Test + public void lastSetDriversWins() { + StorageDriver a = driver("a"); + StorageDriver b = driver("b"); + StorageDriver c = driver("c"); + ExternalStorageOptions storage = + ExternalStorageOptions.newBuilder() + .setDrivers(Arrays.asList(a, b)) + .setDrivers(Collections.singletonList(c)) + .build(); + assertEquals(Collections.singletonList(c), storage.getDrivers()); + } + + @Test + public void zeroThresholdStoresAll() { + ExternalStorageOptions storage = + ExternalStorageOptions.newBuilder() + .setDrivers(Collections.singletonList(driver("a"))) + .setPayloadSizeThreshold(0) + .build(); + assertEquals(0, storage.getPayloadSizeThreshold()); + } + + @Test(expected = IllegalStateException.class) + public void noDriversRejected() { + ExternalStorageOptions.newBuilder().build(); + } + + @Test(expected = IllegalStateException.class) + public void duplicateDriverNamesRejected() { + ExternalStorageOptions.newBuilder() + .setDrivers(Arrays.asList(driver("dup"), driver("dup"))) + .build(); + } + + @Test(expected = IllegalStateException.class) + public void multipleDriversRequireSelector() { + ExternalStorageOptions.newBuilder().setDrivers(Arrays.asList(driver("a"), driver("b"))).build(); + } + + @Test(expected = IllegalStateException.class) + public void negativeThresholdRejected() { + ExternalStorageOptions.newBuilder() + .setDrivers(Collections.singletonList(driver("a"))) + .setPayloadSizeThreshold(-1) + .build(); + } +} From 78d0fee13f4d756b0dd836ed0a49a5c67d19da13 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Mon, 15 Jun 2026 11:35:01 -0700 Subject: [PATCH 012/107] Add backoff start for CAN (#2913) --- .../internal/sync/SyncWorkflowContext.java | 4 ++ .../workflow/ContinueAsNewOptions.java | 49 +++++++++++++++++++ .../sync/SyncWorkflowContextTest.java | 43 ++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index f83d2bcd4a..1517c9257e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -1376,6 +1376,10 @@ public void continueAsNew(ContinueAsNewInput input) { attributes.setWorkflowTaskTimeout( ProtobufTimeUtils.toProtoDuration(options.getWorkflowTaskTimeout())); } + if (options.getBackoffStartInterval() != null) { + attributes.setBackoffStartInterval( + ProtobufTimeUtils.toProtoDuration(options.getBackoffStartInterval())); + } if (options.getTaskQueue() != null && !options.getTaskQueue().isEmpty()) { attributes.setTaskQueue(TaskQueue.newBuilder().setName(options.getTaskQueue())); } diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/ContinueAsNewOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/ContinueAsNewOptions.java index ab9e2b7c58..8a4c6b5ccf 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/ContinueAsNewOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/ContinueAsNewOptions.java @@ -37,6 +37,7 @@ public static final class Builder { private String taskQueue; private RetryOptions retryOptions; private Duration workflowTaskTimeout; + private Duration backoffStartInterval; private Map memo; private Map searchAttributes; private SearchAttributes typedSearchAttributes; @@ -57,6 +58,7 @@ private Builder(ContinueAsNewOptions options) { this.taskQueue = options.taskQueue; this.retryOptions = options.retryOptions; this.workflowTaskTimeout = options.workflowTaskTimeout; + this.backoffStartInterval = options.backoffStartInterval; this.memo = options.getMemo(); this.searchAttributes = options.getSearchAttributes(); this.typedSearchAttributes = options.getTypedSearchAttributes(); @@ -85,6 +87,12 @@ public Builder setWorkflowTaskTimeout(Duration workflowTaskTimeout) { return this; } + /** Sets the delay before the first workflow task of the continued run is scheduled. */ + public Builder setBackoffStartInterval(Duration backoffStartInterval) { + this.backoffStartInterval = backoffStartInterval; + return this; + } + public Builder setMemo(Map memo) { this.memo = memo; return this; @@ -152,6 +160,7 @@ public ContinueAsNewOptions build() { taskQueue, retryOptions, workflowTaskTimeout, + backoffStartInterval, memo, searchAttributes, typedSearchAttributes, @@ -165,6 +174,7 @@ public ContinueAsNewOptions build() { private final @Nullable String taskQueue; private final @Nullable RetryOptions retryOptions; private final @Nullable Duration workflowTaskTimeout; + private final @Nullable Duration backoffStartInterval; private final @Nullable Map memo; private final @Nullable Map searchAttributes; private final @Nullable SearchAttributes typedSearchAttributes; @@ -175,6 +185,10 @@ public ContinueAsNewOptions build() { private final @Nullable InitialVersioningBehavior initialVersioningBehavior; + /** + * @deprecated This constructor doesn't include all options. Use the builder instead. + */ + @Deprecated public ContinueAsNewOptions( @Nullable Duration workflowRunTimeout, @Nullable String taskQueue, @@ -186,10 +200,37 @@ public ContinueAsNewOptions( @Nullable List contextPropagators, @SuppressWarnings("deprecation") @Nullable VersioningIntent versioningIntent, @Nullable InitialVersioningBehavior initialVersioningBehavior) { + this( + workflowRunTimeout, + taskQueue, + retryOptions, + workflowTaskTimeout, + null, + memo, + searchAttributes, + typedSearchAttributes, + contextPropagators, + versioningIntent, + initialVersioningBehavior); + } + + ContinueAsNewOptions( + @Nullable Duration workflowRunTimeout, + @Nullable String taskQueue, + @Nullable RetryOptions retryOptions, + @Nullable Duration workflowTaskTimeout, + @Nullable Duration backoffStartInterval, + @Nullable Map memo, + @Nullable Map searchAttributes, + @Nullable SearchAttributes typedSearchAttributes, + @Nullable List contextPropagators, + @SuppressWarnings("deprecation") @Nullable VersioningIntent versioningIntent, + @Nullable InitialVersioningBehavior initialVersioningBehavior) { this.workflowRunTimeout = workflowRunTimeout; this.taskQueue = taskQueue; this.retryOptions = retryOptions; this.workflowTaskTimeout = workflowTaskTimeout; + this.backoffStartInterval = backoffStartInterval; this.memo = memo; this.searchAttributes = searchAttributes; this.typedSearchAttributes = typedSearchAttributes; @@ -215,6 +256,14 @@ public RetryOptions getRetryOptions() { return workflowTaskTimeout; } + /** + * @return the delay before the first workflow task of the continued run is scheduled, or null if + * unset. + */ + public @Nullable Duration getBackoffStartInterval() { + return backoffStartInterval; + } + public @Nullable Map getMemo() { return memo; } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/sync/SyncWorkflowContextTest.java b/temporal-sdk/src/test/java/io/temporal/internal/sync/SyncWorkflowContextTest.java index 9325f84207..55bdc3b46b 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/sync/SyncWorkflowContextTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/sync/SyncWorkflowContextTest.java @@ -1,16 +1,27 @@ package io.temporal.internal.sync; +import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import io.temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes; import io.temporal.api.common.v1.SearchAttributes; +import io.temporal.api.common.v1.WorkflowType; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowOutboundCallsInterceptor.ContinueAsNewInput; import io.temporal.internal.common.SearchAttributesUtil; import io.temporal.internal.replay.ReplayWorkflowContext; +import io.temporal.workflow.ContinueAsNewOptions; +import java.time.Duration; import java.util.HashMap; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; public class SyncWorkflowContextTest { SyncWorkflowContext context; @@ -37,4 +48,36 @@ public void testUpsertSearchAttributesException() { Map attr = new HashMap<>(); context.upsertSearchAttributes(attr); } + + @Test + public void testContinueAsNewBackoffStartInterval() { + ExecutorService threadPool = Executors.newCachedThreadPool(); + + Duration backoffStartInterval = Duration.ofSeconds(7); + ContinueAsNewOptions options = + ContinueAsNewOptions.newBuilder().setBackoffStartInterval(backoffStartInterval).build(); + DeterministicRunner runner = + DeterministicRunner.newRunner( + threadPool::submit, + context, + () -> + context.continueAsNew( + new ContinueAsNewInput(null, options, new Object[0], Header.empty()))); + + try { + when(mockReplayWorkflowContext.getWorkflowType()) + .thenReturn(WorkflowType.newBuilder().setName("dummy-workflow").build()); + + runner.runUntilAllBlocked(DeterministicRunner.DEFAULT_DEADLOCK_DETECTION_TIMEOUT_MS); + + ArgumentCaptor attributes = + ArgumentCaptor.forClass(ContinueAsNewWorkflowExecutionCommandAttributes.class); + verify(mockReplayWorkflowContext).continueAsNewOnCompletion(attributes.capture()); + assertEquals(7, attributes.getValue().getBackoffStartInterval().getSeconds()); + } finally { + + runner.close(); + threadPool.shutdown(); + } + } } From f3edb105082f8aa0abc4391a656bdce6797fd749 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Mon, 15 Jun 2026 13:23:01 -0700 Subject: [PATCH 013/107] Add GZIP compression defaulting to on (#2911) --- .../workflow/GrpcMessageTooLargeTest.java | 8 +- .../serviceclient/ChannelManager.java | 5 + .../serviceclient/GrpcCompression.java | 23 +++++ .../GrpcCompressionInterceptor.java | 21 +++++ .../serviceclient/ServiceStubsOptions.java | 47 +++++++++- .../serviceclient/GrpcCompressionTest.java | 91 +++++++++++++++++++ .../ServiceStubsOptionsTest.java | 26 ++++++ 7 files changed, 212 insertions(+), 9 deletions(-) create mode 100644 temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java create mode 100644 temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java create mode 100644 temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/GrpcMessageTooLargeTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/GrpcMessageTooLargeTest.java index 89956c32bd..20b4ec3368 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/GrpcMessageTooLargeTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/GrpcMessageTooLargeTest.java @@ -22,8 +22,8 @@ import org.junit.Test; public class GrpcMessageTooLargeTest { - private static final String QUERY_ERROR_MESSAGE = - "Failed to send query response: RESOURCE_EXHAUSTED: grpc: received message larger than max"; + // This string is kept intentionally short to match multiple possible too-large error messages + private static final String TOO_BIG_ERR_MESSAGE = "larger than max"; private static final String VERY_LARGE_DATA; static { @@ -120,7 +120,7 @@ public void queryResultTooLarge() { assertNotNull(e.getCause()); // The exception will not contain the original failure object, so instead of type check we're // checking the message to ensure the correct error is being sent. - assertTrue(e.getCause().getMessage().contains(QUERY_ERROR_MESSAGE)); + assertTrue(e.getCause().getMessage().contains(TOO_BIG_ERR_MESSAGE)); } @Test @@ -132,7 +132,7 @@ public void queryErrorTooLarge() { WorkflowQueryException e = assertThrows(WorkflowQueryException.class, workflow::query); assertNotNull(e.getCause()); - assertTrue(e.getCause().getMessage().contains(QUERY_ERROR_MESSAGE)); + assertTrue(e.getCause().getMessage().contains(TOO_BIG_ERR_MESSAGE)); } private static T createWorkflowStub(Class clazz, SDKTestWorkflowRule workflowRule) { diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ChannelManager.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ChannelManager.java index da39c740f2..4d32633d36 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ChannelManager.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ChannelManager.java @@ -159,6 +159,11 @@ private Channel applyHeadStandardInterceptors(Channel channel) { } } + if (options.getGrpcCompression() != GrpcCompression.NONE) { + channel = + ClientInterceptors.intercept( + channel, new GrpcCompressionInterceptor(options.getGrpcCompression())); + } return ClientInterceptors.intercept( channel, MetadataUtils.newAttachHeadersInterceptor(headers), diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java new file mode 100644 index 0000000000..1e2e5d60a4 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java @@ -0,0 +1,23 @@ +package io.temporal.serviceclient; + +import javax.annotation.Nullable; + +/** Selects outbound transport-level gRPC compression for service calls. */ +public enum GrpcCompression { + /** Do not compress requests. */ + NONE(null), + + /** Gzip-compress requests. */ + GZIP("gzip"); + + private final @Nullable String compressorName; + + GrpcCompression(@Nullable String compressorName) { + this.compressorName = compressorName; + } + + @Nullable + String getCompressorName() { + return compressorName; + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java new file mode 100644 index 0000000000..4d605ab332 --- /dev/null +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java @@ -0,0 +1,21 @@ +package io.temporal.serviceclient; + +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.MethodDescriptor; + +final class GrpcCompressionInterceptor implements ClientInterceptor { + private final GrpcCompression compression; + + GrpcCompressionInterceptor(GrpcCompression compression) { + this.compression = compression; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return next.newCall(method, callOptions.withCompression(compression.getCompressorName())); + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java index 0c3d8ae3ad..5ab5ddce6e 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java @@ -114,6 +114,9 @@ public class ServiceStubsOptions { protected final Scope metricsScope; + /** Outbound transport-level gRPC compression. */ + protected final GrpcCompression grpcCompression; + ServiceStubsOptions(ServiceStubsOptions that) { this.channel = that.channel; this.target = that.target; @@ -135,6 +138,7 @@ public class ServiceStubsOptions { this.grpcMetadataProviders = that.grpcMetadataProviders; this.grpcClientInterceptors = that.grpcClientInterceptors; this.metricsScope = that.metricsScope; + this.grpcCompression = that.grpcCompression; } ServiceStubsOptions( @@ -157,7 +161,8 @@ public class ServiceStubsOptions { Metadata headers, Collection grpcMetadataProviders, Collection grpcClientInterceptors, - Scope metricsScope) { + Scope metricsScope, + GrpcCompression grpcCompression) { this.channel = channel; this.target = target; this.channelInitializer = channelInitializer; @@ -178,6 +183,7 @@ public class ServiceStubsOptions { this.grpcMetadataProviders = grpcMetadataProviders; this.grpcClientInterceptors = grpcClientInterceptors; this.metricsScope = metricsScope; + this.grpcCompression = grpcCompression; } /** @@ -342,6 +348,15 @@ public Scope getMetricsScope() { return metricsScope; } + /** + * @return outbound transport-level gRPC compression used for requests. + * @see Builder#setGrpcCompression(GrpcCompression) + */ + @Nonnull + public GrpcCompression getGrpcCompression() { + return grpcCompression; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -366,7 +381,8 @@ public boolean equals(Object o) { && Objects.equals(headers, that.headers) && Objects.equals(grpcMetadataProviders, that.grpcMetadataProviders) && Objects.equals(grpcClientInterceptors, that.grpcClientInterceptors) - && Objects.equals(metricsScope, that.metricsScope); + && Objects.equals(metricsScope, that.metricsScope) + && grpcCompression == that.grpcCompression; } @Override @@ -391,7 +407,8 @@ public int hashCode() { headers, grpcMetadataProviders, grpcClientInterceptors, - metricsScope); + metricsScope, + grpcCompression); } @Override @@ -436,6 +453,8 @@ public String toString() { + grpcClientInterceptors + ", metricsScope=" + metricsScope + + ", grpcCompression=" + + grpcCompression + '}'; } @@ -460,6 +479,7 @@ public static class Builder> { private Collection grpcClientInterceptors; private Scope metricsScope; private boolean apiKeyProvided; + private GrpcCompression grpcCompression = GrpcCompression.GZIP; protected Builder() {} @@ -491,6 +511,7 @@ protected Builder(ServiceStubsOptions options) { ? new ArrayList<>(options.grpcClientInterceptors) : null; this.metricsScope = options.metricsScope; + this.grpcCompression = options.grpcCompression; } /** @@ -720,6 +741,20 @@ public T setMetricsScope(Scope metricsScope) { return self(); } + /** + * Sets outbound transport-level gRPC compression. Defaults to {@link GrpcCompression#GZIP}. Set + * to {@link GrpcCompression#NONE} to opt out of compressing requests. + * + *

The SDK uses the default gRPC response decompression registry for all compression options, + * so disabling request compression does not disable accepting compressed responses. + * + * @return {@code this} + */ + public T setGrpcCompression(GrpcCompression grpcCompression) { + this.grpcCompression = Objects.requireNonNull(grpcCompression); + return self(); + } + /** * Set the time to wait between service responses on each health check. * @@ -853,7 +888,8 @@ public ServiceStubsOptions build() { this.headers, this.grpcMetadataProviders, this.grpcClientInterceptors, - this.metricsScope); + this.metricsScope, + this.grpcCompression); } public ServiceStubsOptions validateAndBuildWithDefaults() { @@ -916,7 +952,8 @@ public ServiceStubsOptions validateAndBuildWithDefaults() { headers, grpcMetadataProviders, grpcClientInterceptors, - metricsScope); + metricsScope, + this.grpcCompression); } } } diff --git a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java new file mode 100644 index 0000000000..9488627f80 --- /dev/null +++ b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java @@ -0,0 +1,91 @@ +package io.temporal.serviceclient; + +import static org.junit.Assert.*; + +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerCall; +import io.grpc.ServerCallHandler; +import io.grpc.ServerInterceptor; +import io.grpc.ServerInterceptors; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.StreamObserver; +import io.grpc.testing.GrpcCleanupRule; +import io.temporal.api.workflowservice.v1.GetSystemInfoRequest; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; + +public class GrpcCompressionTest { + private static final Metadata.Key GRPC_ENCODING = + Metadata.Key.of("grpc-encoding", Metadata.ASCII_STRING_MARSHALLER); + private static final Metadata.Key GRPC_ACCEPT_ENCODING = + Metadata.Key.of("grpc-accept-encoding", Metadata.ASCII_STRING_MARSHALLER); + + @Rule public final GrpcCleanupRule grpcCleanupRule = new GrpcCleanupRule(); + + @Test + public void gzipCompressionSendsAndAcceptsGzip() throws Exception { + Metadata headers = callGetSystemInfo(GrpcCompression.GZIP); + + assertEquals("gzip", headers.get(GRPC_ENCODING)); + assertTrue(headers.get(GRPC_ACCEPT_ENCODING).contains("gzip")); + } + + @Test + public void noneCompressionDoesNotSendGzipButStillAcceptsGzip() throws Exception { + Metadata headers = callGetSystemInfo(GrpcCompression.NONE); + + assertNull(headers.get(GRPC_ENCODING)); + assertTrue(headers.get(GRPC_ACCEPT_ENCODING).contains("gzip")); + } + + private Metadata callGetSystemInfo(GrpcCompression compression) throws Exception { + AtomicReference capturedHeaders = new AtomicReference<>(); + ServerInterceptor captureHeadersInterceptor = + new ServerInterceptor() { + @Override + public ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + capturedHeaders.set(headers); + return next.startCall(call, headers); + } + }; + Server server = + grpcCleanupRule.register( + NettyServerBuilder.forPort(0) + .addService( + ServerInterceptors.intercept( + new TestWorkflowService(), captureHeadersInterceptor)) + .build() + .start()); + + WorkflowServiceStubs serviceStubs = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setTarget("127.0.0.1:" + server.getPort()) + .setEnableHttps(false) + .setGrpcCompression(compression) + .build()); + try { + serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); + } finally { + serviceStubs.shutdownNow(); + } + + assertNotNull(capturedHeaders.get()); + return capturedHeaders.get(); + } + + private static final class TestWorkflowService + extends WorkflowServiceGrpc.WorkflowServiceImplBase { + @Override + public void getSystemInfo( + GetSystemInfoRequest request, StreamObserver responseObserver) { + responseObserver.onNext(GetSystemInfoResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + } +} diff --git a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ServiceStubsOptionsTest.java b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ServiceStubsOptionsTest.java index ccadbffe81..0c0e76d745 100644 --- a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ServiceStubsOptionsTest.java +++ b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ServiceStubsOptionsTest.java @@ -151,4 +151,30 @@ public void testSpringBootStyleAutoTLSWithApiKey() { "TLS should be disabled when no API key and no explicit TLS setting", options3.getEnableHttps()); } + + @Test + public void testGrpcCompressionDefaultsToGzip() { + ServiceStubsOptions options = + WorkflowServiceStubsOptions.newBuilder() + .setTarget("localhost:7233") + .validateAndBuildWithDefaults(); + + assertEquals(GrpcCompression.GZIP, options.getGrpcCompression()); + } + + @Test + public void testGrpcCompressionNonePassesThroughBuilderCopy() { + ServiceStubsOptions options = + WorkflowServiceStubsOptions.newBuilder() + .setTarget("localhost:7233") + .setGrpcCompression(GrpcCompression.NONE) + .validateAndBuildWithDefaults(); + + assertEquals(GrpcCompression.NONE, options.getGrpcCompression()); + + ServiceStubsOptions copied = + WorkflowServiceStubsOptions.newBuilder(options).validateAndBuildWithDefaults(); + + assertEquals(GrpcCompression.NONE, copied.getGrpcCompression()); + } } From 3c2d93820b2631de5f5bcd95217a0fe513b3b167 Mon Sep 17 00:00:00 2001 From: Gregory Michael Travis Date: Mon, 15 Jun 2026 17:03:31 -0400 Subject: [PATCH 014/107] Standalone Activities start delay (#2906) * wip * fqn not needed * test timing fixes/tweaks * consistent javadoc * 1 hour start delay for terminate/cancel; disable time-sensitive tests. * retrigger opengrep --- .github/workflows/ci.yml | 1 + .../temporal/client/StartActivityOptions.java | 31 ++- .../client/RootActivityClientInvoker.java | 3 + .../client/StartActivityOptionsTest.java | 19 ++ .../functional/StandaloneActivityTest.java | 181 +++++++++++++++++- 5 files changed, 232 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5083cbf28..7bf551c735 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,7 @@ jobs: --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ --dynamic-config-value frontend.activityAPIsEnabled=true \ --dynamic-config-value activity.enableStandalone=true \ + --dynamic-config-value activity.startDelayEnabled=true \ --dynamic-config-value nexusoperation.enableStandalone=true \ --dynamic-config-value history.enableChasm=true \ --dynamic-config-value history.enableTransitionHistory=true & diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java index 7b9bf46775..7eed754476 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java @@ -45,6 +45,7 @@ public static final class Builder { private @Nullable String staticSummary; private @Nullable String staticDetails; private @Nullable Priority priority; + private @Nullable Duration startDelay; private Builder() {} @@ -65,6 +66,7 @@ private Builder(StartActivityOptions options) { this.staticSummary = options.staticSummary; this.staticDetails = options.staticDetails; this.priority = options.priority; + this.startDelay = options.startDelay; } /** Required. A unique identifier for this activity in the namespace. */ @@ -159,6 +161,20 @@ public Builder setPriority(Priority priority) { return this; } + /** + * Time to wait before dispatching the first activity task. The delay is one-shot — retry + * attempts do not re-apply it. {@code ScheduleToStart} and {@code ScheduleToClose} timeouts + * begin counting only after the delay elapses. Must be non-negative; {@code null} or {@link + * Duration#ZERO} mean no delay. + */ + public Builder setStartDelay(Duration startDelay) { + if (startDelay != null && startDelay.isNegative()) { + throw new IllegalArgumentException("startDelay must be non-negative, got " + startDelay); + } + this.startDelay = startDelay; + return this; + } + public StartActivityOptions build() { Preconditions.checkArgument(!Strings.isNullOrEmpty(id), "id must not be null or empty"); Preconditions.checkArgument( @@ -183,6 +199,7 @@ public StartActivityOptions build() { private final @Nullable String staticSummary; private final @Nullable String staticDetails; private final @Nullable Priority priority; + private final @Nullable Duration startDelay; private StartActivityOptions(Builder builder) { this.id = builder.id; @@ -198,6 +215,7 @@ private StartActivityOptions(Builder builder) { this.staticSummary = builder.staticSummary; this.staticDetails = builder.staticDetails; this.priority = builder.priority; + this.startDelay = builder.startDelay; } public Builder toBuilder() { @@ -265,6 +283,11 @@ public Priority getPriority() { return priority; } + @Nullable + public Duration getStartDelay() { + return startDelay; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -282,7 +305,8 @@ public boolean equals(Object o) { && Objects.equals(typedSearchAttributes, that.typedSearchAttributes) && Objects.equals(staticSummary, that.staticSummary) && Objects.equals(staticDetails, that.staticDetails) - && Objects.equals(priority, that.priority); + && Objects.equals(priority, that.priority) + && Objects.equals(startDelay, that.startDelay); } @Override @@ -300,7 +324,8 @@ public int hashCode() { typedSearchAttributes, staticSummary, staticDetails, - priority); + priority, + startDelay); } @Override @@ -332,6 +357,8 @@ public String toString() { + staticDetails + "', priority=" + priority + + ", startDelay=" + + startDelay + '}'; } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index 16e8c8095d..96a9e70f5a 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -97,6 +97,9 @@ public StartActivityOutput startActivity(StartActivityInput input) { if (options.getPriority() != null) { request.setPriority(ProtoConverters.toProto(options.getPriority())); } + if (options.getStartDelay() != null) { + request.setStartDelay(ProtobufTimeUtils.toProtoDuration(options.getStartDelay())); + } io.temporal.api.common.v1.Header grpcHeader = HeaderUtils.toHeaderGrpc(input.getHeader(), null); request.setHeader(grpcHeader); diff --git a/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java index dfee844b5b..96d0482687 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java @@ -32,6 +32,23 @@ public void testMissingTimeoutFails() { StartActivityOptions.newBuilder().setId("id").setTaskQueue("q").build(); } + @Test(expected = IllegalArgumentException.class) + public void testNegativeStartDelayFails() { + StartActivityOptions.newBuilder().setStartDelay(Duration.ofSeconds(-1)); + } + + @Test + public void testZeroStartDelayAccepted() { + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId("id") + .setTaskQueue("q") + .setStartToCloseTimeout(Duration.ofSeconds(5)) + .setStartDelay(Duration.ZERO) + .build(); + assertEquals(Duration.ZERO, opts.getStartDelay()); + } + @Test public void testToBuilder() { StartActivityOptions original = @@ -64,6 +81,7 @@ public void testToBuilderPreservesAllFields() { .setStaticSummary("summary") .setStaticDetails("details") .setPriority(priority) + .setStartDelay(Duration.ofSeconds(7)) .build(); StartActivityOptions copy = original.toBuilder().build(); @@ -80,5 +98,6 @@ public void testToBuilderPreservesAllFields() { assertEquals("summary", copy.getStaticSummary()); assertEquals("details", copy.getStaticDetails()); assertEquals(priority, copy.getPriority()); + assertEquals(Duration.ofSeconds(7), copy.getStartDelay()); } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java index a54f846cec..5be3226dcd 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java @@ -40,6 +40,8 @@ * test server may not support the standalone activity APIs. */ public class StandaloneActivityTest { + // TODO: enable tests disabled with ths when time-skipping is available + private static boolean RUN_TIME_SENSITIVE_TESTS = false; // --------------------------------------------------------------------------- // Activity interfaces and implementations @@ -96,6 +98,12 @@ public interface AlwaysFailActivity { void alwaysFail(); } + @ActivityInterface + public interface RetryThenSucceedActivity { + @ActivityMethod(name = "RetryThenSucceed") + int run(); + } + /** Snapshot of {@link ActivityInfo} fields captured inside an activity body. */ public static class ActivityInfoSnapshot { public String activityId; @@ -200,6 +208,17 @@ public void alwaysFail() { } } + public static class RetryThenSucceedActivityImpl implements RetryThenSucceedActivity { + @Override + public int run() { + int attempt = Activity.getExecutionContext().getInfo().getAttempt(); + if (attempt == 1) { + throw ApplicationFailure.newFailure("fail on attempt 1", "test-type"); + } + return attempt; + } + } + // --------------------------------------------------------------------------- // Test rule // --------------------------------------------------------------------------- @@ -215,7 +234,8 @@ public void alwaysFail() { new InspectInfoActivityImpl(), new EchoVoidActivityImpl(), new ConcatActivityImpl(), - new AlwaysFailActivityImpl()) + new AlwaysFailActivityImpl(), + new RetryThenSucceedActivityImpl()) .build(); // --------------------------------------------------------------------------- @@ -986,6 +1006,165 @@ public void testOnlyStartToCloseTimeoutIsValid() { newActivityClient().execute(SimpleActivity.class, SimpleActivity::execute, opts, "x")); } + // --------------------------------------------------------------------------- + // Start delay + // --------------------------------------------------------------------------- + + @Test + public void testStartDelayDelaysFirstDispatch() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + Duration delay = Duration.ofSeconds(2); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setStartDelay(delay) + .build(); + + ActivityHandle handle = + newActivityClient().start(SimpleActivity.class, SimpleActivity::execute, opts, "hello"); + assertEquals("echo:hello", handle.getResult()); + + ActivityExecutionDescription desc = handle.describe(); + Duration between = Duration.between(desc.getScheduledTime(), desc.getLastStartedTime()); + assertTrue( + "lastStartedTime - scheduledTime should be >= startDelay - 500ms, was " + between, + between.compareTo(delay.minusMillis(500)) >= 0); + } + + @Test + public void testStartDelayPreservesScheduleToStartTimeout() { + assumeTrue(RUN_TIME_SENSITIVE_TESTS); + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofMinutes(5)) + .setScheduleToStartTimeout(Duration.ofSeconds(1)) + .setStartDelay(Duration.ofSeconds(2)) + .build(); + String result = + newActivityClient().execute(SimpleActivity.class, SimpleActivity::execute, opts, "x"); + assertEquals("echo:x", result); + } + + @Test + public void testStartDelayPreservesScheduleToCloseTimeout() { + assumeTrue(RUN_TIME_SENSITIVE_TESTS); + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofSeconds(1)) + .setStartDelay(Duration.ofSeconds(2)) + .build(); + String result = + newActivityClient().execute(SimpleActivity.class, SimpleActivity::execute, opts, "x"); + assertEquals("echo:x", result); + } + + @Test + public void testStartDelayNotReappliedOnRetry() { + assumeTrue(RUN_TIME_SENSITIVE_TESTS); + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setStartDelay(Duration.ofSeconds(2)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(100)) + .setMaximumAttempts(5) + .build()) + .build(); + long startMs = System.currentTimeMillis(); + int finalAttempt = + newActivityClient() + .execute(RetryThenSucceedActivity.class, RetryThenSucceedActivity::run, opts); + long elapsedMs = System.currentTimeMillis() - startMs; + + // Bug-trap: confirm the activity actually retried rather than succeeding silently on attempt 1. + assertTrue( + "activity should have retried at least once (final attempt was " + finalAttempt + ")", + finalAttempt >= 2); + + // If start delay were re-applied to retries, elapsed would be ~2 * startDelay (~4000ms). + // Without re-application: ~2000ms delay + ~100ms retry interval + worker overhead. + assertTrue( + "retry should not re-apply startDelay; elapsed was " + elapsedMs + "ms", elapsedMs < 3500); + } + + @Test + public void testCancelDuringStartDelay() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setStartDelay(Duration.ofHours(1)) + .build(); + ActivityHandle handle = + newActivityClient().start(SimpleActivity.class, SimpleActivity::execute, opts, "x"); + handle.cancel("test cancel during start delay"); + + assertEventually( + Duration.ofSeconds(10), + () -> + assertEquals( + ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_CANCELED, + handle.describe().getStatus())); + } + + @Test + public void testTerminateDuringStartDelay() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setStartDelay(Duration.ofHours(1)) + .build(); + ActivityHandle handle = + newActivityClient().start(SimpleActivity.class, SimpleActivity::execute, opts, "x"); + handle.terminate("test terminate during start delay"); + + assertEventually( + Duration.ofSeconds(10), + () -> + assertEquals( + ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_TERMINATED, + handle.describe().getStatus())); + } + + @Test + public void testZeroStartDelayBehavesAsUnset() { + assumeTrue(RUN_TIME_SENSITIVE_TESTS); + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setStartDelay(Duration.ZERO) + .build(); + ActivityHandle handle = + newActivityClient().start(SimpleActivity.class, SimpleActivity::execute, opts, "x"); + assertEquals("echo:x", handle.getResult()); + + ActivityExecutionDescription desc = handle.describe(); + Duration between = Duration.between(desc.getScheduledTime(), desc.getLastStartedTime()); + assertTrue( + "Duration.ZERO should not introduce dispatch latency, was " + between, + between.compareTo(Duration.ofSeconds(1)) < 0); + } + // --------------------------------------------------------------------------- // Interceptor helpers // --------------------------------------------------------------------------- From aeac5b19b1af658dbd0fcfa16557841afbfab233 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Tue, 16 Jun 2026 17:43:38 -0400 Subject: [PATCH 015/107] Grant explicit actions:read to features reusable-workflow caller (#2919) --- .github/workflows/features.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/features.yml b/.github/workflows/features.yml index 41891b1b6d..05077f374a 100644 --- a/.github/workflows/features.yml +++ b/.github/workflows/features.yml @@ -3,6 +3,9 @@ on: [push, pull_request] jobs: features-test: + permissions: + contents: read + actions: read uses: temporalio/features/.github/workflows/java.yaml@main with: java-repo-path: ${{github.event.pull_request.head.repo.full_name}} From a1b6fff2181c7bec247d78e1aa9d8112763d6d78 Mon Sep 17 00:00:00 2001 From: Sheepman <38871525+444am@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:24:48 +1000 Subject: [PATCH 016/107] Add OpenTracing interceptor for standalone activities (#2909) * Add OpenTracing interceptor for standalone activities * Align standalone activity tracing spans * Remove activity start span overload * fix minor formatting issue in SAA tracing test. --------- Co-authored-by: Chris Constable --- contrib/temporal-opentracing/README.md | 13 +- .../OpenTracingActivityClientInterceptor.java | 30 +++ .../opentracing/SpanCreationContext.java | 23 ++- .../opentracing/StandardTagNames.java | 1 + .../ActionTypeAndNameSpanBuilderProvider.java | 15 +- ...TracingActivityClientCallsInterceptor.java | 43 +++++ ...racingActivityInboundCallsInterceptor.java | 1 + ...acingWorkflowOutboundCallsInterceptor.java | 2 +- .../opentracing/internal/SpanFactory.java | 13 +- .../StandaloneActivityClientTracingTest.java | 174 ++++++++++++++++++ .../StandaloneActivityWorkerTracingTest.java | 93 ++++++++++ 11 files changed, 398 insertions(+), 10 deletions(-) create mode 100644 contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/OpenTracingActivityClientInterceptor.java create mode 100644 contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityClientCallsInterceptor.java create mode 100644 contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java create mode 100644 contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityWorkerTracingTest.java diff --git a/contrib/temporal-opentracing/README.md b/contrib/temporal-opentracing/README.md index 8cedf58f91..c9483783ec 100644 --- a/contrib/temporal-opentracing/README.md +++ b/contrib/temporal-opentracing/README.md @@ -6,7 +6,7 @@ This module provides a set of Interceptors that adds support for OpenTracing Spa You want to register two interceptors - one on the Temporal client side, another on the worker side: -1. Client configuration: +1. Workflow Client configuration: ```java WorkflowClientOptions.newBuilder() //... @@ -21,6 +21,16 @@ You want to register two interceptors - one on the Temporal client side, another .build(); ``` +3. Standalone Activity Client configuration: + ```java + ActivityClientOptions activityClientOptions = + ActivityClientOptions.newBuilder() + //... + .setInterceptors(Collections.singletonList(new OpenTracingActivityClientInterceptor())) + .build(); + ActivityClient activityClient = ActivityClient.newInstance(service, activityClientOptions); + ``` + ## [OpenTelemetry](https://opentelemetry.io/) OpenTracing has been merged into OpenTelemetry and nowadays OpenTelemetry should be a preferred solution. @@ -38,4 +48,3 @@ to hook their OpenTelemetry setup and make it available for OpenTracing API: GlobalTracer.registerIfAbsent(tracer); ``` - diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/OpenTracingActivityClientInterceptor.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/OpenTracingActivityClientInterceptor.java new file mode 100644 index 0000000000..0addec6d5e --- /dev/null +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/OpenTracingActivityClientInterceptor.java @@ -0,0 +1,30 @@ +package io.temporal.opentracing; + +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.common.interceptors.ActivityClientInterceptorBase; +import io.temporal.opentracing.internal.ContextAccessor; +import io.temporal.opentracing.internal.OpenTracingActivityClientCallsInterceptor; +import io.temporal.opentracing.internal.SpanFactory; + +public class OpenTracingActivityClientInterceptor extends ActivityClientInterceptorBase { + private final OpenTracingOptions options; + private final SpanFactory spanFactory; + private final ContextAccessor contextAccessor; + + public OpenTracingActivityClientInterceptor() { + this(OpenTracingOptions.getDefaultInstance()); + } + + public OpenTracingActivityClientInterceptor(OpenTracingOptions options) { + this.options = options; + this.spanFactory = new SpanFactory(options); + this.contextAccessor = new ContextAccessor(options); + } + + @Override + public ActivityClientCallsInterceptor activityClientCallsInterceptor( + ActivityClientCallsInterceptor next) { + return new OpenTracingActivityClientCallsInterceptor( + next, options, spanFactory, contextAccessor); + } +} diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanCreationContext.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanCreationContext.java index a2756afdde..c419a04976 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanCreationContext.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanCreationContext.java @@ -14,6 +14,7 @@ public class SpanCreationContext { private final String runId; private final String parentWorkflowId; private final String parentRunId; + private final String activityId; private SpanCreationContext( SpanOperationType spanOperationType, @@ -21,13 +22,15 @@ private SpanCreationContext( String workflowId, String runId, String parentWorkflowId, - String parentRunId) { + String parentRunId, + String activityId) { this.spanOperationType = spanOperationType; this.actionName = actionName; this.workflowId = workflowId; this.runId = runId; this.parentWorkflowId = parentWorkflowId; this.parentRunId = parentRunId; + this.activityId = activityId; } public SpanOperationType getSpanOperationType() { @@ -59,6 +62,10 @@ public String getParentRunId() { return parentRunId; } + public @Nullable String getActivityId() { + return activityId; + } + public static Builder newBuilder() { return new Builder(); } @@ -70,6 +77,7 @@ public static final class Builder { private String runId; private String parentWorkflowId; private String parentRunId; + private String activityId; private Builder() {} @@ -103,9 +111,20 @@ public Builder setParentRunId(String parentRunId) { return this; } + public Builder setActivityId(String activityId) { + this.activityId = activityId; + return this; + } + public SpanCreationContext build() { return new SpanCreationContext( - spanOperationType, actionName, workflowId, runId, parentWorkflowId, parentRunId); + spanOperationType, + actionName, + workflowId, + runId, + parentWorkflowId, + parentRunId, + activityId); } } } diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/StandardTagNames.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/StandardTagNames.java index de9cf3f4ec..d1fb48e479 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/StandardTagNames.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/StandardTagNames.java @@ -3,6 +3,7 @@ public class StandardTagNames { public static final String WORKFLOW_ID = "workflowId"; public static final String RUN_ID = "runId"; + public static final String ACTIVITY_ID = "activityId"; public static final String PARENT_WORKFLOW_ID = "parentWorkflowId"; public static final String PARENT_RUN_ID = "parentRunId"; diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java index 1734f3a36d..3b56f575da 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java @@ -66,8 +66,6 @@ protected Map getSpanTags(SpanCreationContext context) { StandardTagNames.WORKFLOW_ID, context.getWorkflowId(), StandardTagNames.PARENT_RUN_ID, context.getParentRunId()); case RUN_WORKFLOW: - case START_ACTIVITY: - case RUN_ACTIVITY: case SIGNAL_EXTERNAL_WORKFLOW: case SIGNAL_WORKFLOW: case UPDATE_WORKFLOW: @@ -80,6 +78,19 @@ protected Map getSpanTags(SpanCreationContext context) { return ImmutableMap.of( StandardTagNames.WORKFLOW_ID, context.getWorkflowId(), StandardTagNames.RUN_ID, context.getRunId()); + case START_ACTIVITY: + case RUN_ACTIVITY: + ImmutableMap.Builder tags = ImmutableMap.builder(); + if (context.getActivityId() != null) { + tags.put(StandardTagNames.ACTIVITY_ID, context.getActivityId()); + } + if (context.getWorkflowId() != null) { + tags.put(StandardTagNames.WORKFLOW_ID, context.getWorkflowId()); + } + if (context.getRunId() != null) { + tags.put(StandardTagNames.RUN_ID, context.getRunId()); + } + return tags.build(); case START_NEXUS_OPERATION: return ImmutableMap.of( StandardTagNames.WORKFLOW_ID, context.getWorkflowId(), diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityClientCallsInterceptor.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityClientCallsInterceptor.java new file mode 100644 index 0000000000..774bd70a24 --- /dev/null +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityClientCallsInterceptor.java @@ -0,0 +1,43 @@ +package io.temporal.opentracing.internal; + +import io.opentracing.Scope; +import io.opentracing.Span; +import io.opentracing.Tracer; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; +import io.temporal.opentracing.OpenTracingOptions; + +public class OpenTracingActivityClientCallsInterceptor extends ActivityClientCallsInterceptorBase { + private final SpanFactory spanFactory; + private final Tracer tracer; + private final ContextAccessor contextAccessor; + + public OpenTracingActivityClientCallsInterceptor( + ActivityClientCallsInterceptor next, + OpenTracingOptions options, + SpanFactory spanFactory, + ContextAccessor contextAccessor) { + super(next); + this.spanFactory = spanFactory; + this.tracer = options.getTracer(); + this.contextAccessor = contextAccessor; + } + + @Override + public StartActivityOutput startActivity(StartActivityInput input) { + Span activityStartSpan = + contextAccessor.writeSpanContextToHeader( + () -> + spanFactory + .createActivityStartSpan( + tracer, input.getActivityType(), null, null, input.getOptions().getId()) + .start(), + input.getHeader(), + tracer); + try (Scope ignored = tracer.scopeManager().activate(activityStartSpan)) { + return super.startActivity(input); + } finally { + activityStartSpan.finish(); + } + } +} diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityInboundCallsInterceptor.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityInboundCallsInterceptor.java index 2091df7ec4..d0b2ce2ede 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityInboundCallsInterceptor.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingActivityInboundCallsInterceptor.java @@ -52,6 +52,7 @@ public ActivityOutput execute(ActivityInput input) { activityInfo.getActivityType(), activityInfo.getWorkflowId(), activityInfo.getWorkflowRunId(), + activityInfo.getActivityId(), rootSpanContext) .start(); try (Scope scope = tracer.scopeManager().activate(activityRunSpan)) { diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowOutboundCallsInterceptor.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowOutboundCallsInterceptor.java index 958aeb2d1c..41c85ca0c7 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowOutboundCallsInterceptor.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowOutboundCallsInterceptor.java @@ -251,7 +251,7 @@ public Object newChildThread(Runnable runnable, boolean detached, String name) { private Tracer.SpanBuilder createActivityStartSpanBuilder(String activityName) { WorkflowInfo workflowInfo = Workflow.getInfo(); return spanFactory.createActivityStartSpan( - tracer, activityName, workflowInfo.getWorkflowId(), workflowInfo.getRunId()); + tracer, activityName, workflowInfo.getWorkflowId(), workflowInfo.getRunId(), null); } private Tracer.SpanBuilder createChildWorkflowStartSpanBuilder(ChildWorkflowInput input) { diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/SpanFactory.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/SpanFactory.java index 945d777a6c..fdfe09d1a2 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/SpanFactory.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/SpanFactory.java @@ -139,13 +139,18 @@ public Tracer.SpanBuilder createWorkflowRunSpan( } public Tracer.SpanBuilder createActivityStartSpan( - Tracer tracer, String activityType, String workflowId, String runId) { + Tracer tracer, + String activityType, + @Nullable String workflowId, + @Nullable String runId, + @Nullable String activityId) { SpanCreationContext context = SpanCreationContext.newBuilder() .setSpanOperationType(SpanOperationType.START_ACTIVITY) .setActionName(activityType) .setWorkflowId(workflowId) .setRunId(runId) + .setActivityId(activityId) .build(); return createSpan(context, tracer, null, References.CHILD_OF); } @@ -153,8 +158,9 @@ public Tracer.SpanBuilder createActivityStartSpan( public Tracer.SpanBuilder createActivityRunSpan( Tracer tracer, String activityType, - String workflowId, - String runId, + @Nullable String workflowId, + @Nullable String runId, + @Nullable String activityId, SpanContext activityStartSpanContext) { SpanCreationContext context = SpanCreationContext.newBuilder() @@ -162,6 +168,7 @@ public Tracer.SpanBuilder createActivityRunSpan( .setActionName(activityType) .setWorkflowId(workflowId) .setRunId(runId) + .setActivityId(activityId) .build(); return createSpan(context, tracer, activityStartSpanContext, References.FOLLOWS_FROM); } diff --git a/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java new file mode 100644 index 0000000000..c852175196 --- /dev/null +++ b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java @@ -0,0 +1,174 @@ +package io.temporal.opentracing; + +import static org.junit.Assert.*; + +import io.opentracing.mock.MockSpan; +import io.opentracing.mock.MockTracer; +import io.opentracing.util.ThreadLocalScopeManager; +import io.temporal.api.workflowservice.v1.CountActivityExecutionsResponse; +import io.temporal.client.ActivityExecutionCount; +import io.temporal.client.StartActivityOptions; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; +import io.temporal.common.interceptors.Header; +import io.temporal.opentracing.internal.ContextAccessor; +import io.temporal.opentracing.internal.OpenTracingActivityClientCallsInterceptor; +import io.temporal.opentracing.internal.SpanFactory; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeoutException; +import java.util.stream.Stream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** Unit tests for standalone activity tracing on the client side. */ +public class StandaloneActivityClientTracingTest { + + private final MockTracer mockTracer = + new MockTracer(new ThreadLocalScopeManager(), MockTracer.Propagator.TEXT_MAP); + + private final OpenTracingOptions otOptions = + OpenTracingOptions.newBuilder().setTracer(mockTracer).build(); + + private OpenTracingActivityClientCallsInterceptor interceptor; + + @Before + public void setUp() { + mockTracer.reset(); + interceptor = + new OpenTracingActivityClientCallsInterceptor( + new StubActivityClientCallsInterceptor(), + otOptions, + new SpanFactory(otOptions), + new ContextAccessor(otOptions)); + } + + @After + public void tearDown() { + mockTracer.reset(); + } + + @Test + public void testStartActivityCreatesSpanWithHeaderPropagation() { + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId("act-123") + .setTaskQueue("tq") + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .build(); + Header header = Header.empty(); + ActivityClientCallsInterceptor.StartActivityInput input = + new ActivityClientCallsInterceptor.StartActivityInput( + "MyActivity", Collections.emptyList(), opts, header); + + interceptor.startActivity(input); + + List spans = mockTracer.finishedSpans(); + assertEquals(1, spans.size()); + MockSpan span = spans.get(0); + assertEquals("StartActivity:MyActivity", span.operationName()); + assertEquals("act-123", span.tags().get("activityId")); + assertFalse("Trace context should be propagated into header", header.getValues().isEmpty()); + } + + @Test + public void testStartActivitySpanIsChildOfActiveSpan() { + MockSpan parentSpan = mockTracer.buildSpan("ClientFunction").start(); + try (io.opentracing.Scope ignored = mockTracer.scopeManager().activate(parentSpan)) { + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId("act-child") + .setTaskQueue("tq") + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .build(); + interceptor.startActivity( + new ActivityClientCallsInterceptor.StartActivityInput( + "MyActivity", Collections.emptyList(), opts, Header.empty())); + } finally { + parentSpan.finish(); + } + + List spans = mockTracer.finishedSpans(); + assertEquals(2, spans.size()); + + MockSpan activitySpan = spans.get(0); + assertEquals("StartActivity:MyActivity", activitySpan.operationName()); + assertEquals(parentSpan.context().spanId(), activitySpan.parentId()); + } + + @Test + public void testManagementCallsDoNotCreateSpans() throws TimeoutException { + interceptor.getActivityResult( + new ActivityClientCallsInterceptor.GetActivityResultInput<>( + "act-result", null, String.class)); + interceptor.getActivityResultAsync( + new ActivityClientCallsInterceptor.GetActivityResultInput<>( + "act-result-async", null, String.class)); + interceptor.describeActivity( + new ActivityClientCallsInterceptor.DescribeActivityInput("act-desc", null)); + interceptor.cancelActivity( + new ActivityClientCallsInterceptor.CancelActivityInput("act-cancel", null, "reason")); + interceptor.terminateActivity( + new ActivityClientCallsInterceptor.TerminateActivityInput("act-term", null, "reason")); + interceptor.listActivities( + new ActivityClientCallsInterceptor.ListActivitiesInput("TaskQueue = 'tq'")); + interceptor.countActivities( + new ActivityClientCallsInterceptor.CountActivitiesInput("TaskQueue = 'tq'")); + + assertTrue(mockTracer.finishedSpans().isEmpty()); + } + + private static class StubActivityClientCallsInterceptor + extends ActivityClientCallsInterceptorBase { + + StubActivityClientCallsInterceptor() { + super(null); + } + + @Override + public StartActivityOutput startActivity(StartActivityInput input) { + return new StartActivityOutput(input.getOptions().getId(), null); + } + + @Override + public GetActivityResultOutput getActivityResult(GetActivityResultInput input) + throws TimeoutException { + return new GetActivityResultOutput<>(null); + } + + @Override + public CompletableFuture> getActivityResultAsync( + GetActivityResultInput input) { + return CompletableFuture.completedFuture(new GetActivityResultOutput<>(null)); + } + + @Override + public DescribeActivityOutput describeActivity(DescribeActivityInput input) { + return new DescribeActivityOutput(null); + } + + @Override + public CancelActivityOutput cancelActivity(CancelActivityInput input) { + return new CancelActivityOutput(); + } + + @Override + public TerminateActivityOutput terminateActivity(TerminateActivityInput input) { + return new TerminateActivityOutput(); + } + + @Override + public ListActivitiesOutput listActivities(ListActivitiesInput input) { + return new ListActivitiesOutput(Stream.empty()); + } + + @Override + public CountActivitiesOutput countActivities(CountActivitiesInput input) { + return new CountActivitiesOutput( + new ActivityExecutionCount(CountActivityExecutionsResponse.getDefaultInstance())); + } + } +} diff --git a/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityWorkerTracingTest.java b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityWorkerTracingTest.java new file mode 100644 index 0000000000..c50572d675 --- /dev/null +++ b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityWorkerTracingTest.java @@ -0,0 +1,93 @@ +package io.temporal.opentracing; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.opentracing.Span; +import io.opentracing.mock.MockSpan; +import io.opentracing.mock.MockTracer; +import io.opentracing.util.ThreadLocalScopeManager; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.activity.ActivityInfo; +import io.temporal.common.interceptors.ActivityInboundCallsInterceptor; +import io.temporal.common.interceptors.Header; +import io.temporal.opentracing.internal.ContextAccessor; +import io.temporal.opentracing.internal.OpenTracingActivityInboundCallsInterceptor; +import io.temporal.opentracing.internal.SpanFactory; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** Unit tests for standalone activity tracing on the worker side. */ +public class StandaloneActivityWorkerTracingTest { + + private final MockTracer mockTracer = + new MockTracer(new ThreadLocalScopeManager(), MockTracer.Propagator.TEXT_MAP); + + private final OpenTracingOptions otOptions = + OpenTracingOptions.newBuilder().setTracer(mockTracer).build(); + + private final SpanFactory spanFactory = new SpanFactory(otOptions); + private final ContextAccessor contextAccessor = new ContextAccessor(otOptions); + + private OpenTracingActivityInboundCallsInterceptor interceptor; + + @Before + public void setUp() { + mockTracer.reset(); + interceptor = + new OpenTracingActivityInboundCallsInterceptor( + new StubActivityInboundCallsInterceptor(), otOptions, spanFactory, contextAccessor); + } + + @After + public void tearDown() { + mockTracer.reset(); + } + + @Test + public void testStandaloneActivityRunCreatesSpanWithActivityId() { + Header header = Header.empty(); + Span activityStartSpan = + contextAccessor.writeSpanContextToHeader( + () -> + spanFactory + .createActivityStartSpan( + mockTracer, "MyStandaloneActivity", null, null, "act-run") + .start(), + header, + mockTracer); + activityStartSpan.finish(); + + ActivityExecutionContext executionContext = mock(ActivityExecutionContext.class); + ActivityInfo activityInfo = mock(ActivityInfo.class); + when(activityInfo.isInWorkflow()).thenReturn(false); + when(activityInfo.getActivityType()).thenReturn("MyStandaloneActivity"); + when(activityInfo.getActivityId()).thenReturn("act-run"); + when(executionContext.getInfo()).thenReturn(activityInfo); + + interceptor.init(executionContext); + interceptor.execute(new ActivityInboundCallsInterceptor.ActivityInput(header, new Object[0])); + + OpenTracingSpansHelper spansHelper = new OpenTracingSpansHelper(mockTracer.finishedSpans()); + MockSpan startSpan = spansHelper.getSpanByOperationName("StartActivity:MyStandaloneActivity"); + MockSpan runSpan = spansHelper.getSpanByOperationName("RunActivity:MyStandaloneActivity"); + assertEquals("act-run", runSpan.tags().get("activityId")); + assertNull(runSpan.tags().get("workflowId")); + assertNull(runSpan.tags().get("runId")); + assertEquals(startSpan.context().spanId(), runSpan.parentId()); + } + + private static class StubActivityInboundCallsInterceptor + implements ActivityInboundCallsInterceptor { + @Override + public void init(ActivityExecutionContext context) {} + + @Override + public ActivityOutput execute(ActivityInput input) { + return new ActivityOutput(null); + } + } +} From dae5e0b12ebaba49a3c197f425787c8de089d3e9 Mon Sep 17 00:00:00 2001 From: Baekgyu Kim Date: Fri, 19 Jun 2026 00:48:00 +0900 Subject: [PATCH 017/107] Add option to let activities heartbeat during worker shutdown (#2903) --- .../internal/worker/SingleWorkerOptions.java | 20 ++- .../internal/worker/SyncActivityWorker.java | 37 +++-- .../main/java/io/temporal/worker/Worker.java | 1 + .../io/temporal/worker/WorkerFactory.java | 4 +- .../io/temporal/worker/WorkerOptions.java | 48 +++++- .../io/temporal/worker/WorkerOptionsTest.java | 3 + .../HeartbeatDuringWorkerShutdownTest.java | 143 ++++++++++++++++++ 7 files changed, 238 insertions(+), 18 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/worker/shutdown/HeartbeatDuringWorkerShutdownTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java index 5593707720..f53802f489 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java @@ -41,6 +41,7 @@ public static final class Builder { private boolean usingVirtualThreads; private WorkerDeploymentOptions deploymentOptions; private String workerInstanceKey; + private boolean allowActivityHeartbeatDuringShutdown; private Builder() {} @@ -66,6 +67,7 @@ private Builder(SingleWorkerOptions options) { this.usingVirtualThreads = options.isUsingVirtualThreads(); this.deploymentOptions = options.getDeploymentOptions(); this.workerInstanceKey = options.getWorkerInstanceKey(); + this.allowActivityHeartbeatDuringShutdown = options.getAllowActivityHeartbeatDuringShutdown(); } public Builder setIdentity(String identity) { @@ -162,6 +164,12 @@ public Builder setWorkerInstanceKey(String workerInstanceKey) { return this; } + public Builder setAllowActivityHeartbeatDuringShutdown( + boolean allowActivityHeartbeatDuringShutdown) { + this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; + return this; + } + public SingleWorkerOptions build() { PollerOptions pollerOptions = this.pollerOptions; if (pollerOptions == null) { @@ -201,7 +209,8 @@ public SingleWorkerOptions build() { drainStickyTaskQueueTimeout, usingVirtualThreads, this.deploymentOptions, - this.workerInstanceKey); + this.workerInstanceKey, + this.allowActivityHeartbeatDuringShutdown); } } @@ -223,6 +232,7 @@ public SingleWorkerOptions build() { private final boolean usingVirtualThreads; private final WorkerDeploymentOptions deploymentOptions; private final String workerInstanceKey; + private final boolean allowActivityHeartbeatDuringShutdown; private SingleWorkerOptions( String identity, @@ -242,7 +252,8 @@ private SingleWorkerOptions( Duration drainStickyTaskQueueTimeout, boolean usingVirtualThreads, WorkerDeploymentOptions deploymentOptions, - String workerInstanceKey) { + String workerInstanceKey, + boolean allowActivityHeartbeatDuringShutdown) { this.identity = identity; this.binaryChecksum = binaryChecksum; this.buildId = buildId; @@ -261,6 +272,7 @@ private SingleWorkerOptions( this.usingVirtualThreads = usingVirtualThreads; this.deploymentOptions = deploymentOptions; this.workerInstanceKey = workerInstanceKey; + this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; } public String getIdentity() { @@ -291,6 +303,10 @@ public Duration getDrainStickyTaskQueueTimeout() { return drainStickyTaskQueueTimeout; } + public boolean getAllowActivityHeartbeatDuringShutdown() { + return allowActivityHeartbeatDuringShutdown; + } + public DataConverter getDataConverter() { return dataConverter; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java index ecb24a736a..4eafdb38cf 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java @@ -26,6 +26,7 @@ public class SyncActivityWorker implements SuspendableWorker { private final ScheduledExecutorService heartbeatExecutor; private final ActivityTaskHandlerImpl taskHandler; private final ActivityWorker worker; + private final boolean allowActivityHeartbeatDuringShutdown; public SyncActivityWorker( WorkflowClient client, @@ -38,6 +39,7 @@ public SyncActivityWorker( this.identity = options.getIdentity(); this.namespace = namespace; this.taskQueue = taskQueue; + this.allowActivityHeartbeatDuringShutdown = options.getAllowActivityHeartbeatDuringShutdown(); this.heartbeatExecutor = Executors.newScheduledThreadPool( @@ -89,16 +91,31 @@ public boolean start() { @Override public CompletableFuture shutdown(ShutdownManager shutdownManager, boolean interruptTasks) { - return shutdownManager - // we want to shut down heartbeatExecutor before activity worker, so in-flight activities - // could get an ActivityWorkerShutdownException from their heartbeat - .shutdownExecutor(heartbeatExecutor, this + "#heartbeatExecutor", Duration.ofSeconds(5)) - .thenCompose(r -> worker.shutdown(shutdownManager, interruptTasks)) - .exceptionally( - e -> { - log.error("[BUG] Unexpected exception during shutdown", e); - return null; - }); + CompletableFuture shutdownFuture; + if (allowActivityHeartbeatDuringShutdown && !interruptTasks) { + // we want to shut down heartbeatExecutor only after all outstanding activity tasks have + // finished executing, so in-flight activities can keep heartbeating during the shutdown + shutdownFuture = + worker + .shutdown(shutdownManager, interruptTasks) + .thenCompose(r -> shutdownHeartbeatExecutor(shutdownManager)); + } else { + // we want to shut down heartbeatExecutor before activity worker, so in-flight activities + // could get an ActivityWorkerShutdownException from their heartbeat + shutdownFuture = + shutdownHeartbeatExecutor(shutdownManager) + .thenCompose(r -> worker.shutdown(shutdownManager, interruptTasks)); + } + return shutdownFuture.exceptionally( + e -> { + log.error("[BUG] Unexpected exception during shutdown", e); + return null; + }); + } + + private CompletableFuture shutdownHeartbeatExecutor(ShutdownManager shutdownManager) { + return shutdownManager.shutdownExecutor( + heartbeatExecutor, this + "#heartbeatExecutor", Duration.ofSeconds(5)); } @Override diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index c846667d68..6355e5a75a 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -893,6 +893,7 @@ private static SingleWorkerOptions toActivityOptions( return toSingleWorkerOptions( factoryOptions, options, clientOptions, contextPropagators, workerInstanceKey) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnActivityWorker()) + .setAllowActivityHeartbeatDuringShutdown(options.getAllowActivityHeartbeatDuringShutdown()) .setPollerOptions( PollerOptions.newBuilder() .setMaximumPollRatePerSecond(options.getMaxWorkerActivitiesPerSecond()) diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java index c0a949f825..a87a36fb02 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java @@ -358,7 +358,9 @@ public WorkflowClient getWorkflowClient() { * activity tasks are executed.
* After the shutdown, calls to {@link * io.temporal.activity.ActivityExecutionContext#heartbeat(Object)} start throwing {@link - * io.temporal.client.ActivityWorkerShutdownException}.
+ * io.temporal.client.ActivityWorkerShutdownException}, unless {@link + * WorkerOptions.Builder#setAllowActivityHeartbeatDuringShutdown(boolean)} is enabled, in which + * case heartbeats keep working until the activity tasks finish executing.
* This method does not wait for the shutdown to complete. Use {@link #awaitTermination(long, * TimeUnit)} to do that.
* Invocation has no additional effect if already shut down. diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index f8bfd2f442..84db0b8d62 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -77,6 +77,7 @@ public static final class Builder { private PollerBehavior workflowTaskPollersBehavior; private PollerBehavior activityTaskPollersBehavior; private PollerBehavior nexusTaskPollersBehavior; + private boolean allowActivityHeartbeatDuringShutdown; private Builder() {} @@ -112,6 +113,7 @@ private Builder(WorkerOptions o) { this.workflowTaskPollersBehavior = o.workflowTaskPollersBehavior; this.activityTaskPollersBehavior = o.activityTaskPollersBehavior; this.nexusTaskPollersBehavior = o.nexusTaskPollersBehavior; + this.allowActivityHeartbeatDuringShutdown = o.allowActivityHeartbeatDuringShutdown; } /** @@ -524,6 +526,28 @@ public Builder setNexusTaskPollersBehavior(PollerBehavior pollerBehavior) { return this; } + /** + * If true, activities can keep heartbeating during graceful worker shutdown (see {@link + * io.temporal.worker.WorkerFactory#shutdown WorkerFactory.shutdown}). Defaults to false, which + * means that after graceful shutdown is requested, calling {@link + * io.temporal.activity.ActivityExecutionContext#heartbeat ActivityExecutionContext.heartbeat} + * does not send a heartbeat and instead throws {@link + * io.temporal.client.ActivityWorkerShutdownException ActivityWorkerShutdownException}. This + * option is ignored by non-graceful shutdown (see {@link + * io.temporal.worker.WorkerFactory#shutdownNow WorkerFactory.shutdownNow}). + * + *

Note that with this option enabled, activities are no longer notified of the worker + * shutdown by the {@link io.temporal.client.ActivityWorkerShutdownException + * ActivityWorkerShutdownException} exception, so they are expected to complete within the + * termination grace period on their own. + */ + @Experimental + public Builder setAllowActivityHeartbeatDuringShutdown( + boolean allowActivityHeartbeatDuringShutdown) { + this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; + return this; + } + public WorkerOptions build() { return new WorkerOptions( maxWorkerActivitiesPerSecond, @@ -553,7 +577,8 @@ public WorkerOptions build() { deploymentOptions, workflowTaskPollersBehavior, activityTaskPollersBehavior, - nexusTaskPollersBehavior); + nexusTaskPollersBehavior, + allowActivityHeartbeatDuringShutdown); } public WorkerOptions validateAndBuildWithDefaults() { @@ -685,7 +710,8 @@ public WorkerOptions validateAndBuildWithDefaults() { deploymentOptions, workflowTaskPollersBehavior, activityTaskPollersBehavior, - nexusTaskPollersBehavior); + nexusTaskPollersBehavior, + allowActivityHeartbeatDuringShutdown); } } @@ -717,6 +743,7 @@ public WorkerOptions validateAndBuildWithDefaults() { private final PollerBehavior workflowTaskPollersBehavior; private final PollerBehavior activityTaskPollersBehavior; private final PollerBehavior nexusTaskPollersBehavior; + private final boolean allowActivityHeartbeatDuringShutdown; private WorkerOptions( double maxWorkerActivitiesPerSecond, @@ -746,7 +773,8 @@ private WorkerOptions( WorkerDeploymentOptions deploymentOptions, PollerBehavior workflowTaskPollersBehavior, PollerBehavior activityTaskPollersBehavior, - PollerBehavior nexusTaskPollersBehavior) { + PollerBehavior nexusTaskPollersBehavior, + boolean allowActivityHeartbeatDuringShutdown) { this.maxWorkerActivitiesPerSecond = maxWorkerActivitiesPerSecond; this.maxConcurrentActivityExecutionSize = maxConcurrentActivityExecutionSize; this.maxConcurrentWorkflowTaskExecutionSize = maxConcurrentWorkflowTaskExecutionSize; @@ -775,6 +803,7 @@ private WorkerOptions( this.workflowTaskPollersBehavior = workflowTaskPollersBehavior; this.activityTaskPollersBehavior = activityTaskPollersBehavior; this.nexusTaskPollersBehavior = nexusTaskPollersBehavior; + this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; } public double getMaxWorkerActivitiesPerSecond() { @@ -912,6 +941,11 @@ public PollerBehavior getNexusTaskPollersBehavior() { return nexusTaskPollersBehavior; } + @Experimental + public boolean getAllowActivityHeartbeatDuringShutdown() { + return allowActivityHeartbeatDuringShutdown; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -944,7 +978,8 @@ && compare(maxTaskQueueActivitiesPerSecond, that.maxTaskQueueActivitiesPerSecond && Objects.equals(deploymentOptions, that.deploymentOptions) && Objects.equals(workflowTaskPollersBehavior, that.workflowTaskPollersBehavior) && Objects.equals(activityTaskPollersBehavior, that.activityTaskPollersBehavior) - && Objects.equals(nexusTaskPollersBehavior, that.nexusTaskPollersBehavior); + && Objects.equals(nexusTaskPollersBehavior, that.nexusTaskPollersBehavior) + && allowActivityHeartbeatDuringShutdown == that.allowActivityHeartbeatDuringShutdown; } @Override @@ -977,7 +1012,8 @@ public int hashCode() { deploymentOptions, workflowTaskPollersBehavior, activityTaskPollersBehavior, - nexusTaskPollersBehavior); + nexusTaskPollersBehavior, + allowActivityHeartbeatDuringShutdown); } @Override @@ -1040,6 +1076,8 @@ public String toString() { + activityTaskPollersBehavior + ", nexusTaskPollersBehavior=" + nexusTaskPollersBehavior + + ", allowActivityHeartbeatDuringShutdown=" + + allowActivityHeartbeatDuringShutdown + '}'; } } diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java index 897600443d..9bde963162 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java @@ -56,6 +56,7 @@ public void verifyNewBuilderFromExistingWorkerOptions() { .setBuildId("build-id") .setStickyTaskQueueDrainTimeout(Duration.ofSeconds(15)) .setIdentity("worker-identity") + .setAllowActivityHeartbeatDuringShutdown(true) .build(); WorkerOptions w2 = WorkerOptions.newBuilder(w1).build(); @@ -89,6 +90,8 @@ public void verifyNewBuilderFromExistingWorkerOptions() { assertEquals(w1.getBuildId(), w2.getBuildId()); assertEquals(w1.getStickyTaskQueueDrainTimeout(), w2.getStickyTaskQueueDrainTimeout()); assertEquals(w1.getIdentity(), w2.getIdentity()); + assertEquals( + w1.getAllowActivityHeartbeatDuringShutdown(), w2.getAllowActivityHeartbeatDuringShutdown()); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/HeartbeatDuringWorkerShutdownTest.java b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/HeartbeatDuringWorkerShutdownTest.java new file mode 100644 index 0000000000..0da728c65a --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/HeartbeatDuringWorkerShutdownTest.java @@ -0,0 +1,143 @@ +package io.temporal.worker.shutdown; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityFailedException; +import io.temporal.client.ActivityHandle; +import io.temporal.client.ActivityWorkerShutdownException; +import io.temporal.client.StartActivityOptions; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkerOptions; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests for {@link WorkerOptions.Builder#setAllowActivityHeartbeatDuringShutdown(boolean)}. Gated + * behind {@link SDKTestWorkflowRule#useExternalService} because the embedded test server may not + * support the standalone activity APIs. + */ +public class HeartbeatDuringWorkerShutdownTest { + + private static final String EXPECTED_RESULT = "completed"; + + private final Semaphore activityStarted = new Semaphore(0); + private final Semaphore shutdownTriggered = new Semaphore(0); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setTestTimeoutSeconds(60) + .setWorkerOptions( + WorkerOptions.newBuilder().setAllowActivityHeartbeatDuringShutdown(true).build()) + .setActivityImplementations( + new HeartbeatingActivityImpl(activityStarted, shutdownTriggered)) + .build(); + + /** + * Tests that when {@link WorkerOptions.Builder#setAllowActivityHeartbeatDuringShutdown(boolean)} + * is enabled, heartbeats keep working after a graceful worker shutdown is initiated and the + * activity runs to completion instead of getting an {@link + * io.temporal.client.ActivityWorkerShutdownException}. + */ + @Test + public void testHeartbeatingActivityCompletesDuringShutdown() throws InterruptedException { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatingActivity(); + + assertTrue( + "Activity did not start within 30s", activityStarted.tryAcquire(30, TimeUnit.SECONDS)); + testWorkflowRule.getTestEnvironment().shutdown(); + shutdownTriggered.release(); + + // a heartbeat failure would fail the activity and make getResult throw + assertEquals(EXPECTED_RESULT, handle.getResult()); + testWorkflowRule.getTestEnvironment().awaitTermination(30, TimeUnit.SECONDS); + } + + /** + * Tests that {@link WorkerOptions.Builder#setAllowActivityHeartbeatDuringShutdown(boolean)} is + * ignored by {@link io.temporal.worker.WorkerFactory#shutdownNow()}: the heartbeat fails the + * activity with an {@link io.temporal.client.ActivityWorkerShutdownException} instead of letting + * it complete. + */ + @Test + public void testHeartbeatingActivityFailsDuringShutdownNow() throws InterruptedException { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatingActivity(); + + assertTrue( + "Activity did not start within 30s", activityStarted.tryAcquire(30, TimeUnit.SECONDS)); + testWorkflowRule.getTestEnvironment().shutdownNow(); + shutdownTriggered.release(); + + // if heartbeating was incorrectly allowed, the activity would complete successfully and this + // assertion would fail + ActivityFailedException ex = assertThrows(ActivityFailedException.class, handle::getResult); + // the heartbeat is rejected with ActivityWorkerShutdownException, which crosses the server + // boundary as an ApplicationFailure whose type is the exception's class name + assertEquals( + ActivityWorkerShutdownException.class.getName(), + ((ApplicationFailure) ex.getCause()).getType()); + testWorkflowRule.getTestEnvironment().awaitTermination(30, TimeUnit.SECONDS); + } + + private ActivityHandle startHeartbeatingActivity() { + ActivityClient client = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder().setNamespace(SDKTestWorkflowRule.NAMESPACE).build()); + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("heartbeat-during-shutdown-" + UUID.randomUUID()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build(); + return client.start(HeartbeatingActivity.class, HeartbeatingActivity::execute, options); + } + + @ActivityInterface + public interface HeartbeatingActivity { + @ActivityMethod + String execute(); + } + + public static class HeartbeatingActivityImpl implements HeartbeatingActivity { + private final Semaphore activityStarted; + private final Semaphore shutdownTriggered; + + public HeartbeatingActivityImpl(Semaphore activityStarted, Semaphore shutdownTriggered) { + this.activityStarted = activityStarted; + this.shutdownTriggered = shutdownTriggered; + } + + @Override + public String execute() { + activityStarted.release(); + try { + if (!shutdownTriggered.tryAcquire(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("Worker shutdown was not triggered within 30s"); + } + } catch (InterruptedException e) { + // we ignore the interruption issued by shutdownNow and proceed to the heartbeat below, + // which is the signal under test + } + Activity.getExecutionContext().heartbeat("progress"); + return EXPECTED_RESULT; + } + } +} From 8e5ee336ca29c221b78d58f55b3cb2d073307309 Mon Sep 17 00:00:00 2001 From: Sheepman <38871525+444am@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:09:15 +1000 Subject: [PATCH 018/107] Support Standalone Activity client in temporal-testing (#2916) --------- Co-authored-by: Maciej Dudkowski --- temporal-testing/README.md | 4 + .../testing/TestEnvironmentOptions.java | 46 ++++++++- .../testing/TestWorkflowEnvironment.java | 4 + .../TestWorkflowEnvironmentInternal.java | 29 +++++- .../testing/TestWorkflowExtension.java | 19 +++- .../io/temporal/testing/TestWorkflowRule.java | 19 ++++ .../testing/internal/SDKTestWorkflowRule.java | 11 +++ ...WorkflowEnvironmentActivityClientTest.java | 96 +++++++++++++++++++ .../junit5/TestWorkflowExtensionTest.java | 3 + 9 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 temporal-testing/src/test/java/io/temporal/testing/TestWorkflowEnvironmentActivityClientTest.java diff --git a/temporal-testing/README.md b/temporal-testing/README.md index db86d43cd7..ba511e264b 100644 --- a/temporal-testing/README.md +++ b/temporal-testing/README.md @@ -35,6 +35,10 @@ For JUnit4 see `io.temporal.testing.TestWorkflowRule` for testing of workflows For Junit5 see `io.temporal.testing.TestWorkflowExtension` for testing of workflows and `io.temporal.testing.TestActivityExtension` for isolated testing of activities +`TestWorkflowEnvironment`, `TestWorkflowRule`, and `TestWorkflowExtension` provide clients connected +to the test service. Use `getWorkflowClient()` for Workflows and `getActivityClient()` for +Standalone Activities. + ## For isolated testing of activity implementations See `io.temporal.testing.TestActivityEnvironment` that provides an easy way for isolated testing of diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestEnvironmentOptions.java b/temporal-testing/src/main/java/io/temporal/testing/TestEnvironmentOptions.java index d1eb4e2fc7..ac3847c1ff 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestEnvironmentOptions.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestEnvironmentOptions.java @@ -4,6 +4,7 @@ import com.uber.m3.tally.NoopScope; import com.uber.m3.tally.Scope; import io.temporal.api.enums.v1.IndexedValueType; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClientOptions; import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.worker.WorkerFactoryOptions; @@ -39,6 +40,8 @@ public static final class Builder { private WorkflowClientOptions workflowClientOptions; + private ActivityClientOptions activityClientOptions; + private WorkflowServiceStubsOptions workflowServiceStubsOptions; private Scope metricsScope; @@ -58,6 +61,7 @@ private Builder() {} private Builder(TestEnvironmentOptions o) { this.workerFactoryOptions = o.workerFactoryOptions; this.workflowClientOptions = o.workflowClientOptions; + this.activityClientOptions = o.activityClientOptions; this.workflowServiceStubsOptions = o.workflowServiceStubsOptions; this.metricsScope = o.metricsScope; this.useExternalService = o.useExternalService; @@ -72,6 +76,11 @@ public Builder setWorkflowClientOptions(WorkflowClientOptions workflowClientOpti return this; } + public Builder setActivityClientOptions(ActivityClientOptions activityClientOptions) { + this.activityClientOptions = activityClientOptions; + return this; + } + /** Set factoryOptions for worker factory used to create workers. */ public Builder setWorkerFactoryOptions(WorkerFactoryOptions options) { this.workerFactoryOptions = options; @@ -183,6 +192,7 @@ Builder setSearchAttributes(@Nonnull Map searchAttribu public TestEnvironmentOptions build() { return new TestEnvironmentOptions( workflowClientOptions, + activityClientOptions, workerFactoryOptions, workflowServiceStubsOptions, useExternalService, @@ -194,11 +204,10 @@ public TestEnvironmentOptions build() { } public TestEnvironmentOptions validateAndBuildWithDefaults() { + WorkflowClientOptions workflowClientOptionsWithDefaults = workflowClientOptionsWithDefaults(); return new TestEnvironmentOptions( - (workflowClientOptions != null - ? WorkflowClientOptions.newBuilder(workflowClientOptions) - : WorkflowClientOptions.newBuilder()) - .validateAndBuildWithDefaults(), + workflowClientOptionsWithDefaults, + activityClientOptionsWithDefaults(workflowClientOptionsWithDefaults), (workerFactoryOptions != null ? WorkerFactoryOptions.newBuilder(workerFactoryOptions) : WorkerFactoryOptions.newBuilder()) @@ -214,10 +223,31 @@ public TestEnvironmentOptions validateAndBuildWithDefaults() { useTimeskipping, searchAttributes); } + + private WorkflowClientOptions workflowClientOptionsWithDefaults() { + return (workflowClientOptions != null + ? WorkflowClientOptions.newBuilder(workflowClientOptions) + : WorkflowClientOptions.newBuilder()) + .validateAndBuildWithDefaults(); + } + + private ActivityClientOptions activityClientOptionsWithDefaults( + WorkflowClientOptions workflowClientOptionsWithDefaults) { + if (activityClientOptions != null) { + return ActivityClientOptions.newBuilder(activityClientOptions).build(); + } + return ActivityClientOptions.newBuilder() + .setNamespace(workflowClientOptionsWithDefaults.getNamespace()) + .setDataConverter(workflowClientOptionsWithDefaults.getDataConverter()) + .setIdentity(workflowClientOptionsWithDefaults.getIdentity()) + .setContextPropagators(workflowClientOptionsWithDefaults.getContextPropagators()) + .build(); + } } private final WorkerFactoryOptions workerFactoryOptions; private final WorkflowClientOptions workflowClientOptions; + private final ActivityClientOptions activityClientOptions; private final WorkflowServiceStubsOptions workflowServiceStubsOptions; private final Scope metricsScope; private final boolean useExternalService; @@ -228,6 +258,7 @@ public TestEnvironmentOptions validateAndBuildWithDefaults() { private TestEnvironmentOptions( WorkflowClientOptions workflowClientOptions, + ActivityClientOptions activityClientOptions, WorkerFactoryOptions workerFactoryOptions, WorkflowServiceStubsOptions workflowServiceStubsOptions, boolean useExternalService, @@ -237,6 +268,7 @@ private TestEnvironmentOptions( boolean useTimeskipping, @Nonnull Map searchAttributes) { this.workflowClientOptions = workflowClientOptions; + this.activityClientOptions = activityClientOptions; this.workerFactoryOptions = workerFactoryOptions; this.workflowServiceStubsOptions = workflowServiceStubsOptions; this.metricsScope = metricsScope; @@ -255,6 +287,10 @@ public WorkflowClientOptions getWorkflowClientOptions() { return workflowClientOptions; } + public ActivityClientOptions getActivityClientOptions() { + return activityClientOptions; + } + public WorkflowServiceStubsOptions getWorkflowServiceStubsOptions() { return workflowServiceStubsOptions; } @@ -291,6 +327,8 @@ public String toString() { + workerFactoryOptions + ", workflowClientOptions=" + workflowClientOptions + + ", activityClientOptions=" + + activityClientOptions + ", workflowServiceStubsOptions=" + workflowServiceStubsOptions + ", metricsScope=" diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java index b2dadcf059..24090982e3 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java @@ -3,6 +3,7 @@ import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.IndexedValueType; import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.ActivityClient; import io.temporal.client.WorkflowClient; import io.temporal.common.WorkflowExecutionHistory; import io.temporal.serviceclient.OperatorServiceStubs; @@ -104,6 +105,9 @@ static TestWorkflowEnvironment newInstance(TestEnvironmentOptions options) { /** Creates a WorkflowClient that is connected to the in-memory test Temporal service. */ WorkflowClient getWorkflowClient(); + /** Creates an ActivityClient that is connected to the in-memory test Temporal service. */ + ActivityClient getActivityClient(); + /** * This time might not be equal to {@link System#currentTimeMillis()} due to time skipping. * diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java index f11525f15b..a24f1e3172 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java @@ -17,9 +17,12 @@ import io.temporal.api.operatorservice.v1.AddSearchAttributesRequest; import io.temporal.api.operatorservice.v1.CreateNexusEndpointRequest; import io.temporal.api.testservice.v1.SleepRequest; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.testservice.TestWorkflowService; import io.temporal.serviceclient.*; @@ -28,6 +31,8 @@ import io.temporal.worker.WorkerFactory; import io.temporal.worker.WorkerOptions; import java.time.Duration; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.TimeUnit; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -35,6 +40,7 @@ public final class TestWorkflowEnvironmentInternal implements TestWorkflowEnvironment { private final WorkflowClientOptions workflowClientOptions; + private final ActivityClientOptions activityClientOptions; private final WorkflowServiceStubs workflowServiceStubs; private final OperatorServiceStubs operatorServiceStubs; private final @Nullable TestServiceStubs testServiceStubs; @@ -48,9 +54,10 @@ public TestWorkflowEnvironmentInternal(@Nullable TestEnvironmentOptions testEnvi if (testEnvironmentOptions == null) { testEnvironmentOptions = TestEnvironmentOptions.getDefaultInstance(); } - this.workflowClientOptions = - WorkflowClientOptions.newBuilder(testEnvironmentOptions.getWorkflowClientOptions()) - .validateAndBuildWithDefaults(); + testEnvironmentOptions = + TestEnvironmentOptions.newBuilder(testEnvironmentOptions).validateAndBuildWithDefaults(); + this.workflowClientOptions = testEnvironmentOptions.getWorkflowClientOptions(); + this.activityClientOptions = testEnvironmentOptions.getActivityClientOptions(); WorkflowServiceStubsOptions.Builder stubsOptionsBuilder = testEnvironmentOptions.getWorkflowServiceStubsOptions() != null @@ -146,6 +153,22 @@ public WorkflowClient getWorkflowClient() { return WorkflowClient.newInstance(workflowServiceStubs, options); } + @Override + public ActivityClient getActivityClient() { + ActivityClientOptions options; + if (testServiceStubs != null) { + List interceptors = + new ArrayList<>(activityClientOptions.getInterceptors()); + options = + ActivityClientOptions.newBuilder(activityClientOptions) + .setInterceptors(interceptors) + .build(); + } else { + options = activityClientOptions; + } + return ActivityClient.newInstance(workflowServiceStubs, options); + } + @Override public long currentTimeMillis() { if (testServiceStubs != null) { diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java index 65532e55e4..c508c72803 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java @@ -5,6 +5,8 @@ import com.uber.m3.tally.Scope; import io.temporal.api.enums.v1.IndexedValueType; import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; @@ -40,8 +42,8 @@ * Builder#useExternalService(String)}}). * *

This extension can inject workflow stubs as well as instances of {@link - * TestWorkflowEnvironment}, {@link WorkflowClient}, {@link WorkflowOptions}, {@link Worker}, into - * test methods. + * TestWorkflowEnvironment}, {@link WorkflowClient}, {@link ActivityClient}, {@link + * WorkflowOptions}, {@link Worker}, into test methods. * *

Usage example: * @@ -72,6 +74,7 @@ public class TestWorkflowExtension private final WorkerOptions workerOptions; private final WorkflowClientOptions workflowClientOptions; + private final ActivityClientOptions activityClientOptions; private final WorkerFactoryOptions workerFactoryOptions; private final Map, WorkflowImplementationOptions> workflowTypes; private final Object[] activityImplementations; @@ -96,6 +99,7 @@ private TestWorkflowExtension(Builder builder) { workflowClientOptions = WorkflowClientOptions.newBuilder().setNamespace(builder.namespace).build(); } + activityClientOptions = builder.activityClientOptions; workerFactoryOptions = builder.workerFactoryOptions; workflowTypes = builder.workflowTypes; activityImplementations = builder.activityImplementations; @@ -111,6 +115,7 @@ private TestWorkflowExtension(Builder builder) { supportedParameterTypes.add(TestWorkflowEnvironment.class); supportedParameterTypes.add(WorkflowClient.class); + supportedParameterTypes.add(ActivityClient.class); supportedParameterTypes.add(WorkflowOptions.class); supportedParameterTypes.add(Worker.class); @@ -172,6 +177,8 @@ public Object resolveParameter( return getTestEnvironment(extensionContext); } else if (parameterType == WorkflowClient.class) { return getTestEnvironment(extensionContext).getWorkflowClient(); + } else if (parameterType == ActivityClient.class) { + return getTestEnvironment(extensionContext).getActivityClient(); } else if (parameterType == WorkflowOptions.class) { return getWorkflowOptions(extensionContext); } else if (parameterType == Worker.class) { @@ -225,6 +232,7 @@ public void beforeEach(ExtensionContext context) { protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { return TestEnvironmentOptions.newBuilder() .setWorkflowClientOptions(workflowClientOptions) + .setActivityClientOptions(activityClientOptions) .setWorkerFactoryOptions(workerFactoryOptions) .setUseExternalService(useExternalService) .setUseTimeskipping(useTimeskipping) @@ -297,6 +305,7 @@ public static class Builder { private WorkerOptions workerOptions = WorkerOptions.getDefaultInstance(); private WorkflowClientOptions workflowClientOptions; + private ActivityClientOptions activityClientOptions; private WorkerFactoryOptions workerFactoryOptions; private String namespace = "UnitTest"; private Map, WorkflowImplementationOptions> workflowTypes = new HashMap<>(); @@ -332,6 +341,12 @@ public Builder setWorkflowClientOptions(WorkflowClientOptions workflowClientOpti return this; } + /** Override {@link ActivityClientOptions} for test environment. */ + public Builder setActivityClientOptions(ActivityClientOptions activityClientOptions) { + this.activityClientOptions = activityClientOptions; + return this; + } + /** * Override {@link WorkerFactoryOptions} for test environment. * diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index 0325e999ff..c5dbce712a 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -8,6 +8,8 @@ import io.temporal.api.history.v1.History; import io.temporal.api.nexus.v1.Endpoint; import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; @@ -72,6 +74,7 @@ public class TestWorkflowRule implements TestRule { private final Object[] nexusServiceImplementations; private final WorkflowServiceStubsOptions serviceStubsOptions; private final WorkflowClientOptions clientOptions; + private final ActivityClientOptions activityClientOptions; private final WorkerFactoryOptions workerFactoryOptions; private final WorkflowImplementationOptions workflowImplementationOptions; private final WorkerOptions workerOptions; @@ -115,6 +118,7 @@ private TestWorkflowRule(Builder builder) { (builder.workflowClientOptions == null) ? WorkflowClientOptions.newBuilder().setNamespace(namespace).build() : builder.workflowClientOptions.toBuilder().setNamespace(namespace).build(); + this.activityClientOptions = builder.activityClientOptions; this.workerOptions = (builder.workerOptions == null) ? WorkerOptions.newBuilder().build() @@ -145,6 +149,7 @@ protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { return TestEnvironmentOptions.newBuilder() .setWorkflowServiceStubsOptions(serviceStubsOptions) .setWorkflowClientOptions(clientOptions) + .setActivityClientOptions(activityClientOptions) .setWorkerFactoryOptions(workerFactoryOptions) .setUseExternalService(useExternalService) .setUseTimeskipping(useTimeskipping) @@ -176,6 +181,7 @@ public static class Builder { private Object[] nexusServiceImplementations; private WorkflowServiceStubsOptions workflowServiceStubsOptions; private WorkflowClientOptions workflowClientOptions; + private ActivityClientOptions activityClientOptions; private WorkerFactoryOptions workerFactoryOptions; private WorkflowImplementationOptions workflowImplementationOptions; private WorkerOptions workerOptions; @@ -204,6 +210,12 @@ public Builder setWorkflowClientOptions(WorkflowClientOptions workflowClientOpti return this; } + /** Override {@link ActivityClientOptions} for test environment. */ + public Builder setActivityClientOptions(ActivityClientOptions activityClientOptions) { + this.activityClientOptions = activityClientOptions; + return this; + } + public Builder setWorkerFactoryOptions(WorkerFactoryOptions options) { this.workerFactoryOptions = options; return this; @@ -484,6 +496,13 @@ public WorkflowClient getWorkflowClient() { return testEnvironment.getWorkflowClient(); } + /** + * @return client to the Temporal service used to start standalone activities. + */ + public ActivityClient getActivityClient() { + return testEnvironment.getActivityClient(); + } + /** * @return stubs connected to the test server (in-memory or external) */ diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java index a1de5e0d3f..9c81461212 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/SDKTestWorkflowRule.java @@ -13,6 +13,8 @@ import io.temporal.api.history.v1.History; import io.temporal.api.history.v1.HistoryEvent; import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.NexusClient; import io.temporal.client.NexusClientOptions; import io.temporal.client.WorkflowClient; @@ -118,6 +120,11 @@ public Builder setWorkflowClientOptions(WorkflowClientOptions workflowClientOpti return this; } + public Builder setActivityClientOptions(ActivityClientOptions activityClientOptions) { + testWorkflowRuleBuilder.setActivityClientOptions(activityClientOptions); + return this; + } + public Builder setWorkerOptions(WorkerOptions options) { testWorkflowRuleBuilder.setWorkerOptions( WorkerOptions.newBuilder(options).setUsingVirtualThreads(USE_VIRTUAL_THREADS).build()); @@ -394,6 +401,10 @@ public WorkflowClient getWorkflowClient() { return testWorkflowRule.getWorkflowClient(); } + public ActivityClient getActivityClient() { + return testWorkflowRule.getActivityClient(); + } + public WorkflowServiceStubs getWorkflowServiceStubs() { return testWorkflowRule.getWorkflowServiceStubs(); } diff --git a/temporal-testing/src/test/java/io/temporal/testing/TestWorkflowEnvironmentActivityClientTest.java b/temporal-testing/src/test/java/io/temporal/testing/TestWorkflowEnvironmentActivityClientTest.java new file mode 100644 index 0000000000..74d1b9abd5 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/TestWorkflowEnvironmentActivityClientTest.java @@ -0,0 +1,96 @@ +package io.temporal.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.interceptors.ActivityClientInterceptor; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.lang.reflect.Field; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +public class TestWorkflowEnvironmentActivityClientTest { + + @Test + public void activityClientDefaultsToWorkflowClientOptions() throws Exception { + TestEnvironmentOptions options = + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setNamespace("workflow-client-namespace") + .setIdentity("workflow-client-identity") + .build()) + .build(); + + try (TestWorkflowEnvironment testEnv = TestWorkflowEnvironment.newInstance(options)) { + ActivityClient activityClient = testEnv.getActivityClient(); + ActivityClientOptions activityClientOptions = getActivityClientOptions(activityClient); + + assertEquals("workflow-client-namespace", activityClientOptions.getNamespace()); + assertEquals("workflow-client-identity", activityClientOptions.getIdentity()); + assertSame(testEnv.getWorkflowServiceStubs(), getWorkflowServiceStubs(activityClient)); + } + } + + @Test + public void activityClientUsesExplicitActivityClientOptions() throws Exception { + ActivityClientInterceptor interceptor = next -> next; + ActivityClientOptions options = + ActivityClientOptions.newBuilder() + .setNamespace("activity-client-namespace") + .setIdentity("activity-client-identity") + .setInterceptors(Collections.singletonList(interceptor)) + .build(); + + try (TestWorkflowEnvironment testEnv = + TestWorkflowEnvironment.newInstance( + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setNamespace("workflow-client-namespace") + .build()) + .setActivityClientOptions(options) + .build())) { + ActivityClient activityClient = testEnv.getActivityClient(); + ActivityClientOptions activityClientOptions = getActivityClientOptions(activityClient); + + assertEquals("activity-client-namespace", activityClientOptions.getNamespace()); + assertEquals("activity-client-identity", activityClientOptions.getIdentity()); + assertSame(interceptor, activityClientOptions.getInterceptors().get(0)); + } + } + + @Test + public void testWorkflowRuleExposesActivityClient() throws Exception { + TestWorkflowRule rule = + TestWorkflowRule.newBuilder() + .setActivityClientOptions( + ActivityClientOptions.newBuilder().setNamespace("rule-activity-namespace").build()) + .build(); + + try { + assertEquals( + "rule-activity-namespace", + getActivityClientOptions(rule.getActivityClient()).getNamespace()); + } finally { + rule.getTestEnvironment().close(); + } + } + + private static ActivityClientOptions getActivityClientOptions(ActivityClient client) + throws Exception { + Field field = client.getClass().getDeclaredField("options"); + field.setAccessible(true); + return (ActivityClientOptions) field.get(client); + } + + private static WorkflowServiceStubs getWorkflowServiceStubs(ActivityClient client) + throws Exception { + Field field = client.getClass().getDeclaredField("stubs"); + field.setAccessible(true); + return (WorkflowServiceStubs) field.get(client); + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionTest.java b/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionTest.java index 9bd7147e1a..9da6156bb9 100644 --- a/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionTest.java +++ b/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionTest.java @@ -14,6 +14,7 @@ import io.temporal.activity.ActivityInfo; import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityOptions; +import io.temporal.client.ActivityClient; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; import io.temporal.testing.TestWorkflowEnvironment; @@ -110,6 +111,7 @@ public String sayHello(String name) { public void extensionShouldLaunchTestEnvironmentAndResolveParameters( TestWorkflowEnvironment testEnv, WorkflowClient workflowClient, + ActivityClient activityClient, WorkflowOptions workflowOptions, Worker worker, HelloWorkflow workflow) { @@ -117,6 +119,7 @@ public void extensionShouldLaunchTestEnvironmentAndResolveParameters( assertAll( () -> assertTrue(testEnv.isStarted()), () -> assertNotNull(workflowClient), + () -> assertNotNull(activityClient), () -> assertNotNull(workflowOptions.getTaskQueue()), () -> assertNotNull(worker), () -> From 8d8ca1b504e523d8aa29a3f9bd7ee0ea0950fa42 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 18 Jun 2026 13:02:34 -0700 Subject: [PATCH 019/107] Nexus Signal links (#2889) --------- Co-authored-by: Alex Mazzeo --- .github/workflows/ci.yml | 4 +- .../client/RootWorkflowClientInvoker.java | 42 +- .../external/GenericWorkflowClient.java | 2 +- .../external/GenericWorkflowClientImpl.java | 4 +- .../nexus/InternalNexusOperationContext.java | 61 +++ .../internal/nexus/NexusTaskHandlerImpl.java | 40 ++ ...kflowClientInvokerLinkPropagationTest.java | 307 +++++++++++++ .../nexus/NexusTaskHandlerImplTest.java | 218 +++++++++ .../nexus/SignalOperationLinkingTest.java | 423 ++++++++++++++++++ .../functional/DescribeWorkflowAsserter.java | 17 +- 10 files changed, 1104 insertions(+), 14 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/SignalOperationLinkingTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7bf551c735..fea940f214 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: - name: Start CLI server env: - TEMPORAL_CLI_VERSION: 1.7.1-standalone-nexus-operations + TEMPORAL_CLI_VERSION: 1.7.2-standalone-nexus-operations run: | wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz tar -xzf temporal_cli.tar.gz @@ -112,11 +112,13 @@ jobs: --dynamic-config-value frontend.ListWorkersEnabled=true \ --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ + --dynamic-config-value 'callback.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ --dynamic-config-value frontend.activityAPIsEnabled=true \ --dynamic-config-value activity.enableStandalone=true \ --dynamic-config-value activity.startDelayEnabled=true \ --dynamic-config-value nexusoperation.enableStandalone=true \ --dynamic-config-value history.enableChasm=true \ + --dynamic-config-value history.enableCHASMSignalBacklinks=true \ --dynamic-config-value history.enableTransitionHistory=true & sleep 10s diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java index 896fdc0762..510471f2e6 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java @@ -102,6 +102,13 @@ public WorkflowStartOutput start(WorkflowStartInput input) { e); } } + // If this start is being issued from inside a Nexus operation handler, stash only the + // forward operation->workflow link from the start response so NexusStartWorkflowHelper can + // attach it to the WorkflowExecutionStarted event. Unlike signal/signalWithStart, start + // deliberately does NOT add a response link here: the operation->workflow relationship is + // already captured by the forward link, so re-adding response.getLink() as a response link + // would duplicate it on the caller's history event. Do not "restore symmetry" by calling + // addResponseLink here. if (CurrentNexusOperationContext.isNexusContext()) { CurrentNexusOperationContext.get().setStartWorkflowResponseLink(response.getLink()); } @@ -120,6 +127,13 @@ public WorkflowSignalOutput signal(WorkflowSignalInput input) { .setRequestId(UUID.randomUUID().toString()) .setHeader(HeaderUtils.toHeaderGrpc(input.getHeader(), null)); + // If this signal is being issued from inside a Nexus operation handler, forward the inbound + // Nexus task links so the SignalWorkflowExecution history event links back to the caller. + boolean inNexusContext = CurrentNexusOperationContext.isNexusContext(); + if (inNexusContext) { + request.addAllLinks(CurrentNexusOperationContext.get().getRequestLinks()); + } + DataConverter dataConverterWitSignalContext = clientOptions .getDataConverter() @@ -129,7 +143,12 @@ public WorkflowSignalOutput signal(WorkflowSignalInput input) { Optional inputArgs = dataConverterWitSignalContext.toPayloads(input.getArguments()); inputArgs.ifPresent(request::setInput); - genericClient.signal(request.build()); + SignalWorkflowExecutionResponse response = genericClient.signal(request.build()); + // Server >=1.31 with EnableCHASMSignalBacklinks returns a response link pointing at the signal + // event; older servers leave it unset. Propagate when present. + if (inNexusContext && response.hasLink()) { + CurrentNexusOperationContext.get().addResponseLink(response.getLink()); + } return new WorkflowSignalOutput(); } @@ -148,17 +167,28 @@ public WorkflowSignalWithStartOutput signalWithStart(WorkflowSignalWithStartInpu Optional signalInput = dataConverterWithWorkflowContext.toPayloads(input.getSignalArguments()); - SignalWithStartWorkflowExecutionRequest request = - requestsHelper - .newSignalWithStartWorkflowExecutionRequest( - startRequest, input.getSignalName(), signalInput.orElse(null)) - .build(); + SignalWithStartWorkflowExecutionRequest.Builder requestBuilder = + requestsHelper.newSignalWithStartWorkflowExecutionRequest( + startRequest, input.getSignalName(), signalInput.orElse(null)); + // If this signalWithStart is being issued from inside a Nexus operation handler, forward + // the inbound Nexus task links so both the WorkflowExecutionStarted and + // WorkflowExecutionSignaled events on the callee link back to the caller. + boolean inNexusContext = CurrentNexusOperationContext.isNexusContext(); + if (inNexusContext) { + requestBuilder.addAllLinks(CurrentNexusOperationContext.get().getRequestLinks()); + } + SignalWithStartWorkflowExecutionRequest request = requestBuilder.build(); SignalWithStartWorkflowExecutionResponse response = genericClient.signalWithStart(request); WorkflowExecution execution = WorkflowExecution.newBuilder() .setRunId(response.getRunId()) .setWorkflowId(request.getWorkflowId()) .build(); + // Server >=1.31 with EnableCHASMSignalBacklinks returns a response link pointing at the signal + // event; older servers leave it unset. Propagate when present. + if (inNexusContext && response.hasSignalLink()) { + CurrentNexusOperationContext.get().addResponseLink(response.getSignalLink()); + } // TODO currently SignalWithStartWorkflowExecutionResponse doesn't have eagerWorkflowTask. // We should wire it when it's implemented server-side. return new WorkflowSignalWithStartOutput(new WorkflowStartOutput(execution)); 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 a81fa253a0..23932104fe 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 @@ -10,7 +10,7 @@ public interface GenericWorkflowClient { StartWorkflowExecutionResponse start(StartWorkflowExecutionRequest request); - void signal(SignalWorkflowExecutionRequest request); + SignalWorkflowExecutionResponse signal(SignalWorkflowExecutionRequest request); SignalWithStartWorkflowExecutionResponse signalWithStart( SignalWithStartWorkflowExecutionRequest request); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java index f74d1b6e37..a40e66bc4a 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 @@ -61,13 +61,13 @@ private static Map tagsForStartWorkflow(StartWorkflowExecutionRe } @Override - public void signal(SignalWorkflowExecutionRequest request) { + public SignalWorkflowExecutionResponse signal(SignalWorkflowExecutionRequest request) { Map tags = new ImmutableMap.Builder(1) .put(MetricsTag.SIGNAL_NAME, request.getSignalName()) .build(); Scope scope = metricsScope.tagged(tags); - grpcRetryer.retry( + return grpcRetryer.retryWithResult( () -> service .blockingStub() diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java index d7306ea968..0308683857 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java @@ -6,6 +6,10 @@ import io.temporal.common.interceptors.NexusOperationOutboundCallsInterceptor; import io.temporal.nexus.NexusOperationContext; import io.temporal.nexus.NexusOperationInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nonnull; public class InternalNexusOperationContext { private final String namespace; @@ -14,7 +18,25 @@ public class InternalNexusOperationContext { private final Scope metricScope; private final WorkflowClient client; NexusOperationOutboundCallsInterceptor outboundCalls; + // Link returned by the StartWorkflowExecution response when the operation is backed by a workflow + // (workflow-run operations). Read by NexusStartWorkflowHelper to attach the forward + // operation->workflow link, fabricating a WORKFLOW_EXECUTION_STARTED link when the server omits + // one. Distinct from the response links below. Link startWorkflowResponseLink; + // Links extracted from the inbound Nexus task. Stored once at the task-handler boundary so the + // workflow client can attach them to the outgoing requests it issues (e.g. signal, + // signalWithStart) via the request's links field. + private List requestLinks = Collections.emptyList(); + // Links returned by outbound RPCs the operation handler issues (such as + // SignalWorkflowExecutionResponse.link or SignalWithStartWorkflowExecutionResponse.signal_link). + // One entry per outbound RPC that returned a link. Drained + // by the task handler when building StartOperationResponse so each RPC the handler issued gets a + // corresponding link on the caller workflow's history event. + // + // A handler may issue RPCs from multiple threads, so every read and write of this list is guarded + // by responseLinksLock and getResponseLinks() returns a defensive copy taken under the lock. + private final Object responseLinksLock = new Object(); + private final List responseLinks = new ArrayList<>(); public InternalNexusOperationContext( String namespace, @@ -60,6 +82,19 @@ public NexusOperationContext getUserFacingContext() { return new NexusOperationContextImpl(); } + /** + * Set the {@code common.v1.Link}s extracted from the inbound Nexus task so they can be attached + * to RPCs issued by the operation handler. + */ + public void setRequestLinks(List links) { + this.requestLinks = links == null ? Collections.emptyList() : links; + } + + /** Links from the inbound Nexus task; empty if none. */ + public @Nonnull List getRequestLinks() { + return Collections.unmodifiableList(requestLinks); + } + public void setStartWorkflowResponseLink(Link link) { this.startWorkflowResponseLink = link; } @@ -68,6 +103,32 @@ public Link getStartWorkflowResponseLink() { return startWorkflowResponseLink; } + /** + * Append a response link returned by an outbound RPC the operation handler issued (e.g. signal, + * signalWithStart, etc). The task handler drains the list when building the operation's + * StartOperationResponse. + */ + public void addResponseLink(Link link) { + if (link != null) { + synchronized (responseLinksLock) { + responseLinks.add(link); + } + } + } + + /** + * Response links from every outbound RPC the handler issued. Returned as an unmodifiable view; + * callers must not attempt to mutate. Entries are accumulated while the operation handler runs + * (the call that flows through {@link + * io.temporal.common.interceptors.NexusOperationInboundCallsInterceptor#startOperation}) and are + * drained afterward by the task handler when building the StartOperationResponse. + */ + public @Nonnull List getResponseLinks() { + synchronized (responseLinksLock) { + return Collections.unmodifiableList(new ArrayList<>(responseLinks)); + } + } + private class NexusOperationContextImpl implements NexusOperationContext { @Override public NexusOperationInfo getInfo() { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index 7f10ba8c62..0fac5263a9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -20,6 +20,7 @@ import io.temporal.failure.CanceledFailure; import io.temporal.failure.TemporalFailure; import io.temporal.internal.common.InternalUtils; +import io.temporal.internal.common.LinkConverter; import io.temporal.internal.common.NexusUtil; import io.temporal.internal.worker.NexusTask; import io.temporal.internal.worker.NexusTaskHandler; @@ -284,6 +285,10 @@ private StartOperationResponse handleStartOperation( .setCallbackUrl(task.getCallback()) .setRequestId(task.getRequestId()); task.getCallbackHeaderMap().forEach(operationStartDetails::putCallbackHeader); + // Stash the inbound links in common.v1.Link form on the operation context so the RPCs the + // handler issues (e.g. signal, signalWithStart, etc) can attach them to their + // request's links field. + List inboundCommonLinks = new ArrayList<>(); task.getLinksList() .forEach( link -> { @@ -296,7 +301,23 @@ private StartOperationResponse handleStartOperation( "Invalid link URL: " + link.getUrl(), e); } + // LinkConverter only returns a WorkflowEvent-shaped common.v1.Link; nexus links of + // other shapes (e.g. non-temporal URLs) come back null and are intentionally not + // forwarded onto the RPCs the handler issues, which require the WorkflowEvent + // variant. Log so a debugging session can see what was dropped. + io.temporal.api.common.v1.Link commonLink = + LinkConverter.nexusLinkToWorkflowEvent(link); + if (commonLink != null) { + inboundCommonLinks.add(commonLink); + } else { + log.warn( + "Dropping inbound Nexus link from outbound link propagation: type='{}'," + + " url='{}' (not a parseable temporal WorkflowEvent link)", + link.getType(), + link.getUrl()); + } }); + CurrentNexusOperationContext.get().setRequestLinks(inboundCommonLinks); HandlerInputContent.Builder input = HandlerInputContent.newBuilder().setDataStream(task.getPayload().toByteString().newInput()); @@ -307,10 +328,28 @@ private StartOperationResponse handleStartOperation( try { OperationStartResult result = startOperation(context, operationStartDetails.build(), input.build()); + // If any RPCs the handler issued (e.g. signal, signalWithStart, etc) returned + // response links, propagate them to the caller so the caller workflow's history event links + // to each event on the callee. Same set of response links applies to both sync and async + // response variants. + List responseLinks = new ArrayList<>(); + for (io.temporal.api.common.v1.Link responseLink : + CurrentNexusOperationContext.get().getResponseLinks()) { + if (!responseLink.hasWorkflowEvent()) { + continue; + } + io.temporal.api.nexus.v1.Link converted = + LinkConverter.workflowEventToNexusLink(responseLink.getWorkflowEvent()); + if (converted != null) { + responseLinks.add(converted); + } + } + if (result.isSync()) { startResponseBuilder.setSyncSuccess( StartOperationResponse.Sync.newBuilder() .setPayload(Payload.parseFrom(result.getSyncResult().getDataBytes())) + .addAllLinks(responseLinks) .build()); } else { startResponseBuilder.setAsyncSuccess( @@ -326,6 +365,7 @@ private StartOperationResponse handleStartOperation( .setUrl(link.getUri().toString()) .build()) .collect(Collectors.toList())) + .addAllLinks(responseLinks) .build()); } } catch (OperationException e) { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java new file mode 100644 index 0000000000..85be43a8ce --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java @@ -0,0 +1,307 @@ +package io.temporal.internal.client; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.uber.m3.tally.RootScopeBuilder; +import com.uber.m3.tally.Scope; +import io.temporal.api.common.v1.Link; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse; +import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionResponse; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionResponse; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalInput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalWithStartInput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowStartInput; +import io.temporal.internal.client.external.GenericWorkflowClient; +import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +/** + * Unit tests for {@link RootWorkflowClientInvoker#signal} link propagation in and out of the Nexus + * operation context. These run against mocked dependencies and exercise the code paths that the + * integration tests in {@code SignalOperationLinkingTest} can only cover when a real flag-enabled + * server is available. + */ +public class RootWorkflowClientInvokerLinkPropagationTest { + + private static final String NAMESPACE = "test-namespace"; + private static final String WORKFLOW_ID = "wf-target"; + + private GenericWorkflowClient genericClient; + private RootWorkflowClientInvoker invoker; + private InternalNexusOperationContext nexusCtx; + + @Before + public void setUp() { + genericClient = mock(GenericWorkflowClient.class); + invoker = + new RootWorkflowClientInvoker( + genericClient, + WorkflowClientOptions.newBuilder() + .setNamespace(NAMESPACE) + .validateAndBuildWithDefaults(), + new WorkerFactoryRegistry()); + Scope metricsScope = new RootScopeBuilder().reportEvery(com.uber.m3.util.Duration.ofMillis(10)); + nexusCtx = + new InternalNexusOperationContext( + NAMESPACE, "tq", "endpoint", metricsScope, mock(WorkflowClient.class)); + CurrentNexusOperationContext.set(nexusCtx); + } + + @After + public void tearDown() { + CurrentNexusOperationContext.unset(); + } + + /** + * Happy path against a flag-enabled server: inbound nexus links are forwarded onto the + * SignalWorkflowExecutionRequest, and the response link is captured back onto the operation + * context. + */ + @Test + public void signalForwardsInboundLinksAndCapturesResponseLink() { + Link inboundLink = + workflowEventLink( + "caller-wf", "caller-run", EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); + nexusCtx.setRequestLinks(Collections.singletonList(inboundLink)); + + Link responseLink = + workflowEventLink( + WORKFLOW_ID, "target-run", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + SignalWorkflowExecutionResponse response = + SignalWorkflowExecutionResponse.newBuilder().setLink(responseLink).build(); + when(genericClient.signal(any(SignalWorkflowExecutionRequest.class))).thenReturn(response); + + invoker.signal(newSignalInput()); + + // Forward direction: the request the SDK sent carries the inbound link. + ArgumentCaptor captor = + ArgumentCaptor.forClass(SignalWorkflowExecutionRequest.class); + org.mockito.Mockito.verify(genericClient).signal(captor.capture()); + SignalWorkflowExecutionRequest sent = captor.getValue(); + Assert.assertEquals("request should carry the single inbound link", 1, sent.getLinksCount()); + Assert.assertEquals(inboundLink, sent.getLinks(0)); + + // Backward direction: the response's link is now on the context for the task handler to read. + List captured = nexusCtx.getResponseLinks(); + Assert.assertEquals("expected one captured response link", 1, captured.size()); + Assert.assertEquals(responseLink, captured.get(0)); + } + + /** + * Older-server compatibility: the server returns a response without {@code link} set. The SDK + * must not crash and must leave the operation context's response link list empty. + */ + @Test + public void signalAgainstOlderServerCapturesNoResponseLink() { + Link inboundLink = + workflowEventLink( + "caller-wf", "caller-run", EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); + nexusCtx.setRequestLinks(Collections.singletonList(inboundLink)); + + // Pre-1.31 server / flag-off server: response has no link. + SignalWorkflowExecutionResponse response = SignalWorkflowExecutionResponse.getDefaultInstance(); + when(genericClient.signal(any(SignalWorkflowExecutionRequest.class))).thenReturn(response); + + invoker.signal(newSignalInput()); + + // Forward direction still works regardless of server version. + ArgumentCaptor captor = + ArgumentCaptor.forClass(SignalWorkflowExecutionRequest.class); + org.mockito.Mockito.verify(genericClient).signal(captor.capture()); + Assert.assertEquals(1, captor.getValue().getLinksCount()); + + // Backward direction: no response link captured because the server didn't send one. + Assert.assertTrue( + "expected no captured response link when server returned no link", + nexusCtx.getResponseLinks().isEmpty()); + } + + /** + * Multi-signal: two signal RPCs in a row each contribute a response link; both must be captured + * in order on the context, ready for the task handler to drain into the operation response. + */ + @Test + public void multipleSignalsAccumulateAllResponseLinks() { + Link firstResponseLink = + workflowEventLink("callee-a", "run-a", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + Link secondResponseLink = + workflowEventLink("callee-b", "run-b", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + when(genericClient.signal(any(SignalWorkflowExecutionRequest.class))) + .thenReturn(SignalWorkflowExecutionResponse.newBuilder().setLink(firstResponseLink).build()) + .thenReturn( + SignalWorkflowExecutionResponse.newBuilder().setLink(secondResponseLink).build()); + + invoker.signal(newSignalInput()); + invoker.signal(newSignalInput()); + + List captured = nexusCtx.getResponseLinks(); + Assert.assertEquals( + "expected one response link per signal call", + Arrays.asList(firstResponseLink, secondResponseLink), + captured); + } + + /** + * Happy-path mirror of {@link #signalForwardsInboundLinksAndCapturesResponseLink} but for {@code + * signalWithStart}. The forward direction must attach inbound links to {@link + * SignalWithStartWorkflowExecutionRequest#getLinksList}, and the backward direction must capture + * {@code response.signal_link} via the same response link path. Different proto field name + * ({@code signal_link} vs {@code link}) and different code path inside {@link + * io.temporal.internal.client.RootWorkflowClientInvoker#signalWithStart} — a regression in only + * one branch would otherwise pass the plain-signal tests. + */ + @Test + public void signalWithStartForwardsInboundLinksAndCapturesResponseLink() { + Link inboundLink = + workflowEventLink( + "caller-wf", "caller-run", EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED); + nexusCtx.setRequestLinks(Collections.singletonList(inboundLink)); + + Link responseLink = + workflowEventLink( + WORKFLOW_ID, "target-run", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + SignalWithStartWorkflowExecutionResponse response = + SignalWithStartWorkflowExecutionResponse.newBuilder() + .setRunId("target-run") + .setSignalLink(responseLink) + .build(); + when(genericClient.signalWithStart(any(SignalWithStartWorkflowExecutionRequest.class))) + .thenReturn(response); + + invoker.signalWithStart(newSignalWithStartInput()); + + // Forward direction: the SignalWithStartWorkflowExecutionRequest carries the inbound link. + ArgumentCaptor captor = + ArgumentCaptor.forClass(SignalWithStartWorkflowExecutionRequest.class); + org.mockito.Mockito.verify(genericClient).signalWithStart(captor.capture()); + SignalWithStartWorkflowExecutionRequest sent = captor.getValue(); + Assert.assertEquals("request should carry the single inbound link", 1, sent.getLinksCount()); + Assert.assertEquals(inboundLink, sent.getLinks(0)); + + // Backward direction: response.signal_link is on the context for the task handler to read. + List captured = nexusCtx.getResponseLinks(); + Assert.assertEquals("expected one captured response link", 1, captured.size()); + Assert.assertEquals(responseLink, captured.get(0)); + } + + /** + * Mixed-RPC accumulation: a handler that issues one signal and one signalWithStart against the + * same context must end up with both response links captured, in call order. Guards against + * regressions where one of the two code paths stops appending to the same list. + */ + @Test + public void mixedSignalAndSignalWithStartAccumulateAllResponseLinks() { + Link signalResponseLink = + workflowEventLink("callee-s", "run-s", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + Link signalWithStartResponseLink = + workflowEventLink( + "callee-sws", "run-sws", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + when(genericClient.signal(any(SignalWorkflowExecutionRequest.class))) + .thenReturn( + SignalWorkflowExecutionResponse.newBuilder().setLink(signalResponseLink).build()); + when(genericClient.signalWithStart(any(SignalWithStartWorkflowExecutionRequest.class))) + .thenReturn( + SignalWithStartWorkflowExecutionResponse.newBuilder() + .setRunId("run-sws") + .setSignalLink(signalWithStartResponseLink) + .build()); + + invoker.signal(newSignalInput()); + invoker.signalWithStart(newSignalWithStartInput()); + + Assert.assertEquals( + "expected one response link each from signal and signalWithStart, in call order", + Arrays.asList(signalResponseLink, signalWithStartResponseLink), + nexusCtx.getResponseLinks()); + } + + /** + * Post-rebase start contract: a plain {@code start()} issued from inside a Nexus operation + * handler captures only the FORWARD operation->workflow link (via {@code + * setStartWorkflowResponseLink}) and deliberately does NOT add a response link (unlike + * signal/signalWithStart). Replaces the two start-link tests removed by the rebase and guards + * against a regression that re-adds a response link on the start path. + */ + @Test + public void startSetsForwardLinkOnlyAndCapturesNoResponseLink() { + Link startResponseLink = + workflowEventLink( + WORKFLOW_ID, "target-run", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); + StartWorkflowExecutionResponse response = + StartWorkflowExecutionResponse.newBuilder() + .setRunId("target-run") + .setLink(startResponseLink) + .build(); + when(genericClient.start(any(StartWorkflowExecutionRequest.class))).thenReturn(response); + + invoker.start(newStartInput()); + + // Forward direction: the start response link is stashed for NexusStartWorkflowHelper to read. + Assert.assertEquals( + "expected the forward start link on the context", + startResponseLink, + nexusCtx.getStartWorkflowResponseLink()); + + // Backward direction: start must not add a response link. + Assert.assertTrue( + "expected no response link captured on the start path", + nexusCtx.getResponseLinks().isEmpty()); + } + + // ── helpers ────────────────────────────────────────────────────────────────────────────── + + private static WorkflowSignalInput newSignalInput() { + return new WorkflowSignalInput( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).build(), + "test-signal", + Header.empty(), + new Object[] {"payload"}); + } + + private static WorkflowStartInput newStartInput() { + WorkflowOptions options = + WorkflowOptions.newBuilder().setTaskQueue("tq").setDisableEagerExecution(true).build(); + return new WorkflowStartInput( + WORKFLOW_ID, "TestWorkflow", Header.empty(), new Object[] {}, options); + } + + private static WorkflowSignalWithStartInput newSignalWithStartInput() { + WorkflowOptions options = WorkflowOptions.newBuilder().setTaskQueue("tq").build(); + WorkflowStartInput startInput = + new WorkflowStartInput( + WORKFLOW_ID, "TestWorkflow", Header.empty(), new Object[] {}, options); + return new WorkflowSignalWithStartInput( + startInput, "test-signal", new Object[] {"signal-payload"}); + } + + private static Link workflowEventLink(String workflowId, String runId, EventType eventType) { + return Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId(workflowId) + .setRunId(runId) + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder().setEventType(eventType))) + .build(); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java index ad7c628e98..8649cf7393 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java @@ -7,10 +7,14 @@ import com.uber.m3.tally.Scope; import com.uber.m3.util.Duration; import io.nexusrpc.Header; +import io.nexusrpc.OperationException; import io.nexusrpc.handler.*; +import io.temporal.api.common.v1.Link; import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.EventType; import io.temporal.api.nexus.v1.Request; import io.temporal.api.nexus.v1.StartOperationRequest; +import io.temporal.api.nexus.v1.StartOperationResponse; import io.temporal.api.workflowservice.v1.PollNexusTaskQueueResponse; import io.temporal.client.WorkflowClient; import io.temporal.common.converter.DataConverter; @@ -157,6 +161,220 @@ public void startAsyncSyncOperation() throws TimeoutException { "test id", result.getResponse().getStartOperation().getAsyncSuccess().getOperationToken()); } + /** + * Verify that signal response links stashed on the {@link InternalNexusOperationContext} during a + * handler invocation are merged into the resulting {@code StartOperationResponse.Async} via + * {@link io.temporal.internal.common.LinkConverter}. No server required. + */ + @Test + public void asyncResponseIncludesSignalResponseLinks() throws TimeoutException { + WorkflowClient client = mock(WorkflowClient.class); + NexusTaskHandlerImpl nexusTaskHandlerImpl = + new NexusTaskHandlerImpl( + client, NAMESPACE, TASK_QUEUE, dataConverter, new WorkerInterceptor[] {}); + nexusTaskHandlerImpl.registerNexusServiceImplementations( + new Object[] {new ResponseLinkStashingAsyncServiceImpl()}); + nexusTaskHandlerImpl.start(); + + PollNexusTaskQueueResponse.Builder task = + PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setStartOperation( + StartOperationRequest.newBuilder() + .setOperation("operation") + .setService("TestNexusService1") + .setPayload(dataConverter.toPayload("op-token").get()) + .build())); + + NexusTaskHandler.Result result = + nexusTaskHandlerImpl.handle(new NexusTask(task, null, null), metricsScope); + + Assert.assertNull(result.getHandlerException()); + StartOperationResponse.Async async = result.getResponse().getStartOperation().getAsyncSuccess(); + Assert.assertEquals("op-token", async.getOperationToken()); + Assert.assertEquals( + "expected one signal response link on the async response", 1, async.getLinksCount()); + // The response link was stashed as a WorkflowEvent for callee workflowId "callee-wf"; the + // response should contain a temporal:// URL referencing that workflow. + Assert.assertTrue( + "expected response link URL to reference the callee workflow, got: " + + async.getLinks(0).getUrl(), + async.getLinks(0).getUrl().contains("callee-wf")); + } + + /** + * Same as {@link #asyncResponseIncludesSignalResponseLinks} but the handler returns sync. Guards + * against the sync and async builders drifting (both must call {@code + * addAllLinks(responseLinks)}). + */ + @Test + public void syncResponseIncludesSignalResponseLinks() throws TimeoutException { + WorkflowClient client = mock(WorkflowClient.class); + NexusTaskHandlerImpl nexusTaskHandlerImpl = + new NexusTaskHandlerImpl( + client, NAMESPACE, TASK_QUEUE, dataConverter, new WorkerInterceptor[] {}); + nexusTaskHandlerImpl.registerNexusServiceImplementations( + new Object[] {new ResponseLinkStashingSyncServiceImpl()}); + nexusTaskHandlerImpl.start(); + + PollNexusTaskQueueResponse.Builder task = + PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setStartOperation( + StartOperationRequest.newBuilder() + .setOperation("operation") + .setService("TestNexusService1") + .setPayload(dataConverter.toPayload("input").get()) + .build())); + + NexusTaskHandler.Result result = + nexusTaskHandlerImpl.handle(new NexusTask(task, null, null), metricsScope); + + Assert.assertNull(result.getHandlerException()); + StartOperationResponse.Sync sync = result.getResponse().getStartOperation().getSyncSuccess(); + Assert.assertEquals( + "expected one signal response link on the sync response", 1, sync.getLinksCount()); + Assert.assertTrue( + "expected response link URL to reference the callee workflow, got: " + + sync.getLinks(0).getUrl(), + sync.getLinks(0).getUrl().contains("callee-wf")); + } + + /** + * Failure path: a handler that stashes a response link (as a successful signal RPC would) and + * then throws afterwards must NOT leak the captured response link onto the failure response. + * Response links are only drained on the success branch of {@link + * NexusTaskHandlerImpl#handleStartOperation}; the failure branch builds a {@code + * StartOperationResponse.failure} that carries no links. + */ + @Test + public void failureResponseDropsCapturedResponseLinks() throws TimeoutException { + WorkflowClient client = mock(WorkflowClient.class); + NexusTaskHandlerImpl nexusTaskHandlerImpl = + new NexusTaskHandlerImpl( + client, NAMESPACE, TASK_QUEUE, dataConverter, new WorkerInterceptor[] {}); + nexusTaskHandlerImpl.registerNexusServiceImplementations( + new Object[] {new ResponseLinkStashingThenThrowingServiceImpl()}); + nexusTaskHandlerImpl.start(); + + PollNexusTaskQueueResponse.Builder task = + PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setStartOperation( + StartOperationRequest.newBuilder() + .setOperation("operation") + .setService("TestNexusService1") + .setPayload(dataConverter.toPayload("input").get()) + .build())); + + NexusTaskHandler.Result result = + nexusTaskHandlerImpl.handle(new NexusTask(task, null, null), metricsScope); + + Assert.assertNull(result.getHandlerException()); + StartOperationResponse response = result.getResponse().getStartOperation(); + Assert.assertEquals( + "expected the failure response variant", + StartOperationResponse.VariantCase.FAILURE, + response.getVariantCase()); + // The handler captured a response link before throwing; the failure response must not carry it + // (and has no links field at all). + Assert.assertFalse( + "failure response variant should not expose any success-path links", + response.hasSyncSuccess() || response.hasAsyncSuccess()); + } + + /** + * Handler that simulates what a real Nexus operation would do after issuing a signal: stash a + * response link on the operation context, then return an async result. Lets us exercise the + * async-response link merge in {@link NexusTaskHandlerImpl} without standing up a real signal + * RPC. + */ + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class ResponseLinkStashingAsyncServiceImpl { + @OperationImpl + public OperationHandler operation() { + return new OperationHandler() { + @Override + public OperationStartResult start( + OperationContext ctx, OperationStartDetails details, @Nullable String token) { + Link responseLink = + Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId("callee-wf") + .setRunId("callee-run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED))) + .build(); + CurrentNexusOperationContext.get().addResponseLink(responseLink); + return OperationStartResult.async(token); + } + + @Override + public void cancel(OperationContext ctx, OperationCancelDetails details) {} + }; + } + } + + /** Sync mirror of {@link ResponseLinkStashingAsyncServiceImpl}. */ + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class ResponseLinkStashingSyncServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, input) -> { + Link responseLink = + Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId("callee-wf") + .setRunId("callee-run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventType( + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED))) + .build(); + CurrentNexusOperationContext.get().addResponseLink(responseLink); + return "result"; + }); + } + } + + /** + * Stashes a response link on the operation context (as a successful signal RPC would) and then + * throws an {@link OperationException}, exercising the failure branch of {@link + * NexusTaskHandlerImpl#handleStartOperation}. + */ + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class ResponseLinkStashingThenThrowingServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, input) -> { + Link responseLink = + Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId("callee-wf") + .setRunId("callee-run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventType( + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED))) + .build(); + CurrentNexusOperationContext.get().addResponseLink(responseLink); + throw OperationException.failed("boom after capturing a response link"); + }); + } + } + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) public class TestNexusServiceImpl { @OperationImpl diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SignalOperationLinkingTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SignalOperationLinkingTest.java new file mode 100644 index 0000000000..afb91f72d2 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/SignalOperationLinkingTest.java @@ -0,0 +1,423 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.OperationCancelDetails; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.OperationStartDetails; +import io.nexusrpc.handler.OperationStartResult; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.BatchRequest; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.nexus.Nexus; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationHandle; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nullable; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies link propagation in both directions when a Nexus operation handler interacts with a + * workflow via signal. Covers three scenarios: + * + *

    + *
  • {@link #testSignalOperationLinks()} — sync handler, two signals (signalWithStart + plain + * signal). + *
  • {@link #testMultiSignalOperationLinks()} — one Nexus operation signals three different + * callees; verifies all three response links land on the caller's single {@code + * NexusOperationCompleted} event. + *
  • {@link #testAsyncSignalOperationLinks()} — handler returns an async result after signaling; + * verifies the response link lands on {@code NexusOperationStarted} (the async response path + * in {@link io.temporal.internal.nexus.NexusTaskHandlerImpl}). + *
+ * + *

All tests require Temporal server ≥ 1.31 with {@code EnableCHASMSignalBacklinks=true}; the + * in-memory test server does not implement this path so the class is skipped unless a real server + * is in use. + */ +public class SignalOperationLinkingTest { + + private static final String MODE_SIGNAL_WITH_START = "signalWithStart"; + private static final String MODE_SIGNAL = "signal"; + private static final String MODE_MULTI_SIGNAL_WITH_START = "multi"; + private static final String MODE_ASYNC_SIGNAL_WITH_START = "asyncSignalWithStart"; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(SignalCallerWorkflow.class, SignalCalleeWorkflowImpl.class) + .setNexusServiceImplementation(new SignalingNexusServiceImpl()) + .build(); + + @BeforeClass + public static void requireExternalService() { + // The server-side response link implementation (temporalio/temporal#9897) is gated by + // EnableCHASMSignalBacklinks and is only present in real servers. + assumeTrue( + "signal response links require a real server with EnableCHASMSignalBacklinks=true", + SDKTestWorkflowRule.useExternalService); + } + + // ── Tests ──────────────────────────────────────────────────────────────────────────────── + + @Test + public void testSignalOperationLinks() { + runTwoSignalScenario(); + } + + /** + * One Nexus operation signals three different callees. The handler's three signal-class RPCs each + * contribute a response link and all three end up on the caller's single {@code + * NexusOperationCompleted} event. + */ + @Test + public void testMultiSignalOperationLinks() { + WorkflowClient client = testWorkflowRule.getWorkflowClient(); + List calleeIds = Arrays.asList("multicallee-a", "multicallee-b", "multicallee-c"); + + TestWorkflows.TestWorkflow1 callerStub = + testWorkflowRule.newWorkflowStubTimeoutOptions( + TestWorkflows.TestWorkflow1.class, "multicaller"); + String result = + callerStub.execute(MODE_MULTI_SIGNAL_WITH_START + ":" + String.join(",", calleeIds)); + Assert.assertEquals("ok:multi:" + String.join(",", calleeIds), result); + + // Each callee gets one signal and completes. + for (String calleeId : calleeIds) { + String calleeResult = client.newUntypedWorkflowStub(calleeId).getResult(String.class); + Assert.assertEquals("multi-signal", calleeResult); + } + + String callerWorkflowId = WorkflowStub.fromTyped(callerStub).getExecution().getWorkflowId(); + History callerHistory = client.fetchHistory(callerWorkflowId).getHistory(); + + // Caller → each callee: forward links on every callee's WorkflowExecutionSignaled event. + for (String calleeId : calleeIds) { + History calleeHistory = client.fetchHistory(calleeId).getHistory(); + assertForwardLinks(calleeHistory, callerWorkflowId, /* expectedCount= */ 1); + } + + // Callee → caller: the single NexusOperationCompleted carries one response link per callee. + List completedEvents = + getAllEventsOfType(callerHistory, EventType.EVENT_TYPE_NEXUS_OPERATION_COMPLETED); + Assert.assertEquals( + "expected exactly one NexusOperationCompleted event", 1, completedEvents.size()); + HistoryEvent completed = completedEvents.get(0); + Assert.assertEquals( + "expected one response link per signaled callee", + calleeIds.size(), + completed.getLinksCount()); + List responseLinkWorkflowIds = new ArrayList<>(); + for (int i = 0; i < completed.getLinksCount(); i++) { + io.temporal.api.common.v1.Link.WorkflowEvent responseLink = + completed.getLinks(i).getWorkflowEvent(); + responseLinkWorkflowIds.add(responseLink.getWorkflowId()); + EventType responseLinkEventType = + responseLink.hasRequestIdRef() + ? responseLink.getRequestIdRef().getEventType() + : responseLink.getEventRef().getEventType(); + Assert.assertEquals(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED, responseLinkEventType); + } + Assert.assertTrue( + "expected response links to reference all three callees: " + responseLinkWorkflowIds, + responseLinkWorkflowIds.containsAll(calleeIds)); + } + + /** + * Async response path: handler signals the callee then returns an async result. Verifies that the + * response link lands on {@code NexusOperationStarted} (the async branch in + * NexusTaskHandlerImpl). + */ + @Test + public void testAsyncSignalOperationLinks() { + WorkflowClient client = testWorkflowRule.getWorkflowClient(); + String calleeWorkflowId = "async-callee"; + + TestWorkflows.TestWorkflow1 callerStub = + testWorkflowRule.newWorkflowStubTimeoutOptions( + TestWorkflows.TestWorkflow1.class, "async-caller"); + String result = callerStub.execute(MODE_ASYNC_SIGNAL_WITH_START + ":" + calleeWorkflowId); + Assert.assertEquals("async-started", result); + + String calleeResult = client.newUntypedWorkflowStub(calleeWorkflowId).getResult(String.class); + Assert.assertEquals("async-signal", calleeResult); + + String callerWorkflowId = WorkflowStub.fromTyped(callerStub).getExecution().getWorkflowId(); + History callerHistory = client.fetchHistory(callerWorkflowId).getHistory(); + History calleeHistory = client.fetchHistory(calleeWorkflowId).getHistory(); + + assertForwardLinks(calleeHistory, callerWorkflowId, /* expectedCount= */ 1); + + // Backward direction lands on NexusOperationStarted for the async response path. + List startedEvents = + getAllEventsOfType(callerHistory, EventType.EVENT_TYPE_NEXUS_OPERATION_STARTED); + Assert.assertEquals( + "expected exactly one NexusOperationStarted event for the async op", + 1, + startedEvents.size()); + assertResponseLink(startedEvents.get(0), calleeWorkflowId); + } + + // ── Shared scenario + assertion helpers ────────────────────────────────────────────────── + + /** Drive the two-signal flow (signalWithStart + plain signal) and assert link propagation. */ + private void runTwoSignalScenario() { + WorkflowClient client = testWorkflowRule.getWorkflowClient(); + String calleeWorkflowId = "callee"; + + TestWorkflows.TestWorkflow1 callerStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class, "caller"); + String result = callerStub.execute("twoSync:" + calleeWorkflowId); + Assert.assertEquals("ok:signalWithStart|ok:signal", result); + + String calleeResult = client.newUntypedWorkflowStub(calleeWorkflowId).getResult(String.class); + Assert.assertEquals("first,second", calleeResult); + + String callerWorkflowId = WorkflowStub.fromTyped(callerStub).getExecution().getWorkflowId(); + History callerHistory = client.fetchHistory(callerWorkflowId).getHistory(); + History calleeHistory = client.fetchHistory(calleeWorkflowId).getHistory(); + + assertForwardLinks(calleeHistory, callerWorkflowId, /* expectedCount= */ 2); + + List completedEvents = + getAllEventsOfType(callerHistory, EventType.EVENT_TYPE_NEXUS_OPERATION_COMPLETED); + Assert.assertEquals( + "expected two NexusOperationCompleted events on the caller", 2, completedEvents.size()); + for (HistoryEvent completed : completedEvents) { + assertResponseLink(completed, calleeWorkflowId); + } + } + + /** + * Assert that the callee history has {@code expectedCount} {@code WorkflowExecutionSignaled} + * events, each linked back to the caller's {@code NexusOperationScheduled} event. + */ + private static void assertForwardLinks( + History calleeHistory, String callerWorkflowId, int expectedCount) { + List signaledEvents = + getAllEventsOfType(calleeHistory, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + Assert.assertEquals( + "expected " + expectedCount + " WorkflowExecutionSignaled events on the callee", + expectedCount, + signaledEvents.size()); + for (HistoryEvent signaled : signaledEvents) { + Assert.assertTrue( + "expected at least one link on each WorkflowExecutionSignaled event", + signaled.getLinksCount() >= 1); + Assert.assertEquals( + "signaled-event link should reference the caller workflow", + callerWorkflowId, + signaled.getLinks(0).getWorkflowEvent().getWorkflowId()); + Assert.assertEquals( + EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, + signaled.getLinks(0).getWorkflowEvent().getEventRef().getEventType()); + } + } + + /** + * Assert that a single caller-side event ({@code NexusOperationCompleted} or {@code + * NexusOperationStarted}) carries a response link to the callee's {@code + * WorkflowExecutionSignaled} event. Server PR #9897 keys these via {@code RequestIdReference} + * rather than {@code EventReference}, so we accept either oneof variant. + */ + private static void assertResponseLink(HistoryEvent event, String calleeWorkflowId) { + Assert.assertTrue( + "expected a signal-event response link on " + event.getEventType().name(), + event.getLinksCount() >= 1); + io.temporal.api.common.v1.Link.WorkflowEvent responseLink = + event.getLinks(0).getWorkflowEvent(); + Assert.assertEquals(calleeWorkflowId, responseLink.getWorkflowId()); + EventType responseLinkEventType = + responseLink.hasRequestIdRef() + ? responseLink.getRequestIdRef().getEventType() + : responseLink.getEventRef().getEventType(); + Assert.assertEquals(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED, responseLinkEventType); + } + + /** Find all history events of a given type, in order. */ + private static List getAllEventsOfType(History history, EventType type) { + List out = new ArrayList<>(); + for (HistoryEvent e : history.getEventsList()) { + if (e.getEventType() == type) { + out.add(e); + } + } + return out; + } + + // ── Workflows ──────────────────────────────────────────────────────────────────────────── + + /** + * Caller workflow. Branches on a mode prefix in the input: + * + *

    + *
  • {@code twoSync:} — invoke the nexus op twice synchronously (signalWithStart, + * then signal). + *
  • {@code multi:,,} — invoke the nexus op once synchronously; handler + * signalWithStart's each id. + *
  • {@code asyncSignalWithStart:} — invoke the nexus op asynchronously via {@code + * Workflow.startNexusOperation}; wait for execution start and return without waiting for + * the operation result. + *
+ */ + public static class SignalCallerWorkflow implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + String[] parts = input.split(":", 2); + String mode = parts[0]; + String rest = parts[1]; + + TestNexusServices.TestNexusService1 stub = + Workflow.newNexusServiceStub( + TestNexusServices.TestNexusService1.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build()) + .build()); + + switch (mode) { + case "twoSync": + { + String r1 = stub.operation(MODE_SIGNAL_WITH_START + ":" + rest); + String r2 = stub.operation(MODE_SIGNAL + ":" + rest); + return r1 + "|" + r2; + } + case MODE_MULTI_SIGNAL_WITH_START: + return stub.operation(MODE_MULTI_SIGNAL_WITH_START + ":" + rest); + case MODE_ASYNC_SIGNAL_WITH_START: + { + NexusOperationHandle h = + Workflow.startNexusOperation( + stub::operation, MODE_ASYNC_SIGNAL_WITH_START + ":" + rest); + // Wait for the async op to be Started (the event that carries the response link) but + // not for its eventual result — the async op completes outside this workflow. + h.getExecution().get(); + return "async-started"; + } + default: + throw new IllegalArgumentException("unknown mode: " + mode); + } + } + } + + /** Callee workflow. Awaits {@code expectedSignals} signals then returns their joined payloads. */ + @WorkflowInterface + public interface SignalCalleeWorkflow { + @WorkflowMethod + String execute(int expectedSignals); + + @SignalMethod + void ping(String msg); + } + + public static class SignalCalleeWorkflowImpl implements SignalCalleeWorkflow { + private final List received = new ArrayList<>(); + + @Override + public String execute(int expectedSignals) { + Workflow.await(() -> received.size() >= expectedSignals); + return String.join(",", received); + } + + @Override + public void ping(String msg) { + received.add(msg); + } + } + + // ── Nexus service ──────────────────────────────────────────────────────────────────────── + + /** + * Single Nexus operation that dispatches based on a mode prefix in its input. Supports sync and + * async return shapes. + */ + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class SignalingNexusServiceImpl { + + @OperationImpl + public OperationHandler operation() { + return new OperationHandler() { + @Override + public OperationStartResult start( + OperationContext ctx, OperationStartDetails details, @Nullable String input) { + String[] parts = input.split(":", 2); + String mode = parts[0]; + String rest = parts[1]; + + io.temporal.nexus.NexusOperationContext opCtx = Nexus.getOperationContext(); + WorkflowClient client = opCtx.getWorkflowClient(); + String taskQueue = opCtx.getInfo().getTaskQueue(); + + switch (mode) { + case MODE_SIGNAL_WITH_START: + signalWithStart(client, rest, taskQueue, /* expectedSignals= */ 2, "first"); + return OperationStartResult.sync("ok:" + MODE_SIGNAL_WITH_START); + case MODE_SIGNAL: + client.newWorkflowStub(SignalCalleeWorkflow.class, rest).ping("second"); + return OperationStartResult.sync("ok:" + MODE_SIGNAL); + case MODE_MULTI_SIGNAL_WITH_START: + for (String id : rest.split(",")) { + signalWithStart(client, id, taskQueue, /* expectedSignals= */ 1, "multi-signal"); + } + return OperationStartResult.sync("ok:multi:" + rest); + case MODE_ASYNC_SIGNAL_WITH_START: + signalWithStart(client, rest, taskQueue, /* expectedSignals= */ 1, "async-signal"); + // Async branch in NexusTaskHandlerImpl. The caller never waits for completion, so + // the token is opaque. + return OperationStartResult.async("async-op-" + UUID.randomUUID()); + default: + throw new IllegalArgumentException("unknown mode: " + mode); + } + } + + @Override + public void cancel(OperationContext ctx, OperationCancelDetails details) { + // Not exercised in these tests. + } + }; + } + + private static void signalWithStart( + WorkflowClient client, + String calleeWorkflowId, + String taskQueue, + int expectedSignals, + String signalPayload) { + SignalCalleeWorkflow startStub = + client.newWorkflowStub( + SignalCalleeWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(calleeWorkflowId) + .setTaskQueue(taskQueue) + .build()); + BatchRequest batch = client.newSignalWithStartRequest(); + batch.add(startStub::execute, expectedSignals); + batch.add(startStub::ping, signalPayload); + client.signalWithStart(batch); + } + } +} diff --git a/temporal-test-server/src/test/java/io/temporal/testserver/functional/DescribeWorkflowAsserter.java b/temporal-test-server/src/test/java/io/temporal/testserver/functional/DescribeWorkflowAsserter.java index 90e36fa451..2af5675cec 100644 --- a/temporal-test-server/src/test/java/io/temporal/testserver/functional/DescribeWorkflowAsserter.java +++ b/temporal-test-server/src/test/java/io/temporal/testserver/functional/DescribeWorkflowAsserter.java @@ -189,11 +189,20 @@ public DescribeWorkflowAsserter assertPendingChildrenCount(int expected) { return this; } + /** + * Assert that every expected request-id info is present and matches. Extra entries on the actual + * map are tolerated: a real server records request IDs that the in-memory test server does not + * (for example, the request ID of a signal RPC), so callers assert only the entries they control. + */ public DescribeWorkflowAsserter assertRequestIdInfos(Map expected) { - Assert.assertEquals( - "request id infos should match", - expected, - actual.getWorkflowExtendedInfo().getRequestIdInfosMap()); + Map actualInfos = + actual.getWorkflowExtendedInfo().getRequestIdInfosMap(); + expected.forEach( + (requestId, info) -> + Assert.assertEquals( + "request id info for " + requestId + " should match", + info, + actualInfos.get(requestId))); return this; } } From 85e12a38f8a16878ea81234b07c9ac503afaf4fe Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Mon, 22 Jun 2026 16:06:42 -0400 Subject: [PATCH 020/107] feat(otel): add tracing for startWithUpdate. fixes #2620. (#2925) --- .../opentracing/SpanOperationType.java | 1 + .../ActionTypeAndNameSpanBuilderProvider.java | 1 + ...TracingWorkflowClientCallsInterceptor.java | 22 +++ .../opentracing/UpdateWithStartTest.java | 130 ++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/UpdateWithStartTest.java diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanOperationType.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanOperationType.java index 2f8a27429d..21fc62f73b 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanOperationType.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/SpanOperationType.java @@ -3,6 +3,7 @@ public enum SpanOperationType { START_WORKFLOW("StartWorkflow"), SIGNAL_WITH_START_WORKFLOW("SignalWithStartWorkflow"), + UPDATE_WITH_START_WORKFLOW("UpdateWithStartWorkflow"), RUN_WORKFLOW("RunWorkflow"), START_CHILD_WORKFLOW("StartChildWorkflow"), START_CONTINUE_AS_NEW_WORKFLOW("StartContinueAsNewWorkflow"), diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java index 3b56f575da..a1fcadae37 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/ActionTypeAndNameSpanBuilderProvider.java @@ -55,6 +55,7 @@ protected Map getSpanTags(SpanCreationContext context) { switch (operationType) { case START_WORKFLOW: case SIGNAL_WITH_START_WORKFLOW: + case UPDATE_WITH_START_WORKFLOW: return ImmutableMap.of(StandardTagNames.WORKFLOW_ID, context.getWorkflowId()); case START_CHILD_WORKFLOW: return ImmutableMap.of( diff --git a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowClientCallsInterceptor.java b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowClientCallsInterceptor.java index 79a7c1c21a..e6113ac264 100644 --- a/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowClientCallsInterceptor.java +++ b/contrib/temporal-opentracing/src/main/java/io/temporal/opentracing/internal/OpenTracingWorkflowClientCallsInterceptor.java @@ -78,6 +78,28 @@ public WorkflowSignalWithStartOutput signalWithStart(WorkflowSignalWithStartInpu } } + @Override + public WorkflowUpdateWithStartOutput updateWithStart( + WorkflowUpdateWithStartInput input) { + WorkflowStartInput workflowStartInput = input.getWorkflowStartInput(); + StartUpdateInput startUpdateInput = input.getStartUpdateInput(); + Span workflowStartSpan = + contextAccessor.writeSpanContextToHeader( + () -> + createWorkflowStartSpanBuilder( + workflowStartInput, SpanOperationType.UPDATE_WITH_START_WORKFLOW) + .start(), + workflowStartInput.getHeader(), + tracer); + contextAccessor.writeSpanContextToHeader( + workflowStartSpan.context(), startUpdateInput.getHeader(), tracer); + try (Scope ignored = tracer.scopeManager().activate(workflowStartSpan)) { + return super.updateWithStart(input); + } finally { + workflowStartSpan.finish(); + } + } + @Override public QueryOutput query(QueryInput input) { Span workflowQuerySpan = diff --git a/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/UpdateWithStartTest.java b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/UpdateWithStartTest.java new file mode 100644 index 0000000000..12a92aad40 --- /dev/null +++ b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/UpdateWithStartTest.java @@ -0,0 +1,130 @@ +package io.temporal.opentracing; + +import static org.junit.Assert.*; + +import io.opentracing.Scope; +import io.opentracing.Span; +import io.opentracing.mock.MockSpan; +import io.opentracing.mock.MockTracer; +import io.opentracing.util.ThreadLocalScopeManager; +import io.temporal.api.enums.v1.WorkflowIdConflictPolicy; +import io.temporal.client.*; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.workflow.CompletablePromise; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Rule; +import org.junit.Test; + +public class UpdateWithStartTest { + + private static final MockTracer mockTracer = + new MockTracer(new ThreadLocalScopeManager(), MockTracer.Propagator.TEXT_MAP); + + private final OpenTracingOptions OT_OPTIONS = + OpenTracingOptions.newBuilder().setTracer(mockTracer).build(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setInterceptors(new OpenTracingClientInterceptor(OT_OPTIONS)) + .validateAndBuildWithDefaults()) + .setWorkerFactoryOptions( + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new OpenTracingWorkerInterceptor(OT_OPTIONS)) + .validateAndBuildWithDefaults()) + .setWorkflowTypes(WorkflowImpl.class) + .build(); + + @After + public void tearDown() { + mockTracer.reset(); + } + + @WorkflowInterface + public interface TestWorkflow { + @WorkflowMethod + String workflow(String input); + + @UpdateMethod + String update(String value); + } + + public static class WorkflowImpl implements TestWorkflow { + + private final CompletablePromise promise = Workflow.newPromise(); + private String value; + + @Override + public String workflow(String input) { + promise.get(); + return value; + } + + @Override + public String update(String value) { + this.value = value; + promise.complete(null); + return value; + } + } + + @Test + public void updateWithStart() { + WorkflowClient client = testWorkflowRule.getWorkflowClient(); + TestWorkflow workflow = + client.newWorkflowStub( + TestWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_FAIL) + .validateBuildWithDefaults()); + + Span span = mockTracer.buildSpan("ClientFunction").start(); + + try (Scope scope = mockTracer.scopeManager().activate(span)) { + WithStartWorkflowOperation startOp = + new WithStartWorkflowOperation<>(workflow::workflow, "input"); + WorkflowClient.executeUpdateWithStart( + workflow::update, + "update", + UpdateOptions.newBuilder().setWaitForStage(WorkflowUpdateStage.COMPLETED).build(), + startOp); + } finally { + span.finish(); + } + + WorkflowStub.fromTyped(workflow).getResult(String.class); + OpenTracingSpansHelper spansHelper = new OpenTracingSpansHelper(mockTracer.finishedSpans()); + MockSpan clientSpan = spansHelper.getSpanByOperationName("ClientFunction"); + MockSpan workflowStartSpan = spansHelper.getByParentSpan(clientSpan).get(0); + + assertEquals(clientSpan.context().spanId(), workflowStartSpan.parentId()); + assertEquals("UpdateWithStartWorkflow:TestWorkflow", workflowStartSpan.operationName()); + + // updateWithStart propagates the start span context into both the StartWorkflow and + // UpdateWorkflow operation headers + List workflowSpans = spansHelper.getByParentSpan(workflowStartSpan); + assertEquals(2, workflowSpans.size()); + for (MockSpan workflowSpan : workflowSpans) { + assertEquals(workflowStartSpan.context().spanId(), workflowSpan.parentId()); + } + Set operationNames = + workflowSpans.stream().map(MockSpan::operationName).collect(Collectors.toSet()); + assertEquals( + new HashSet<>(Arrays.asList("HandleUpdate:update", "RunWorkflow:TestWorkflow")), + operationNames); + } +} From e97611374820ec509b63ac055f0f995ab520b95b Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 23 Jun 2026 11:05:59 -0700 Subject: [PATCH 021/107] Implement nexus-based activity cancels (#2917) --- .github/workflows/ci.yml | 5 +- AGENTS.md | 2 +- .../activity/ActivityCancellationToken.java | 51 +++++ .../activity/ActivityExecutionContext.java | 12 +- .../ActivityExecutionContextBase.java | 6 + .../ActivityCancellationTokenImpl.java | 51 +++++ .../ActivityExecutionContextFactory.java | 8 + .../ActivityExecutionContextFactoryImpl.java | 53 +++-- .../ActivityExecutionContextImpl.java | 21 +- .../activity/ActivityTaskExecutors.java | 9 +- .../activity/ActivityTaskHandlerImpl.java | 4 + .../internal/activity/HeartbeatContext.java | 9 + .../activity/HeartbeatContextImpl.java | 41 +++- .../InternalActivityExecutionContext.java | 3 + ...alActivityExecutionContextFactoryImpl.java | 5 + .../LocalActivityExecutionContextImpl.java | 11 + .../internal/worker/ActivityPollTask.java | 6 +- .../internal/worker/ActivityWorker.java | 10 +- .../worker/AsyncActivityPollTask.java | 6 +- .../internal/worker/AsyncNexusPollTask.java | 36 +++- .../temporal/internal/worker/AsyncPoller.java | 2 +- .../worker/AsyncWorkflowPollTask.java | 6 +- .../temporal/internal/worker/BasePoller.java | 2 +- .../internal/worker/MultiThreadedPoller.java | 4 +- .../worker/NamespaceCapabilities.java | 12 ++ .../internal/worker/NexusPollTask.java | 36 +++- .../temporal/internal/worker/NexusWorker.java | 30 ++- .../internal/worker/SingleWorkerOptions.java | 19 +- .../internal/worker/SyncActivityWorker.java | 4 + .../worker/WorkerCommandTaskHandler.java | 128 ++++++++++++ .../internal/worker/WorkflowPollTask.java | 6 +- .../internal/worker/WorkflowWorker.java | 20 +- .../main/java/io/temporal/worker/Worker.java | 65 ++++-- .../io/temporal/worker/WorkerFactory.java | 62 +++++- .../activity/HeartbeatContextImplTest.java | 188 +++++++++++++++++- .../internal/worker/SlotSupplierTest.java | 6 +- .../worker/StickyQueueBacklogTest.java | 3 +- .../temporal/worker/WorkerShutdownTest.java | 1 + .../AsyncActivityCompleteWithErrorTest.java | 30 ++- ...AsyncActivityWithCompletionClientTest.java | 4 + ...ivityCancellationTokenIntegrationTest.java | 163 +++++++++++++++ .../workflow/shared/TestActivities.java | 26 ++- .../serviceclient/CloudServiceStubsImpl.java | 2 +- .../OperatorServiceStubsImpl.java | 2 +- .../WorkflowServiceStubsImpl.java | 2 +- .../serviceclient/TestServiceStubsImpl.java | 2 +- 46 files changed, 1096 insertions(+), 78 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerCommandTaskHandler.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea940f214..76aba1538c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,7 +119,10 @@ jobs: --dynamic-config-value nexusoperation.enableStandalone=true \ --dynamic-config-value history.enableChasm=true \ --dynamic-config-value history.enableCHASMSignalBacklinks=true \ - --dynamic-config-value history.enableTransitionHistory=true & + --dynamic-config-value history.enableTransitionHistory=true \ + --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ + --dynamic-config-value frontend.workerCommandsEnabled=true \ + --dynamic-config-value system.enableCancelActivityWorkerCommand=true & sleep 10s # Can't actually run tests against Java 8 because Mockito 5 requires Java 11+. diff --git a/AGENTS.md b/AGENTS.md index 8d449963c0..0a13d6abf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ - The SDK code is written for Java 8. ## Building and Testing -1. Format the code before committing: +1. Format the code before committing (and don't bother running spotlessCheck, just run apply): ```bash ./gradlew --offline spotlessApply ``` diff --git a/temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java b/temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java new file mode 100644 index 0000000000..612aeaae27 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java @@ -0,0 +1,51 @@ +package io.temporal.activity; + +import io.temporal.client.ActivityCanceledException; +import io.temporal.common.Experimental; +import java.util.concurrent.CompletableFuture; + +/** Token that allows an Activity implementation to observe cancellation requests. */ +@Experimental +public interface ActivityCancellationToken { + + ActivityCancellationToken NONE = + new ActivityCancellationToken() { + @Override + public boolean isCancellationRequested() { + return false; + } + + @Override + public void throwIfCancellationRequested() throws ActivityCanceledException {} + + @Override + public CompletableFuture getCancellationFuture() { + return new CompletableFuture<>(); + } + }; + + /** + * Returns true after cancellation has been requested for this Activity Execution. + * + *

If this method returns true, the Activity implementation should stop its work and usually + * call {@link #throwIfCancellationRequested()} to report successful cancellation to Temporal. + */ + boolean isCancellationRequested(); + + /** + * Throws {@link ActivityCanceledException} if cancellation has been requested for this Activity + * Execution. + * + *

Rethrowing this exception from Activity code reports successful cancellation to Temporal. + */ + void throwIfCancellationRequested() throws ActivityCanceledException; + + /** + * Future that completes exceptionally with {@link ActivityCanceledException} when cancellation + * has been requested for this Activity Execution. + * + *

Activity code should still call {@link #throwIfCancellationRequested()} or otherwise report + * cancellation if it wants the Activity Execution to complete as canceled. + */ + CompletableFuture getCancellationFuture(); +} diff --git a/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java b/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java index 8918effc4b..0d8c1be793 100644 --- a/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java +++ b/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java @@ -3,6 +3,7 @@ import com.uber.m3.tally.Scope; import io.temporal.client.ActivityCompletionException; import io.temporal.client.WorkflowClient; +import io.temporal.common.Experimental; import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.worker.WorkerOptions; import java.lang.reflect.Type; @@ -89,10 +90,17 @@ public interface ActivityExecutionContext { */ byte[] getTaskToken(); + /** + * Returns a token that can be used by Activity code to observe cancellation requests without + * recording Heartbeats. + */ + @Experimental + ActivityCancellationToken getCancellationToken(); + /** * If this method is called during an Activity Execution then the Activity Execution is not going - * to complete when it's method returns. It is expected to be completed asynchronously using - * {@link io.temporal.client.ActivityCompletionClient}. + * to complete when its method returns. It is expected to be completed asynchronously using {@link + * io.temporal.client.ActivityCompletionClient}. * *

Async Activity Executions that have {@link #isUseLocalManualCompletion()} set to false will * not respect the limit defined by {@link WorkerOptions#getMaxConcurrentActivityExecutionSize()}. diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java index 73adde3784..3ce86cec38 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java @@ -1,6 +1,7 @@ package io.temporal.common.interceptors; import com.uber.m3.tally.Scope; +import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInfo; import io.temporal.activity.ManualActivityCompletionClient; @@ -52,6 +53,11 @@ public byte[] getTaskToken() { return next.getTaskToken(); } + @Override + public ActivityCancellationToken getCancellationToken() { + return next.getCancellationToken(); + } + @Override public void doNotCompleteOnReturn() { next.doNotCompleteOnReturn(); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java new file mode 100644 index 0000000000..b78fcdafae --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java @@ -0,0 +1,51 @@ +package io.temporal.internal.activity; + +import io.temporal.activity.ActivityCancellationToken; +import io.temporal.client.ActivityCanceledException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; + +final class ActivityCancellationTokenImpl implements ActivityCancellationToken { + private final CompletableFuture cancellationFuture = new CompletableFuture<>(); + private volatile ActivityCanceledException cancellationException; + + @Override + public boolean isCancellationRequested() { + return cancellationException != null; + } + + @Override + public void throwIfCancellationRequested() throws ActivityCanceledException { + ActivityCanceledException exception = cancellationException; + if (exception != null) { + throw exception; + } + } + + @Override + public CompletableFuture getCancellationFuture() { + CompletableFuture result = new CompletableFuture<>(); + cancellationFuture.whenComplete( + (ignored, exception) -> { + if (exception == null) { + result.complete(null); + } else { + result.completeExceptionally(unwrapCompletionException(exception)); + } + }); + return result; + } + + synchronized void requestCancel(ActivityCanceledException exception) { + if (cancellationException == null) { + cancellationException = exception; + cancellationFuture.completeExceptionally(exception); + } + } + + private static Throwable unwrapCompletionException(Throwable exception) { + return exception instanceof CompletionException && exception.getCause() != null + ? exception.getCause() + : exception; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactory.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactory.java index cc0f5ee279..bfab3489c2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactory.java @@ -5,4 +5,12 @@ public interface ActivityExecutionContextFactory { InternalActivityExecutionContext createContext( ActivityInfoInternal info, Object activity, Scope metricsScope); + + /** + * Removes a context for a currently running activity identified by task token and optionally + * requests cancellation. + * + * @return true if the activity was found and cleaned up. + */ + boolean cleanupContext(byte[] taskToken, boolean cancel); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java index c3df217721..4acc1d17dd 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java @@ -4,8 +4,12 @@ import io.temporal.client.WorkflowClient; import io.temporal.common.converter.DataConverter; import io.temporal.internal.client.external.ManualActivityCompletionClientFactory; +import java.nio.ByteBuffer; import java.time.Duration; +import java.util.Arrays; import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; public class ActivityExecutionContextFactoryImpl implements ActivityExecutionContextFactory { @@ -17,6 +21,8 @@ public class ActivityExecutionContextFactoryImpl implements ActivityExecutionCon private final DataConverter dataConverter; private final ScheduledExecutorService heartbeatExecutor; private final ManualActivityCompletionClientFactory manualCompletionClientFactory; + private final ConcurrentMap activeContexts = + new ConcurrentHashMap<>(); public ActivityExecutionContextFactoryImpl( WorkflowClient client, @@ -42,18 +48,39 @@ public ActivityExecutionContextFactoryImpl( @Override public InternalActivityExecutionContext createContext( ActivityInfoInternal info, Object activity, Scope metricsScope) { - return new ActivityExecutionContextImpl( - client, - namespace, - activity, - info, - dataConverter, - heartbeatExecutor, - manualCompletionClientFactory, - info.getCompletionHandle(), - metricsScope, - identity, - maxHeartbeatThrottleInterval, - defaultHeartbeatThrottleInterval); + ByteBuffer taskToken = taskTokenKey(info.getTaskToken()); + ActivityExecutionContextImpl context = + new ActivityExecutionContextImpl( + client, + namespace, + activity, + info, + dataConverter, + heartbeatExecutor, + manualCompletionClientFactory, + info.getCompletionHandle(), + metricsScope, + identity, + maxHeartbeatThrottleInterval, + defaultHeartbeatThrottleInterval, + () -> cleanupContext(info.getTaskToken(), false)); + activeContexts.put(taskToken, context); + return context; + } + + @Override + public boolean cleanupContext(byte[] taskToken, boolean cancel) { + ActivityExecutionContextImpl context = activeContexts.remove(taskTokenKey(taskToken)); + if (context == null) { + return false; + } + if (cancel) { + context.cancelFromWorkerCommand(); + } + return true; + } + + private static ByteBuffer taskTokenKey(byte[] taskToken) { + return ByteBuffer.wrap(Arrays.copyOf(taskToken, taskToken.length)).asReadOnlyBuffer(); } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java index 101ca4c047..db8943138c 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java @@ -1,6 +1,7 @@ package io.temporal.internal.activity; import com.uber.m3.tally.Scope; +import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInfo; import io.temporal.activity.ManualActivityCompletionClient; @@ -32,6 +33,7 @@ class ActivityExecutionContextImpl implements InternalActivityExecutionContext { private final ManualActivityCompletionClientFactory manualCompletionClientFactory; private final Functions.Proc completionHandle; private final HeartbeatContext heartbeatContext; + private final Functions.Proc closeCallback; private final Scope metricsScope; private final ActivityInfo info; @@ -51,12 +53,14 @@ class ActivityExecutionContextImpl implements InternalActivityExecutionContext { Scope metricsScope, String identity, Duration maxHeartbeatThrottleInterval, - Duration defaultHeartbeatThrottleInterval) { + Duration defaultHeartbeatThrottleInterval, + Functions.Proc closeCallback) { this.client = client; this.activity = activity; this.metricsScope = metricsScope; this.info = info; this.completionHandle = completionHandle; + this.closeCallback = closeCallback; this.manualCompletionClientFactory = manualCompletionClientFactory; this.heartbeatContext = new HeartbeatContextImpl( @@ -105,6 +109,11 @@ public byte[] getTaskToken() { return info.getTaskToken(); } + @Override + public ActivityCancellationToken getCancellationToken() { + return heartbeatContext.getCancellationToken(); + } + @Override public void doNotCompleteOnReturn() { lock.lock(); @@ -170,6 +179,16 @@ public Object getLastHeartbeatValue() { @Override public void cancelOutstandingHeartbeat() { heartbeatContext.cancelOutstandingHeartbeat(); + closeCallback.apply(); + } + + @Override + public void asyncCompletionStarted() { + heartbeatContext.asyncCompletionStarted(); + } + + void cancelFromWorkerCommand() { + heartbeatContext.cancelFromWorkerCommand(); } @Override diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskExecutors.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskExecutors.java index a77838193c..7789bd2716 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskExecutors.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskExecutors.java @@ -129,8 +129,13 @@ public ActivityTaskHandler.Result execute(ActivityInfoInternal info, Scope metri local, dataConverterWithActivityContext); } finally { - if (!context.isDoNotCompleteOnReturn()) { - // if the activity is not completed, we need to cancel the heartbeat + if (context.isDoNotCompleteOnReturn()) { + if (!context.isUseLocalManualCompletion()) { + context.asyncCompletionStarted(); + } + executionContextFactory.cleanupContext(info.getTaskToken(), false); + } else { + // if the activity is completed, we need to cancel the heartbeat // to avoid sending it after the activity is completed context.cancelOutstandingHeartbeat(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java index 312e7c728a..48e9dbfabf 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java @@ -83,6 +83,10 @@ public boolean isTypeSupported(String type) { return activities.get(type) != null || dynamicActivity != null; } + public boolean requestCancel(byte[] taskToken) { + return executionContextFactory.cleanupContext(taskToken, true); + } + public void registerActivityImplementations(Object[] activitiesImplementation) { for (Object activity : activitiesImplementation) { registerActivityImplementation(activity); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java index f87f3c637f..c960f70ba9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java @@ -1,5 +1,6 @@ package io.temporal.internal.activity; +import io.temporal.activity.ActivityCancellationToken; import io.temporal.client.ActivityCompletionException; import java.lang.reflect.Type; import java.util.Optional; @@ -23,6 +24,14 @@ interface HeartbeatContext { Object getLatestHeartbeatDetails(); + ActivityCancellationToken getCancellationToken(); + + /** Mark this activity as canceled by an external worker command. */ + void cancelFromWorkerCommand(); + + /** Mark this activity as returned for async completion. */ + void asyncCompletionStarted(); + /** Cancel any pending heartbeat and discard cached heartbeat details. */ void cancelOutstandingHeartbeat(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java index 48993d0da1..477780a975 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java @@ -3,6 +3,7 @@ import com.uber.m3.tally.Scope; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInfo; import io.temporal.api.common.v1.Payloads; @@ -72,8 +73,11 @@ static long getLocalHeartbeatTimeoutBufferMillis() { // 0 means no local timeout is active. private long heartbeatTimeoutDeadlineNanos; private boolean heartbeatTimedOut; + private boolean rejectNewHeartbeats; private ActivityCompletionException lastException; + private final ActivityCancellationTokenImpl cancellationToken = + new ActivityCancellationTokenImpl(); public HeartbeatContextImpl( WorkflowServiceStubs service, @@ -149,6 +153,9 @@ public void heartbeat(V details) throws ActivityCompletionException { lock.lock(); try { checkHeartbeatTimeoutDeadlineLocked(); + if (rejectNewHeartbeats) { + throw new IllegalStateException("Cannot record heartbeats after async activity completion"); + } receivedAHeartbeat = true; lastDetails = details; hasOutstandingHeartbeat = true; @@ -159,6 +166,7 @@ public void heartbeat(V details) throws ActivityCompletionException { if (lastException != null) { throw lastException; } + cancellationToken.throwIfCancellationRequested(); } finally { lock.unlock(); } @@ -228,6 +236,31 @@ public void cancelOutstandingHeartbeat() { } } + @Override + public void cancelFromWorkerCommand() { + lock.lock(); + try { + requestCancelLocked(); + } finally { + lock.unlock(); + } + } + + @Override + public void asyncCompletionStarted() { + lock.lock(); + try { + rejectNewHeartbeats = true; + } finally { + lock.unlock(); + } + } + + @Override + public ActivityCancellationToken getCancellationToken() { + return cancellationToken; + } + private void doHeartBeatLocked(Object details) { long nextHeartbeatDelay; try { @@ -307,7 +340,7 @@ private void sendHeartbeatRequest(Object details) { dataConverterWithActivityContext.toPayloads(details), metricsScope); if (status.getCancelRequested()) { - lastException = new ActivityCanceledException(info); + requestCancelLocked(); } else if (status.getActivityReset()) { lastException = new ActivityResetException(info); } else if (status.getActivityPaused()) { @@ -327,6 +360,12 @@ private void sendHeartbeatRequest(Object details) { } } + private void requestCancelLocked() { + ActivityCanceledException exception = new ActivityCanceledException(info); + lastException = exception; + cancellationToken.requestCancel(exception); + } + private static long getHeartbeatIntervalMs( Duration activityHeartbeatTimeout, Duration maxHeartbeatThrottleInterval, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/InternalActivityExecutionContext.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/InternalActivityExecutionContext.java index 1b65e32cd7..8b6fbe997e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/InternalActivityExecutionContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/InternalActivityExecutionContext.java @@ -10,6 +10,9 @@ public interface InternalActivityExecutionContext extends ActivityExecutionConte /** Get the latest value of {@link ActivityExecutionContext#heartbeat(Object)}. */ Object getLastHeartbeatValue(); + /** Mark this context as returned for async completion. */ + void asyncCompletionStarted(); + /** Cancel any pending heartbeat and discard cached heartbeat details. */ void cancelOutstandingHeartbeat(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextFactoryImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextFactoryImpl.java index 11730063ad..fa34f963ff 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextFactoryImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextFactoryImpl.java @@ -15,4 +15,9 @@ public InternalActivityExecutionContext createContext( ActivityInfoInternal info, Object activity, Scope metricsScope) { return new LocalActivityExecutionContextImpl(client, activity, info, metricsScope); } + + @Override + public boolean cleanupContext(byte[] taskToken, boolean cancel) { + return false; + } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java index 78b82135a4..0f66364248 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java @@ -1,6 +1,7 @@ package io.temporal.internal.activity; import com.uber.m3.tally.Scope; +import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityInfo; import io.temporal.activity.ManualActivityCompletionClient; import io.temporal.client.ActivityCompletionException; @@ -57,6 +58,11 @@ public byte[] getTaskToken() { throw new UnsupportedOperationException("getTaskToken is not supported for local activities"); } + @Override + public ActivityCancellationToken getCancellationToken() { + return ActivityCancellationToken.NONE; + } + @Override public void doNotCompleteOnReturn() { throw new UnsupportedOperationException( @@ -89,6 +95,11 @@ public Object getLastHeartbeatValue() { return null; } + @Override + public void asyncCompletionStarted() { + // Ignored + } + @Override public void cancelOutstandingHeartbeat() { // Ignored diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityPollTask.java index 1dceb67fb0..f0d3e649f0 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityPollTask.java @@ -43,7 +43,8 @@ public ActivityPollTask( @Nonnull TrackingSlotSupplier slotSupplier, @Nonnull Scope metricsScope, @Nonnull Supplier serverCapabilities, - @Nonnull PollerTracker pollerTracker) { + @Nonnull PollerTracker pollerTracker, + String workerControlTaskQueue) { this.service = Objects.requireNonNull(service); this.slotSupplier = slotSupplier; this.metricsScope = Objects.requireNonNull(metricsScope); @@ -55,6 +56,9 @@ public ActivityPollTask( .setIdentity(identity) .setTaskQueue(TaskQueue.newBuilder().setName(taskQueue)); pollRequest.setWorkerInstanceKey(workerInstanceKey); + if (workerControlTaskQueue != null) { + pollRequest.setWorkerControlTaskQueue(workerControlTaskQueue); + } if (activitiesPerSecond > 0) { pollRequest.setTaskQueueMetadata( TaskQueueMetadata.newBuilder() diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java index d2fddde3f3..6c86fc4472 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java @@ -111,7 +111,8 @@ public boolean start() { this.slotSupplier, workerMetricsScope, service.getServerCapabilities(), - pollerTracker), + pollerTracker, + workerControlTaskQueue()), this.pollTaskExecutor, pollerOptions, namespaceCapabilities, @@ -132,7 +133,8 @@ public boolean start() { this.slotSupplier, workerMetricsScope, service.getServerCapabilities(), - pollerTracker), + pollerTracker, + workerControlTaskQueue()), this.pollTaskExecutor, pollerOptions, workerMetricsScope, @@ -146,6 +148,10 @@ public boolean start() { } } + private String workerControlTaskQueue() { + return namespaceCapabilities.isWorkerCommands() ? options.getWorkerControlTaskQueue() : null; + } + @Override public CompletableFuture shutdown(ShutdownManager shutdownManager, boolean interruptTasks) { String supplierName = this + "#executorSlots"; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncActivityPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncActivityPollTask.java index b23d161845..1e8791bd02 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncActivityPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncActivityPollTask.java @@ -49,7 +49,8 @@ public AsyncActivityPollTask( @Nonnull TrackingSlotSupplier slotSupplier, @Nonnull Scope metricsScope, @Nonnull Supplier serverCapabilities, - @Nonnull PollerTracker pollerTracker) { + @Nonnull PollerTracker pollerTracker, + String workerControlTaskQueue) { this.service = service; this.slotSupplier = slotSupplier; this.metricsScope = metricsScope; @@ -61,6 +62,9 @@ public AsyncActivityPollTask( .setIdentity(identity) .setTaskQueue(TaskQueue.newBuilder().setName(taskQueue)); pollRequest.setWorkerInstanceKey(workerInstanceKey); + if (workerControlTaskQueue != null) { + pollRequest.setWorkerControlTaskQueue(workerControlTaskQueue); + } if (activitiesPerSecond > 0) { pollRequest.setTaskQueueMetadata( TaskQueueMetadata.newBuilder() diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java index 1ba3b84d15..d83bda0be2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java @@ -6,6 +6,7 @@ import com.uber.m3.tally.Scope; import io.grpc.Context; import io.temporal.api.common.v1.WorkerVersionCapabilities; +import io.temporal.api.enums.v1.TaskQueueKind; import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; import io.temporal.api.workflowservice.v1.PollNexusTaskQueueRequest; @@ -47,6 +48,33 @@ public AsyncNexusPollTask( @Nonnull Supplier serverCapabilities, TrackingSlotSupplier slotSupplier, @Nonnull PollerTracker pollerTracker) { + this( + service, + namespace, + taskQueue, + identity, + workerInstanceKey, + versioningOptions, + metricsScope, + serverCapabilities, + slotSupplier, + pollerTracker, + false); + } + + @SuppressWarnings("deprecation") + public AsyncNexusPollTask( + @Nonnull WorkflowServiceStubs service, + @Nonnull String namespace, + @Nonnull String taskQueue, + @Nonnull String identity, + @Nonnull String workerInstanceKey, + @Nonnull WorkerVersioningOptions versioningOptions, + @Nonnull Scope metricsScope, + @Nonnull Supplier serverCapabilities, + TrackingSlotSupplier slotSupplier, + @Nonnull PollerTracker pollerTracker, + boolean workerCommandsTaskQueue) { this.service = Objects.requireNonNull(service); this.metricsScope = Objects.requireNonNull(metricsScope); this.slotSupplier = slotSupplier; @@ -56,7 +84,13 @@ public AsyncNexusPollTask( PollNexusTaskQueueRequest.newBuilder() .setNamespace(namespace) .setIdentity(identity) - .setTaskQueue(TaskQueue.newBuilder().setName(taskQueue)); + .setTaskQueue( + TaskQueue.newBuilder() + .setName(taskQueue) + .setKind( + workerCommandsTaskQueue + ? TaskQueueKind.TASK_QUEUE_KIND_WORKER_COMMANDS + : TaskQueueKind.TASK_QUEUE_KIND_NORMAL)); pollRequest.setWorkerInstanceKey(workerInstanceKey); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncPoller.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncPoller.java index c56111c02e..9a376a25c6 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncPoller.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncPoller.java @@ -305,7 +305,7 @@ public void run() { if (shouldTerminate()) { pollerBalancer.removePoller(asyncTaskPoller.getLabel()); abort = true; - log.info( + log.debug( "Poll loop is terminated: {} - {}", AsyncPoller.this.getClass().getSimpleName(), asyncTaskPoller.getLabel()); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncWorkflowPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncWorkflowPollTask.java index 3bfa796a30..97ed165ed2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncWorkflowPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncWorkflowPollTask.java @@ -57,7 +57,8 @@ public AsyncWorkflowPollTask( @Nonnull TrackingSlotSupplier slotSupplier, @Nonnull Scope metricsScope, @Nonnull Supplier serverCapabilities, - @Nonnull PollerTracker pollerTracker) { + @Nonnull PollerTracker pollerTracker, + String workerControlTaskQueue) { this.service = service; this.slotSupplier = slotSupplier; this.metricsScope = metricsScope; @@ -69,6 +70,9 @@ public AsyncWorkflowPollTask( .setIdentity(Objects.requireNonNull(identity)); pollRequestBuilder.setWorkerInstanceKey(workerInstanceKey); + if (workerControlTaskQueue != null) { + pollRequestBuilder.setWorkerControlTaskQueue(workerControlTaskQueue); + } if (versioningOptions.getWorkerDeploymentOptions() != null) { pollRequestBuilder.setDeploymentOptions( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java index 855145b317..a8a77d680f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java @@ -52,7 +52,7 @@ public boolean isTerminated() { @Override public CompletableFuture shutdown(ShutdownManager shutdownManager, boolean interruptTasks) { - log.info("shutdown: {}", this); + log.debug("shutdown: {}", this); WorkerLifecycleState lifecycleState = getLifecycleState(); switch (lifecycleState) { case NOT_STARTED: diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/MultiThreadedPoller.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/MultiThreadedPoller.java index 7fe0335b15..e82d162665 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/MultiThreadedPoller.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/MultiThreadedPoller.java @@ -68,7 +68,7 @@ public MultiThreadedPoller( @Override public boolean start() { - log.info("start: {}", this); + log.debug("start: {}", this); if (pollerOptions.getMaximumPollRatePerSecond() > 0.0) { pollRateThrottler = @@ -193,7 +193,7 @@ public void run() { // Resubmit itself back to pollExecutor pollExecutor.execute(this); } else { - log.info( + log.debug( "poll loop is terminated: {}", MultiThreadedPoller.this.pollTask.getClass().getSimpleName()); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java index 8c9f23270f..ed4ac3935f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java @@ -12,6 +12,7 @@ public final class NamespaceCapabilities { private final AtomicBoolean pollerAutoscaling = new AtomicBoolean(false); private final AtomicBoolean gracefulPollShutdown = new AtomicBoolean(false); private final AtomicBoolean workerHeartbeats = new AtomicBoolean(false); + private final AtomicBoolean workerCommands = new AtomicBoolean(false); public void setFromCapabilities(Capabilities capabilities) { if (capabilities.getPollerAutoscaling()) { @@ -23,6 +24,9 @@ public void setFromCapabilities(Capabilities capabilities) { if (capabilities.getWorkerHeartbeats()) { workerHeartbeats.set(true); } + if (capabilities.getWorkerCommands()) { + workerCommands.set(true); + } } public boolean isPollerAutoscaling() { @@ -44,4 +48,12 @@ public boolean isWorkerHeartbeats() { public void setWorkerHeartbeats(boolean value) { workerHeartbeats.set(value); } + + public boolean isWorkerCommands() { + return workerCommands.get(); + } + + public void setWorkerCommands(boolean value) { + workerCommands.set(value); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java index 0ccab59443..b53546cfd2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java @@ -5,6 +5,7 @@ import com.google.protobuf.Timestamp; import com.uber.m3.tally.Scope; import io.temporal.api.common.v1.WorkerVersionCapabilities; +import io.temporal.api.enums.v1.TaskQueueKind; import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.api.workflowservice.v1.*; import io.temporal.internal.common.ProtobufTimeUtils; @@ -40,6 +41,33 @@ public NexusPollTask( @Nonnull Scope metricsScope, @Nonnull Supplier serverCapabilities, @Nonnull PollerTracker pollerTracker) { + this( + service, + namespace, + taskQueue, + identity, + workerInstanceKey, + versioningOptions, + slotSupplier, + metricsScope, + serverCapabilities, + pollerTracker, + false); + } + + @SuppressWarnings("deprecation") + public NexusPollTask( + @Nonnull WorkflowServiceStubs service, + @Nonnull String namespace, + @Nonnull String taskQueue, + @Nonnull String identity, + @Nonnull String workerInstanceKey, + @Nonnull WorkerVersioningOptions versioningOptions, + @Nonnull TrackingSlotSupplier slotSupplier, + @Nonnull Scope metricsScope, + @Nonnull Supplier serverCapabilities, + @Nonnull PollerTracker pollerTracker, + boolean workerCommandsTaskQueue) { this.service = Objects.requireNonNull(service); this.slotSupplier = slotSupplier; this.metricsScope = Objects.requireNonNull(metricsScope); @@ -49,7 +77,13 @@ public NexusPollTask( PollNexusTaskQueueRequest.newBuilder() .setNamespace(namespace) .setIdentity(identity) - .setTaskQueue(TaskQueue.newBuilder().setName(taskQueue)); + .setTaskQueue( + TaskQueue.newBuilder() + .setName(taskQueue) + .setKind( + workerCommandsTaskQueue + ? TaskQueueKind.TASK_QUEUE_KIND_WORKER_COMMANDS + : TaskQueueKind.TASK_QUEUE_KIND_NORMAL)); pollRequest.setWorkerInstanceKey(workerInstanceKey); if (versioningOptions.getWorkerDeploymentOptions() != null) { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java index a09993c037..1fd9cf9148 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java @@ -54,6 +54,7 @@ final class NexusWorker implements SuspendableWorker { private final TrackingSlotSupplier slotSupplier; private final NamespaceCapabilities namespaceCapabilities; private final boolean forceOldFailureFormat; + private final boolean workerCommandsTaskQueue; private final TaskCounter taskCounter = new TaskCounter(); private final PollerTracker pollerTracker = new PollerTracker(); @@ -66,6 +67,28 @@ public NexusWorker( @Nonnull DataConverter dataConverter, @Nonnull SlotSupplier slotSupplier, @Nonnull NamespaceCapabilities namespaceCapabilities) { + this( + service, + namespace, + taskQueue, + options, + handler, + dataConverter, + slotSupplier, + namespaceCapabilities, + false); + } + + public NexusWorker( + @Nonnull WorkflowServiceStubs service, + @Nonnull String namespace, + @Nonnull String taskQueue, + @Nonnull SingleWorkerOptions options, + @Nonnull NexusTaskHandler handler, + @Nonnull DataConverter dataConverter, + @Nonnull SlotSupplier slotSupplier, + @Nonnull NamespaceCapabilities namespaceCapabilities, + boolean workerCommandsTaskQueue) { this.service = Objects.requireNonNull(service); this.namespace = Objects.requireNonNull(namespace); this.taskQueue = Objects.requireNonNull(taskQueue); @@ -82,6 +105,7 @@ public NexusWorker( this.slotSupplier = new TrackingSlotSupplier<>(slotSupplier, this.workerMetricsScope); this.namespaceCapabilities = namespaceCapabilities; + this.workerCommandsTaskQueue = workerCommandsTaskQueue; // Allow tests to force old format for backward compatibility testing String forceOldFormat = System.getProperty("temporal.nexus.forceOldFailureFormat"); this.forceOldFailureFormat = "true".equalsIgnoreCase(forceOldFormat); @@ -116,7 +140,8 @@ public boolean start() { workerMetricsScope, service.getServerCapabilities(), this.slotSupplier, - pollerTracker), + pollerTracker, + workerCommandsTaskQueue), this.pollTaskExecutor, pollerOptions, namespaceCapabilities, @@ -135,7 +160,8 @@ public boolean start() { this.slotSupplier, workerMetricsScope, service.getServerCapabilities(), - pollerTracker), + pollerTracker, + workerCommandsTaskQueue), this.pollTaskExecutor, pollerOptions, workerMetricsScope, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java index f53802f489..3e84dc750f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java @@ -42,6 +42,7 @@ public static final class Builder { private WorkerDeploymentOptions deploymentOptions; private String workerInstanceKey; private boolean allowActivityHeartbeatDuringShutdown; + private String workerControlTaskQueue; private Builder() {} @@ -68,6 +69,7 @@ private Builder(SingleWorkerOptions options) { this.deploymentOptions = options.getDeploymentOptions(); this.workerInstanceKey = options.getWorkerInstanceKey(); this.allowActivityHeartbeatDuringShutdown = options.getAllowActivityHeartbeatDuringShutdown(); + this.workerControlTaskQueue = options.getWorkerControlTaskQueue(); } public Builder setIdentity(String identity) { @@ -170,6 +172,11 @@ public Builder setAllowActivityHeartbeatDuringShutdown( return this; } + public Builder setWorkerControlTaskQueue(String workerControlTaskQueue) { + this.workerControlTaskQueue = workerControlTaskQueue; + return this; + } + public SingleWorkerOptions build() { PollerOptions pollerOptions = this.pollerOptions; if (pollerOptions == null) { @@ -210,7 +217,8 @@ public SingleWorkerOptions build() { usingVirtualThreads, this.deploymentOptions, this.workerInstanceKey, - this.allowActivityHeartbeatDuringShutdown); + this.allowActivityHeartbeatDuringShutdown, + this.workerControlTaskQueue); } } @@ -233,6 +241,7 @@ public SingleWorkerOptions build() { private final WorkerDeploymentOptions deploymentOptions; private final String workerInstanceKey; private final boolean allowActivityHeartbeatDuringShutdown; + private final String workerControlTaskQueue; private SingleWorkerOptions( String identity, @@ -253,7 +262,8 @@ private SingleWorkerOptions( boolean usingVirtualThreads, WorkerDeploymentOptions deploymentOptions, String workerInstanceKey, - boolean allowActivityHeartbeatDuringShutdown) { + boolean allowActivityHeartbeatDuringShutdown, + String workerControlTaskQueue) { this.identity = identity; this.binaryChecksum = binaryChecksum; this.buildId = buildId; @@ -273,6 +283,7 @@ private SingleWorkerOptions( this.deploymentOptions = deploymentOptions; this.workerInstanceKey = workerInstanceKey; this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; + this.workerControlTaskQueue = workerControlTaskQueue; } public String getIdentity() { @@ -362,6 +373,10 @@ public String getWorkerInstanceKey() { return workerInstanceKey; } + public String getWorkerControlTaskQueue() { + return workerControlTaskQueue; + } + public WorkerVersioningOptions getWorkerVersioningOptions() { return new WorkerVersioningOptions( this.getBuildId(), this.isUsingBuildIdForVersioning(), this.getDeploymentOptions()); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java index 4eafdb38cf..94d2f5dee3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java @@ -171,6 +171,10 @@ public boolean isAnyTypeSupported() { return taskHandler.isAnyTypeSupported(); } + public boolean requestCancelActivity(byte[] taskToken) { + return taskHandler.requestCancel(taskToken); + } + public TrackingSlotSupplier getSlotSupplier() { return worker.getSlotSupplier(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerCommandTaskHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerCommandTaskHandler.java new file mode 100644 index 0000000000..88dd1fc959 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerCommandTaskHandler.java @@ -0,0 +1,128 @@ +package io.temporal.internal.worker; + +import com.google.protobuf.InvalidProtocolBufferException; +import com.uber.m3.tally.Scope; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.nexus.v1.Response; +import io.temporal.api.nexus.v1.StartOperationRequest; +import io.temporal.api.nexus.v1.StartOperationResponse; +import io.temporal.api.nexusservices.workerservice.v1.ExecuteCommandsRequest; +import io.temporal.api.nexusservices.workerservice.v1.ExecuteCommandsResponse; +import io.temporal.api.worker.v1.CancelActivityResult; +import io.temporal.api.worker.v1.WorkerCommand; +import io.temporal.api.worker.v1.WorkerCommandResult; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.serviceclient.Version; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.tuning.FixedSizeSlotSupplier; +import io.temporal.worker.tuning.NexusSlotInfo; +import io.temporal.worker.tuning.PollerBehaviorSimpleMaximum; +import java.util.Objects; +import java.util.concurrent.TimeoutException; +import java.util.function.Function; +import javax.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Handles server-to-worker commands delivered on the worker command Nexus task queue. */ +public final class WorkerCommandTaskHandler implements NexusTaskHandler { + private static final Logger log = LoggerFactory.getLogger(WorkerCommandTaskHandler.class); + private static final String TASK_QUEUE_PREFIX = "temporal-sys/worker-commands"; + + private final Function activityCancelCallback; + + public WorkerCommandTaskHandler(Function activityCancelCallback) { + this.activityCancelCallback = Objects.requireNonNull(activityCancelCallback); + } + + public static String workerControlTaskQueue(String namespace, String workerGroupingKey) { + return String.format("%s/%s/%s", TASK_QUEUE_PREFIX, namespace, workerGroupingKey); + } + + public static SuspendableWorker newWorkerCommandWorker( + @Nonnull WorkflowServiceStubs service, + @Nonnull String namespace, + @Nonnull String identity, + @Nonnull String workerGroupingKey, + @Nonnull Function activityCancelCallback, + @Nonnull Scope metricsScope, + @Nonnull NamespaceCapabilities namespaceCapabilities) { + String taskQueue = workerControlTaskQueue(namespace, workerGroupingKey); + DataConverter dataConverter = GlobalDataConverter.get(); + SingleWorkerOptions options = + SingleWorkerOptions.newBuilder() + .setIdentity(identity) + .setBuildId(Version.LIBRARY_VERSION) + .setWorkerInstanceKey(workerGroupingKey) + .setDataConverter(dataConverter) + .setMetricsScope(metricsScope) + .setPollerOptions( + PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) + .setPollThreadNamePrefix("WorkerCommandNexusPoller") + .build()) + .build(); + return new NexusWorker( + service, + namespace, + taskQueue, + options, + new WorkerCommandTaskHandler(activityCancelCallback), + dataConverter, + new FixedSizeSlotSupplier(5), + namespaceCapabilities, + true); + } + + @Override + public boolean start() { + return true; + } + + @Override + public Result handle(NexusTask task, Scope metricsScope) throws TimeoutException { + ExecuteCommandsRequest request = decodeRequest(task); + ExecuteCommandsResponse.Builder response = ExecuteCommandsResponse.newBuilder(); + for (WorkerCommand command : request.getCommandsList()) { + response.addResults(handleCommand(command)); + } + return new Result( + Response.newBuilder() + .setStartOperation( + StartOperationResponse.newBuilder() + .setSyncSuccess( + StartOperationResponse.Sync.newBuilder() + .setPayload( + Payload.newBuilder().setData(response.build().toByteString())))) + .build()); + } + + private ExecuteCommandsRequest decodeRequest(NexusTask task) { + StartOperationRequest request = task.getResponse().getRequest().getStartOperation(); + if (!request.hasPayload()) { + throw new IllegalArgumentException( + "Worker command Nexus task missing ExecuteCommands payload"); + } + try { + return ExecuteCommandsRequest.parseFrom(request.getPayload().getData()); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("Failed to decode ExecuteCommandsRequest", e); + } + } + + private WorkerCommandResult handleCommand(WorkerCommand command) { + WorkerCommandResult.Builder result = WorkerCommandResult.newBuilder(); + if (command.hasCancelActivity()) { + byte[] taskToken = command.getCancelActivity().getTaskToken().toByteArray(); + Boolean found = activityCancelCallback.apply(taskToken); + if (!Boolean.TRUE.equals(found)) { + log.debug("Activity task token from worker command was not found"); + } + result.setCancelActivity(CancelActivityResult.newBuilder()); + } else { + log.warn("Unsupported worker command"); + } + return result.build(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java index 18607b5d1e..1b6c8cf7dc 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java @@ -54,7 +54,8 @@ public WorkflowPollTask( @Nonnull Scope workerMetricsScope, @Nonnull Supplier serverCapabilities, @Nonnull PollerTracker pollerTracker, - @Nonnull PollerTracker stickyPollerTracker) { + @Nonnull PollerTracker stickyPollerTracker, + String workerControlTaskQueue) { this.slotSupplier = Objects.requireNonNull(slotSupplier); this.stickyQueueBalancer = Objects.requireNonNull(stickyQueueBalancer); this.metricsScope = Objects.requireNonNull(workerMetricsScope); @@ -75,6 +76,9 @@ public WorkflowPollTask( .setNamespace(Objects.requireNonNull(namespace)) .setIdentity(Objects.requireNonNull(identity)); pollRequestBuilder.setWorkerInstanceKey(workerInstanceKey); + if (workerControlTaskQueue != null) { + pollRequestBuilder.setWorkerControlTaskQueue(workerControlTaskQueue); + } if (versioningOptions.getWorkerDeploymentOptions() != null) { pollRequestBuilder.setDeploymentOptions( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index d6aa835a29..0de3ffaf6b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -126,7 +126,8 @@ public boolean start() { slotSupplier, workerMetricsScope, service.getServerCapabilities(), - pollerTracker); + pollerTracker, + workerControlTaskQueue()); pollers = Arrays.asList( new AsyncWorkflowPollTask( @@ -140,7 +141,8 @@ public boolean start() { slotSupplier, workerMetricsScope, service.getServerCapabilities(), - stickyPollerTracker), + stickyPollerTracker, + workerControlTaskQueue()), normalPoller); this.stickyQueueBalancer = normalPoller; } else { @@ -157,7 +159,8 @@ public boolean start() { slotSupplier, workerMetricsScope, service.getServerCapabilities(), - pollerTracker)); + pollerTracker, + workerControlTaskQueue())); } poller = new AsyncPoller<>( @@ -191,7 +194,8 @@ public boolean start() { workerMetricsScope, service.getServerCapabilities(), pollerTracker, - stickyPollerTracker), + stickyPollerTracker, + workerControlTaskQueue()), pollTaskExecutor, pollerOptions, workerMetricsScope, @@ -647,6 +651,10 @@ private RespondWorkflowTaskCompletedResponse sendTaskCompleted( .setIdentity(options.getIdentity()) .setNamespace(namespace) .setTaskToken(taskToken); + String workerControlTaskQueue = workerControlTaskQueue(); + if (workerControlTaskQueue != null) { + taskCompleted.setWorkerControlTaskQueue(workerControlTaskQueue); + } if (options.getDeploymentOptions() != null) { taskCompleted.setDeploymentOptions( @@ -755,4 +763,8 @@ private Failure grpcMessageTooLargeFailure( .exceptionToFailure(applicationFailure); } } + + private String workerControlTaskQueue() { + return namespaceCapabilities.isWorkerCommands() ? options.getWorkerControlTaskQueue() : null; + } } diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index 6355e5a75a..5c77e93a6c 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -110,6 +110,7 @@ private static final class TaskSnapshot { WorkflowThreadExecutor workflowThreadExecutor, List contextPropagators, @Nonnull List plugins, + @Nonnull String workerGroupingKey, @Nonnull NamespaceCapabilities namespaceCapabilities) { Objects.requireNonNull(client, "client should not be null"); @@ -126,6 +127,8 @@ private static final class TaskSnapshot { WorkflowClientOptions clientOptions = client.getOptions(); String namespace = clientOptions.getNamespace(); this.namespace = namespace; + String workerControlTaskQueue = + WorkerCommandTaskHandler.workerControlTaskQueue(namespace, workerGroupingKey); Map tags = new ImmutableMap.Builder(1).put(MetricsTag.TASK_QUEUE, taskQueue).build(); Scope taggedScope = metricsScope.tagged(tags); @@ -136,7 +139,8 @@ private static final class TaskSnapshot { clientOptions, contextPropagators, taggedScope, - workerInstanceKey); + workerInstanceKey, + workerControlTaskQueue); if (this.options.isLocalActivityWorkerOnly()) { activityWorker = null; } else { @@ -169,7 +173,8 @@ private static final class TaskSnapshot { clientOptions, contextPropagators, taggedScope, - workerInstanceKey); + workerInstanceKey, + workerControlTaskQueue); SlotSupplier nexusSlotSupplier = this.options.getWorkerTuner() == null ? new FixedSizeSlotSupplier<>(this.options.getMaxConcurrentNexusExecutionSize()) @@ -188,7 +193,8 @@ private static final class TaskSnapshot { taskQueue, contextPropagators, taggedScope, - workerInstanceKey); + workerInstanceKey, + workerControlTaskQueue); SingleWorkerOptions localActivityOptions = toLocalActivityOptions( factoryOptions, @@ -196,7 +202,8 @@ private static final class TaskSnapshot { clientOptions, contextPropagators, taggedScope, - workerInstanceKey); + workerInstanceKey, + workerControlTaskQueue); SlotSupplier workflowSlotSupplier = this.options.getWorkerTuner() == null @@ -671,6 +678,10 @@ Supplier buildHeartbeatCallback(String workerGroupingKey) { }; } + boolean requestCancelActivity(byte[] taskToken) { + return activityWorker != null && activityWorker.requestCancelActivity(taskToken); + } + private WorkerSlotsInfo buildSlotsInfo( String key, TrackingSlotSupplier tracker, TaskCounter taskCounter) { int maxSlots = tracker.maximumSlots().orElse(-1); @@ -889,9 +900,15 @@ private static SingleWorkerOptions toActivityOptions( WorkflowClientOptions clientOptions, List contextPropagators, Scope metricsScope, - String workerInstanceKey) { + String workerInstanceKey, + String workerControlTaskQueue) { return toSingleWorkerOptions( - factoryOptions, options, clientOptions, contextPropagators, workerInstanceKey) + factoryOptions, + options, + clientOptions, + contextPropagators, + workerInstanceKey, + workerControlTaskQueue) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnActivityWorker()) .setAllowActivityHeartbeatDuringShutdown(options.getAllowActivityHeartbeatDuringShutdown()) .setPollerOptions( @@ -914,9 +931,15 @@ private static SingleWorkerOptions toNexusOptions( WorkflowClientOptions clientOptions, List contextPropagators, Scope metricsScope, - String workerInstanceKey) { + String workerInstanceKey, + String workerControlTaskQueue) { return toSingleWorkerOptions( - factoryOptions, options, clientOptions, contextPropagators, workerInstanceKey) + factoryOptions, + options, + clientOptions, + contextPropagators, + workerInstanceKey, + workerControlTaskQueue) .setPollerOptions( PollerOptions.newBuilder() .setPollerBehavior( @@ -938,7 +961,8 @@ private static SingleWorkerOptions toWorkflowWorkerOptions( String taskQueue, List contextPropagators, Scope metricsScope, - String workerInstanceKey) { + String workerInstanceKey, + String workerControlTaskQueue) { Map tags = new ImmutableMap.Builder(1).put(MetricsTag.TASK_QUEUE, taskQueue).build(); @@ -968,7 +992,12 @@ private static SingleWorkerOptions toWorkflowWorkerOptions( } return toSingleWorkerOptions( - factoryOptions, options, clientOptions, contextPropagators, workerInstanceKey) + factoryOptions, + options, + clientOptions, + contextPropagators, + workerInstanceKey, + workerControlTaskQueue) .setPollerOptions( PollerOptions.newBuilder() .setPollerBehavior( @@ -991,9 +1020,15 @@ private static SingleWorkerOptions toLocalActivityOptions( WorkflowClientOptions clientOptions, List contextPropagators, Scope metricsScope, - String workerInstanceKey) { + String workerInstanceKey, + String workerControlTaskQueue) { return toSingleWorkerOptions( - factoryOptions, options, clientOptions, contextPropagators, workerInstanceKey) + factoryOptions, + options, + clientOptions, + contextPropagators, + workerInstanceKey, + workerControlTaskQueue) .setPollerOptions( PollerOptions.newBuilder() .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) @@ -1011,7 +1046,8 @@ private static SingleWorkerOptions.Builder toSingleWorkerOptions( WorkerOptions options, WorkflowClientOptions clientOptions, List contextPropagators, - String workerInstanceKey) { + String workerInstanceKey, + String workerControlTaskQueue) { String buildId = null; if (options.getBuildId() != null) { buildId = options.getBuildId(); @@ -1035,7 +1071,8 @@ private static SingleWorkerOptions.Builder toSingleWorkerOptions( .setMaxHeartbeatThrottleInterval(options.getMaxHeartbeatThrottleInterval()) .setDefaultHeartbeatThrottleInterval(options.getDefaultHeartbeatThrottleInterval()) .setDeploymentOptions(options.getDeploymentOptions()) - .setWorkerInstanceKey(workerInstanceKey); + .setWorkerInstanceKey(workerInstanceKey) + .setWorkerControlTaskQueue(workerControlTaskQueue); } /** diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java index a87a36fb02..70bcf28c76 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java @@ -17,6 +17,8 @@ import io.temporal.internal.worker.HeartbeatManager; import io.temporal.internal.worker.NamespaceCapabilities; import io.temporal.internal.worker.ShutdownManager; +import io.temporal.internal.worker.SuspendableWorker; +import io.temporal.internal.worker.WorkerCommandTaskHandler; import io.temporal.internal.worker.WorkflowExecutorCache; import io.temporal.internal.worker.WorkflowRunLockManager; import io.temporal.serviceclient.MetricsTag; @@ -64,6 +66,8 @@ public final class WorkerFactory { /** Namespace capabilities populated during start() from DescribeNamespace response. */ private final NamespaceCapabilities namespaceCapabilities = new NamespaceCapabilities(); + private SuspendableWorker workerCommandWorker; + private State state = State.Initial; private final String statusErrorMessage = @@ -201,6 +205,7 @@ public synchronized Worker newWorker(String taskQueue, WorkerOptions options) { workflowThreadExecutor, workflowClient.getOptions().getContextPropagators(), plugins, + ((WorkflowClientInternal) workflowClient.getInternal()).getWorkerGroupingKey(), namespaceCapabilities); workers.put(taskQueue, worker); @@ -285,6 +290,24 @@ public synchronized void start() { /** Internal method that actually starts the workers. Called from the plugin chain. */ private void doStart() { + // Start the internal nexus worker if enabled + WorkflowClientInternal clientInternal = (WorkflowClientInternal) workflowClient.getInternal(); + String namespace = workflowClient.getOptions().getNamespace(); + String workerGroupingKey = clientInternal.getWorkerGroupingKey(); + HeartbeatManager hbManager = clientInternal.getHeartbeatManager(); + if (namespaceCapabilities.isWorkerCommands()) { + workerCommandWorker = + WorkerCommandTaskHandler.newWorkerCommandWorker( + workflowClient.getWorkflowServiceStubs(), + namespace, + workflowClient.getOptions().getIdentity(), + workerGroupingKey, + this::requestCancelActivity, + metricsScope, + namespaceCapabilities); + workerCommandWorker.start(); + } + // Start each worker with plugin hooks for (Map.Entry entry : workers.entrySet()) { String taskQueue = entry.getKey(); @@ -303,11 +326,7 @@ private void doStart() { } // Register heartbeat callbacks after workers are started. - WorkflowClientInternal clientInternal = (WorkflowClientInternal) workflowClient.getInternal(); - HeartbeatManager hbManager = clientInternal.getHeartbeatManager(); if (hbManager != null && namespaceCapabilities.isWorkerHeartbeats()) { - String namespace = workflowClient.getOptions().getNamespace(); - String workerGroupingKey = clientInternal.getWorkerGroupingKey(); for (Worker worker : workers.values()) { Supplier heartbeatSupplier = worker.buildHeartbeatCallback(workerGroupingKey); @@ -320,6 +339,15 @@ private void doStart() { ((WorkflowClientInternal) workflowClient.getInternal()).registerWorkerFactory(this); } + private synchronized boolean requestCancelActivity(byte[] taskToken) { + for (Worker worker : workers.values()) { + if (worker.requestCancelActivity(taskToken)) { + return true; + } + } + return false; + } + /** Was {@link #start()} called. */ public synchronized boolean isStarted() { return state != State.Initial; @@ -338,6 +366,9 @@ public synchronized boolean isTerminated() { if (state != State.Shutdown) { return false; } + if (workerCommandWorker != null && !workerCommandWorker.isTerminated()) { + return false; + } for (Worker worker : workers.values()) { if (!worker.isTerminated()) { return false; @@ -366,7 +397,7 @@ public WorkflowClient getWorkflowClient() { * Invocation has no additional effect if already shut down. */ public synchronized void shutdown() { - log.info("shutdown: {}", this); + log.debug("shutdown: {}", this); shutdownInternal(false); } @@ -432,6 +463,11 @@ private void doShutdown(boolean interruptUserTasks) { shutdownFutures.add(futureHolder[0]); } } + if (workerCommandWorker != null) { + // TODO: Should be able to pass `interruptUserTasks` here when + // https://github.com/temporalio/api/pull/784 is in + shutdownFutures.add(workerCommandWorker.shutdown(shutdownManager, true)); + } CompletableFuture.allOf(shutdownFutures.toArray(new CompletableFuture[0])) .thenApply( @@ -450,6 +486,7 @@ private void doShutdown(boolean interruptUserTasks) { } cache.invalidateAll(); workflowThreadPool.shutdownNow(); + workerCommandWorker = null; return null; }) .whenComplete( @@ -468,7 +505,7 @@ private void doShutdown(boolean interruptUserTasks) { * occurs. */ public void awaitTermination(long timeout, TimeUnit unit) { - log.info("awaitTermination begin: {}", this); + log.debug("awaitTermination begin: {}", this); long timeoutMillis = unit.toMillis(timeout); for (Worker worker : workers.values()) { long t = timeoutMillis; // closure needs immutable value @@ -476,7 +513,12 @@ public void awaitTermination(long timeout, TimeUnit unit) { ShutdownManager.runAndGetRemainingTimeoutMs( t, () -> worker.awaitTermination(t, TimeUnit.MILLISECONDS)); } - log.info("awaitTermination done: {}", this); + if (workerCommandWorker != null) { + long t = timeoutMillis; + ShutdownManager.runAndGetRemainingTimeoutMs( + t, () -> workerCommandWorker.awaitTermination(t, TimeUnit.MILLISECONDS)); + } + log.debug("awaitTermination done: {}", this); } // TODO we should hide an actual implementation of WorkerFactory under WorkerFactory interface and @@ -496,6 +538,9 @@ public synchronized void suspendPolling() { for (Worker worker : workers.values()) { worker.suspendPolling(); } + if (workerCommandWorker != null) { + workerCommandWorker.suspendPolling(); + } } public synchronized void resumePolling() { @@ -508,6 +553,9 @@ public synchronized void resumePolling() { for (Worker worker : workers.values()) { worker.resumePolling(); } + if (workerCommandWorker != null) { + workerCommandWorker.resumePolling(); + } } @Override diff --git a/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java index 686bc566f8..9b04bb5cd9 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java @@ -7,24 +7,30 @@ import com.uber.m3.tally.NoopScope; import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityInfo; import io.temporal.api.enums.v1.TimeoutType; +import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatResponse; import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; +import io.temporal.client.WorkflowClient; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.failure.TimeoutFailure; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.testUtils.Eventually; import java.time.Duration; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicInteger; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; public class HeartbeatContextImplTest { @@ -183,7 +189,170 @@ public void heartbeatTimeoutPersistsAcrossMultipleCalls() { ctx.cancelOutstandingHeartbeat(); } + @Test + public void workerCommandCancelStillSendsHeartbeatDetails() { + when(blockingStub.recordActivityTaskHeartbeat(any())) + .thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance()); + + ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(10)); + HeartbeatContextImpl ctx = + createHeartbeatContext(info, Duration.ofMillis(100), Duration.ofMillis(100)); + + assertFalse(ctx.getCancellationToken().isCancellationRequested()); + assertFalse(ctx.getCancellationToken().getCancellationFuture().isDone()); + + ctx.heartbeat("before-cancel"); + ctx.cancelFromWorkerCommand(); + + assertTrue(ctx.getCancellationToken().isCancellationRequested()); + ActivityCanceledException exception = + assertThrows( + ActivityCanceledException.class, + () -> ctx.getCancellationToken().throwIfCancellationRequested()); + assertSame( + exception, assertCancellationFutureCompletedExceptionally(ctx.getCancellationToken())); + + try { + ctx.heartbeat("after-cancel"); + fail("Expected ActivityCanceledException"); + } catch (ActivityCanceledException e) { + assertNull(e.getCause()); + } + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(RecordActivityTaskHeartbeatRequest.class); + verify(blockingStub, timeout(1000).times(2)) + .recordActivityTaskHeartbeat(requestCaptor.capture()); + String details = + GlobalDataConverter.get() + .fromPayloads( + 0, + Optional.of(requestCaptor.getAllValues().get(1).getDetails()), + String.class, + String.class); + assertEquals("after-cancel", details); + ctx.cancelOutstandingHeartbeat(); + } + + @Test + public void completingReturnedCancellationFutureDoesNotCancelToken() { + ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(10)); + HeartbeatContextImpl ctx = + createHeartbeatContext(info, Duration.ofMillis(100), Duration.ofMillis(100)); + + CompletableFuture callerFuture = ctx.getCancellationToken().getCancellationFuture(); + callerFuture.complete(null); + + assertTrue(callerFuture.isDone()); + assertFalse(ctx.getCancellationToken().isCancellationRequested()); + ctx.getCancellationToken().throwIfCancellationRequested(); + + ctx.cancelFromWorkerCommand(); + + ActivityCanceledException exception = + assertThrows( + ActivityCanceledException.class, + () -> ctx.getCancellationToken().throwIfCancellationRequested()); + assertSame( + exception, assertCancellationFutureCompletedExceptionally(ctx.getCancellationToken())); + + ctx.cancelOutstandingHeartbeat(); + } + + @Test + public void asyncCompletionRejectsNewHeartbeatsAndFlushesQueuedHeartbeat() { + when(blockingStub.recordActivityTaskHeartbeat(any())) + .thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance()); + + ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(10)); + HeartbeatContextImpl ctx = + createHeartbeatContext(info, Duration.ofMillis(100), Duration.ofMillis(100)); + + ctx.heartbeat("sent-before-return"); + ctx.heartbeat("queued-before-return"); + ctx.asyncCompletionStarted(); + + assertFalse(ctx.getCancellationToken().isCancellationRequested()); + assertFalse(ctx.getCancellationToken().getCancellationFuture().isDone()); + assertThrows(IllegalStateException.class, () -> ctx.heartbeat("after-return")); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(RecordActivityTaskHeartbeatRequest.class); + verify(blockingStub, timeout(1000).times(2)) + .recordActivityTaskHeartbeat(requestCaptor.capture()); + String details = + GlobalDataConverter.get() + .fromPayloads( + 0, + Optional.of(requestCaptor.getAllValues().get(1).getDetails()), + String.class, + String.class); + assertEquals("queued-before-return", details); + ctx.cancelOutstandingHeartbeat(); + } + + @Test + public void heartbeatCancelCompletesCancellationToken() { + when(blockingStub.recordActivityTaskHeartbeat(any())) + .thenReturn( + RecordActivityTaskHeartbeatResponse.newBuilder().setCancelRequested(true).build()); + + ActivityInfo info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(10)); + HeartbeatContextImpl ctx = createHeartbeatContext(info); + + assertFalse(ctx.getCancellationToken().isCancellationRequested()); + assertFalse(ctx.getCancellationToken().getCancellationFuture().isDone()); + + assertThrows(ActivityCanceledException.class, () -> ctx.heartbeat("details")); + + assertTrue(ctx.getCancellationToken().isCancellationRequested()); + ActivityCanceledException exception = + assertThrows( + ActivityCanceledException.class, + () -> ctx.getCancellationToken().throwIfCancellationRequested()); + assertSame( + exception, assertCancellationFutureCompletedExceptionally(ctx.getCancellationToken())); + + ctx.cancelOutstandingHeartbeat(); + } + + @Test + public void factoryCancelByTaskTokenCompletesCancellationToken() { + WorkflowClient client = mock(WorkflowClient.class); + when(client.getWorkflowServiceStubs()).thenReturn(service); + + ActivityExecutionContextFactoryImpl factory = + new ActivityExecutionContextFactoryImpl( + client, + "test-identity", + "test-namespace", + Duration.ofSeconds(60), + Duration.ofSeconds(30), + GlobalDataConverter.get(), + heartbeatExecutor); + + ActivityInfoInternal info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(10)); + InternalActivityExecutionContext context = + factory.createContext(info, new Object(), new NoopScope()); + + assertFalse(context.getCancellationToken().isCancellationRequested()); + assertFalse(factory.cleanupContext(new byte[] {9, 8, 7}, true)); + assertTrue(factory.cleanupContext(new byte[] {1, 2, 3}, true)); + assertTrue(context.getCancellationToken().isCancellationRequested()); + assertCancellationFutureCompletedExceptionally(context.getCancellationToken()); + + context.cancelOutstandingHeartbeat(); + assertFalse(factory.cleanupContext(new byte[] {1, 2, 3}, true)); + } + private HeartbeatContextImpl createHeartbeatContext(ActivityInfo info) { + return createHeartbeatContext(info, Duration.ofSeconds(60), Duration.ofSeconds(30)); + } + + private HeartbeatContextImpl createHeartbeatContext( + ActivityInfo info, + Duration maxHeartbeatThrottleInterval, + Duration defaultHeartbeatThrottleInterval) { return new HeartbeatContextImpl( service, "test-namespace", @@ -192,13 +361,23 @@ private HeartbeatContextImpl createHeartbeatContext(ActivityInfo info) { heartbeatExecutor, new NoopScope(), "test-identity", - Duration.ofSeconds(60), - Duration.ofSeconds(30), + maxHeartbeatThrottleInterval, + defaultHeartbeatThrottleInterval, TEST_BUFFER_MILLIS); } - private static ActivityInfo activityInfoWithHeartbeatTimeout(Duration heartbeatTimeout) { - ActivityInfo info = mock(ActivityInfo.class); + private static ActivityCanceledException assertCancellationFutureCompletedExceptionally( + ActivityCancellationToken cancellationToken) { + CompletableFuture cancellationFuture = cancellationToken.getCancellationFuture(); + assertTrue(cancellationFuture.isDone()); + assertTrue(cancellationFuture.isCompletedExceptionally()); + ExecutionException exception = assertThrows(ExecutionException.class, cancellationFuture::get); + assertSame(ActivityCanceledException.class, exception.getCause().getClass()); + return (ActivityCanceledException) exception.getCause(); + } + + private static ActivityInfoInternal activityInfoWithHeartbeatTimeout(Duration heartbeatTimeout) { + ActivityInfoInternal info = mock(ActivityInfoInternal.class); when(info.getHeartbeatTimeout()).thenReturn(heartbeatTimeout); when(info.getTaskToken()).thenReturn(new byte[] {1, 2, 3}); when(info.getWorkflowId()).thenReturn("test-workflow-id"); @@ -208,6 +387,7 @@ private static ActivityInfo activityInfoWithHeartbeatTimeout(Duration heartbeatT when(info.getActivityId()).thenReturn("test-activity-id"); when(info.isLocal()).thenReturn(false); when(info.getHeartbeatDetails()).thenReturn(Optional.empty()); + when(info.getCompletionHandle()).thenReturn(() -> {}); return info; } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotSupplierTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotSupplierTest.java index c6f11a61a1..1017a76431 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotSupplierTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotSupplierTest.java @@ -87,7 +87,8 @@ public void supplierIsCalledAppropriately() { metricsScope, () -> GetSystemInfoResponse.Capabilities.newBuilder().build(), new PollerTracker(), - new PollerTracker()); + new PollerTracker(), + null); PollWorkflowTaskQueueResponse pollResponse = PollWorkflowTaskQueueResponse.newBuilder() @@ -178,7 +179,8 @@ public void asyncPollerSupplierIsCalledAppropriately() throws Exception { trackingSS, metricsScope, () -> GetSystemInfoResponse.Capabilities.newBuilder().build(), - new PollerTracker()); + new PollerTracker(), + null); SlotPermit permit = new SlotPermit(); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/StickyQueueBacklogTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/StickyQueueBacklogTest.java index ab806c960b..5a29a74054 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/StickyQueueBacklogTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/StickyQueueBacklogTest.java @@ -75,7 +75,8 @@ public void stickyQueueBacklogResetTest() { metricsScope, () -> GetSystemInfoResponse.Capabilities.newBuilder().build(), new PollerTracker(), - new PollerTracker()); + new PollerTracker(), + null); PollWorkflowTaskQueueResponse pollResponse = PollWorkflowTaskQueueResponse.newBuilder() diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java index e9f4c9a361..23a63cda8b 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java @@ -118,6 +118,7 @@ public void activeTaskQueueTypesEvaluatedAtShutdownTime() throws Exception { wfThreadExecutor, Collections.emptyList(), Collections.emptyList(), + "test-worker-group", new NamespaceCapabilities()); // Register types AFTER worker construction. The request built by shutdown should reflect diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityCompleteWithErrorTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityCompleteWithErrorTest.java index d999909c7f..dd43ded763 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityCompleteWithErrorTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityCompleteWithErrorTest.java @@ -12,17 +12,21 @@ import io.temporal.workflow.WorkflowMethod; import java.time.Duration; import java.util.concurrent.ForkJoinPool; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; public class AsyncActivityCompleteWithErrorTest { + private final AsyncActivityWithManualCompletion activities = + new AsyncActivityWithManualCompletion(); @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(TestWorkflowImpl.class) - .setActivityImplementations(new AsyncActivityWithManualCompletion()) + .setActivityImplementations(activities) .build(); @WorkflowInterface @@ -42,6 +46,7 @@ public String execute(String taskQueue) { ActivityOptions.newBuilder() .setScheduleToStartTimeout(Duration.ofSeconds(1)) .setScheduleToCloseTimeout(Duration.ofSeconds(1)) + .setHeartbeatTimeout(Duration.ofSeconds(1)) .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) .build()); Promise promise = Async.function(activity::execute); @@ -64,15 +69,31 @@ public interface TestActivity { } public static class AsyncActivityWithManualCompletion implements TestActivity { + private final AtomicBoolean postReturnHeartbeatSucceeded = new AtomicBoolean(); + private final AtomicBoolean postReturnTokenCanceled = new AtomicBoolean(); + private final AtomicReference postReturnHeartbeatFailure = new AtomicReference<>(); + @Override public int execute() { ActivityExecutionContext context = Activity.getExecutionContext(); ManualActivityCompletionClient completionClient = context.useLocalManualCompletion(); - ForkJoinPool.commonPool().execute(() -> asyncActivityFn(completionClient)); + ForkJoinPool.commonPool().execute(() -> asyncActivityFn(context, completionClient)); return 0; } - private void asyncActivityFn(ManualActivityCompletionClient completionClient) { + private void asyncActivityFn( + ActivityExecutionContext context, ManualActivityCompletionClient completionClient) { + try { + Thread.sleep(100); + postReturnTokenCanceled.set(context.getCancellationToken().isCancellationRequested()); + context.heartbeat("after-local-manual-return"); + postReturnHeartbeatSucceeded.set(true); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + postReturnHeartbeatFailure.set(e); + } catch (Throwable e) { + postReturnHeartbeatFailure.set(e); + } completionClient.fail( ApplicationFailure.newFailure("simulated failure", "test", "some details")); } @@ -84,5 +105,8 @@ public void verifyActivityCompletionClientCompleteExceptionally() { TestWorkflow workflow = testWorkflowRule.newWorkflowStub(TestWorkflow.class); String result = workflow.execute(taskQueue); Assert.assertEquals("success", result); + Assert.assertNull(activities.postReturnHeartbeatFailure.get()); + Assert.assertTrue(activities.postReturnHeartbeatSucceeded.get()); + Assert.assertFalse(activities.postReturnTokenCanceled.get()); } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityWithCompletionClientTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityWithCompletionClientTest.java index ef666618d5..77d92a8028 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityWithCompletionClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/AsyncActivityWithCompletionClientTest.java @@ -33,12 +33,16 @@ public void tearDown() throws Exception { @Test public void testAsyncActivity() { + completionClientActivitiesImpl.activity1AsyncCompletionTokenCanceled.set(false); + completionClientActivitiesImpl.activity1PostReturnHeartbeatRejected.set(false); completionClientActivitiesImpl.completionClient = testWorkflowRule.getWorkflowClient().newActivityCompletionClient(); TestWorkflow1 client = testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow1.class); String result = client.execute(testWorkflowRule.getTaskQueue()); Assert.assertEquals("workflow", result); Assert.assertEquals("activity1", completionClientActivitiesImpl.invocations.get(0)); + Assert.assertFalse(completionClientActivitiesImpl.activity1AsyncCompletionTokenCanceled.get()); + Assert.assertTrue(completionClientActivitiesImpl.activity1PostReturnHeartbeatRejected.get()); } public static class TestAsyncActivityWorkflowImpl implements TestWorkflow1 { diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java new file mode 100644 index 0000000000..c6e994fa4d --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java @@ -0,0 +1,163 @@ +package io.temporal.workflow.activityTests.cancellation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assume.assumeTrue; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityCancellationType; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityOptions; +import io.temporal.api.workflowservice.v1.DescribeNamespaceRequest; +import io.temporal.api.workflowservice.v1.DescribeNamespaceResponse; +import io.temporal.client.ActivityCanceledException; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.failure.ActivityFailure; +import io.temporal.failure.CanceledFailure; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Async; +import io.temporal.workflow.CancellationScope; +import io.temporal.workflow.Promise; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class ActivityCancellationTokenIntegrationTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setTestTimeoutSeconds(30) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setWorkerHeartbeatInterval(Duration.ofSeconds(1)) + .build()) + .setWorkflowTypes(TestCancellationWorkflowImpl.class) + .setActivityImplementations(new NonHeartbeatingActivityImpl()) + .build(); + + @Before + public void checkServerSupportsWorkerCommands() { + assumeTrue( + "Requires real server with worker command support", SDKTestWorkflowRule.useExternalService); + + DescribeNamespaceResponse response = + testWorkflowRule + .getWorkflowClient() + .getWorkflowServiceStubs() + .blockingStub() + .describeNamespace( + DescribeNamespaceRequest.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + assumeTrue( + "Server does not support worker heartbeats", + response.getNamespaceInfo().getCapabilities().getWorkerHeartbeats()); + assumeTrue( + "Server does not support worker commands", + response.getNamespaceInfo().getCapabilities().getWorkerCommands()); + } + + @Test + public void activityObservesCancellationWithoutHeartbeat() { + TestCancellationWorkflow workflow = + testWorkflowRule.newWorkflowStub(TestCancellationWorkflow.class); + + assertEquals("cancelled", workflow.execute(testWorkflowRule.getTaskQueue())); + } + + @WorkflowInterface + public interface TestCancellationWorkflow { + @WorkflowMethod + String execute(String taskQueue); + + @SignalMethod + void activityStarted(); + } + + @ActivityInterface + public interface NonHeartbeatingActivity { + String waitForCancellation(); + } + + public static class TestCancellationWorkflowImpl implements TestCancellationWorkflow { + private boolean activityStarted; + + @Override + public String execute(String taskQueue) { + NonHeartbeatingActivity activity = + Workflow.newActivityStub( + NonHeartbeatingActivity.class, + ActivityOptions.newBuilder() + .setTaskQueue(taskQueue) + .setScheduleToCloseTimeout(Duration.ofSeconds(20)) + .setStartToCloseTimeout(Duration.ofSeconds(20)) + .setCancellationType(ActivityCancellationType.WAIT_CANCELLATION_COMPLETED) + .setDisableEagerExecution(true) + .build()); + + List> activityResults = new ArrayList<>(); + CancellationScope cancellationScope = + Workflow.newCancellationScope( + () -> activityResults.add(Async.function(activity::waitForCancellation))); + + cancellationScope.run(); + Workflow.await(() -> activityStarted); + cancellationScope.cancel(); + + try { + activityResults.get(0).get(); + return "completed"; + } catch (ActivityFailure e) { + if (e.getCause() instanceof CanceledFailure) { + return "cancelled"; + } + throw e; + } + } + + @Override + public void activityStarted() { + activityStarted = true; + } + } + + public static class NonHeartbeatingActivityImpl implements NonHeartbeatingActivity { + @Override + public String waitForCancellation() { + ActivityExecutionContext context = Activity.getExecutionContext(); + context + .getWorkflowClient() + .newWorkflowStub(TestCancellationWorkflow.class, context.getInfo().getWorkflowId()) + .activityStarted(); + + try { + context.getCancellationToken().getCancellationFuture().get(20, TimeUnit.SECONDS); + context.getCancellationToken().throwIfCancellationRequested(); + return "not-cancelled"; + } catch (ActivityCanceledException e) { + throw e; + } catch (ExecutionException e) { + if (e.getCause() instanceof ActivityCanceledException) { + throw (ActivityCanceledException) e.getCause(); + } + throw new RuntimeException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } catch (TimeoutException e) { + throw new RuntimeException(e); + } + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java b/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java index d80a7ece8f..0c71210516 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java @@ -25,6 +25,7 @@ import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; public class TestActivities { @@ -407,6 +408,8 @@ public int getLastAttempt() { public static class CompletionClientActivitiesImpl implements CompletionClientActivities, Closeable { public final List invocations = Collections.synchronizedList(new ArrayList<>()); + public final AtomicBoolean activity1AsyncCompletionTokenCanceled = new AtomicBoolean(); + public final AtomicBoolean activity1PostReturnHeartbeatRejected = new AtomicBoolean(); private final ThreadPoolExecutor executor = new ThreadPoolExecutor(0, 100, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); public ActivityCompletionClient completionClient; @@ -422,13 +425,32 @@ public void assertInvocations(String... expected) { @Override public String activity1(String a1) { Preconditions.checkNotNull(completionClient, "completionClient"); - byte[] taskToken = Activity.getExecutionContext().getInfo().getTaskToken(); + ActivityExecutionContext ctx = Activity.getExecutionContext(); + byte[] taskToken = ctx.getInfo().getTaskToken(); executor.execute( () -> { invocations.add("activity1"); + long deadline = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(5); + while (!activity1PostReturnHeartbeatRejected.get() + && System.currentTimeMillis() < deadline) { + try { + ctx.heartbeat("after-async-return"); + } catch (IllegalStateException e) { + activity1PostReturnHeartbeatRejected.set(true); + break; + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + activity1AsyncCompletionTokenCanceled.set( + ctx.getCancellationToken().isCancellationRequested()); completionClient.complete(taskToken, a1); }); - Activity.getExecutionContext().doNotCompleteOnReturn(); + ctx.doNotCompleteOnReturn(); return "ignored"; } diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/CloudServiceStubsImpl.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/CloudServiceStubsImpl.java index be5874160b..0f126cad9c 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/CloudServiceStubsImpl.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/CloudServiceStubsImpl.java @@ -39,7 +39,7 @@ final class CloudServiceStubsImpl implements CloudServiceStubs { .setInternalErrorDifferentiation(true) .build()); - log.info("Created CloudServiceStubs for channel: {}", channelManager.getRawChannel()); + log.debug("Created CloudServiceStubs for channel: {}", channelManager.getRawChannel()); this.blockingStub = CloudServiceGrpc.newBlockingStub(channelManager.getInterceptedChannel()); this.futureStub = CloudServiceGrpc.newFutureStub(channelManager.getInterceptedChannel()); diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/OperatorServiceStubsImpl.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/OperatorServiceStubsImpl.java index ad13548786..8ee6c6d01d 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/OperatorServiceStubsImpl.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/OperatorServiceStubsImpl.java @@ -32,7 +32,7 @@ final class OperatorServiceStubsImpl implements OperatorServiceStubs { this.channelManager = new ChannelManager(options, Collections.singletonList(deadlineInterceptor)); - log.info("Created OperatorServiceStubs for channel: {}", channelManager.getRawChannel()); + log.debug("Created OperatorServiceStubs for channel: {}", channelManager.getRawChannel()); this.blockingStub = OperatorServiceGrpc.newBlockingStub(channelManager.getInterceptedChannel()); this.futureStub = OperatorServiceGrpc.newFutureStub(channelManager.getInterceptedChannel()); diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/WorkflowServiceStubsImpl.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/WorkflowServiceStubsImpl.java index bfeb3b533e..4edc11bd4e 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/WorkflowServiceStubsImpl.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/WorkflowServiceStubsImpl.java @@ -63,7 +63,7 @@ final class WorkflowServiceStubsImpl implements WorkflowServiceStubs { this.channelManager = new ChannelManager(this.options, Collections.singletonList(deadlineInterceptor)); - log.info( + log.debug( String.format( "Created WorkflowServiceStubs for channel: %s", channelManager.getRawChannel())); diff --git a/temporal-test-server/src/main/java/io/temporal/serviceclient/TestServiceStubsImpl.java b/temporal-test-server/src/main/java/io/temporal/serviceclient/TestServiceStubsImpl.java index e847118013..9f62a6089f 100644 --- a/temporal-test-server/src/main/java/io/temporal/serviceclient/TestServiceStubsImpl.java +++ b/temporal-test-server/src/main/java/io/temporal/serviceclient/TestServiceStubsImpl.java @@ -32,7 +32,7 @@ public class TestServiceStubsImpl implements TestServiceStubs { this.channelManager = new ChannelManager(options, Collections.singletonList(deadlineInterceptor)); - log.info("Created TestServiceStubs for channel: {}", channelManager.getRawChannel()); + log.debug("Created TestServiceStubs for channel: {}", channelManager.getRawChannel()); this.blockingStub = TestServiceGrpc.newBlockingStub(channelManager.getInterceptedChannel()); this.futureStub = TestServiceGrpc.newFutureStub(channelManager.getInterceptedChannel()); From 273f28a5541eff2b14bacdaa29c3267a0f4cb782 Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Wed, 24 Jun 2026 13:30:27 -0400 Subject: [PATCH 022/107] Release Java SDK v1.36.0 (#2926) --- releases/v1.36.0 | 69 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 releases/v1.36.0 diff --git a/releases/v1.36.0 b/releases/v1.36.0 new file mode 100644 index 0000000000..e1af904133 --- /dev/null +++ b/releases/v1.36.0 @@ -0,0 +1,69 @@ +# **Highlights** + +## Standalone Activities (Public Preview) + +Support for Standalone Activities is now in [Public Preview](https://docs.temporal.io/evaluate/development-production-features/release-stages#public-preview). +Telemetry support has been expanded with improved `OpenTracingClientInterceptor` and a new `OpenTracingActivityClientInterceptor`. + +## Standalone Nexus Operations (Experimental) + +This release adds `NexusClient` interface for interacting with Nexus services outside of workflows. The `NexusServiceClient` +generic interface can be used to execute operations of a specific Nexus service in a type-safe manner. + +## GZIP transport-level compression + +Client connections now use GZIP transport-level gRPC compression by default. +Use `setGrpcCompression(GrpcCompression.NONE)` when building `ServiceStubsOptions` to disable it. + +## Continue-as-New USE_RAMPING_VERSION versioning behaviour + +Continue-as-New now supports `USE_RAMPING_VERSION` as an initial versioning behavior. +It pins the workflow to its task queue's Ramping Version at start time, ignoring the workflow's Target Version. + +## Activity cancellation without heartbeating + +Activities can receive cancellation requests without waiting for a heartbeat response if running against a recent enough version of the server. +You can use the `ActivityExecutionContext.getCancellationToken()` method to detect such cancellations when not using the `heartbeat()` API. + +## Additional worker shutdown options + +Enabling `WorkerOptions.Builder.setAllowActivityHeartbeatDuringShutdown` removes the limitation on activity heartbeats during +graceful worker shutdown, matching the behavior of other SDKs. Note that this will make it impossible to detect graceful shutdown +via `ActivityWorkerShutdownException`, so if the detection is desired, an alternative method is needed. + +`WorkerFactoryOptions.Builder.setShutdownCheckInterval` can be used to speed up worker shutdown in certain testing scenarios. +Changing this setting in production environment is discouraged. + +# What's Changed + +2026-05-04 - 5a765b17 - CaN USE_RAMPING_VERSION versioning behaviour (#2868) +2026-05-07 - 20afdcb4 - Expose Nexus Endpoint on Nexus Info (#2837) +2026-05-14 - 1e110b28 - Fixed a bug with spaces in WorkflowIds when creating links in the UI. (#2874) +2026-05-18 - b19042bf - Expose ShutdownManager poll interval via WorkerFactoryOptions (#2876) +2026-05-19 - 1386d4b3 - Add banner like other SDKs have (#2877) +2026-05-19 - 73560d3a - remove stale nightly tps omes test (#2879) +2026-05-19 - caba3510 - Upgrade cloud-api to v0.16.0 (#2873) +2026-05-21 - 7a8e6845 - Improve CONTRIBUTING guide and streamline local dev requirements (#2871) +2026-05-22 - 44bb6034 - Shutdown task loss prevention (#2820) +2026-05-22 - d45886cd - Fixed flaky test WorkerFactoryRegistryTest.testRandomOrder (#2886) +2026-05-27 - 187421b0 - Upgrade temporal-api to v1.62.12 (#2892) +2026-05-27 - f71f93b0 - remove dead omes job (#2891) +2026-06-01 - e947cc23 - Add history hints to workflow task started attributes (#2865) +2026-06-02 - 3ed49850 - Add cooldown on dependabot config (#2888) +2026-06-08 - 4d539760 - Wait for MARKER_RECORDED to fire version callback on replay (#2821) +2026-06-10 - 62a7f08a - Fix flaky test `NexusWorkflowTest.testNexusOperationTimeout_AfterStart` (#2908) +2026-06-11 - 27cfa7dc - Add Temporal Nexus Operation Handler (#2842) +2026-06-11 - 5f25aad6 - Standalone operations for Nexus (#2872) +2026-06-12 - 2bc7d9b3 - Use constants for all failure_reason metrics (#2914) +2026-06-12 - c9c4bdc0 - Add tests for temporal-kotlin extension APIs (#2905) +2026-06-15 - 3c2d9382 - Standalone Activities start delay (#2906) +2026-06-15 - 7390e05b - feat(extstore): add initial extstore types (#2900) +2026-06-15 - 78d0fee1 - Add backoff start for CAN (#2913) +2026-06-15 - f3edb105 - Add GZIP compression defaulting to on (#2911) +2026-06-16 - a1b6fff2 - Add OpenTracing interceptor for standalone activities (#2909) +2026-06-16 - aeac5b19 - Grant explicit actions:read to features reusable-workflow caller (#2919) +2026-06-18 - 8d8ca1b5 - Nexus Signal links (#2889) +2026-06-18 - 8e5ee336 - Support Standalone Activity client in temporal-testing (#2916) +2026-06-18 - dae5e0b1 - Add option to let activities heartbeat during worker shutdown (#2903) +2026-06-22 - 85e12a38 - feat(otel): add tracing for startWithUpdate. fixes #2620. (#2925) +2026-06-23 - e9761137 - Implement nexus-based activity cancels (#2917) From 3da1f1db85c0c2e59b3dad34f29edeed032cbf1c Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:54:07 -0700 Subject: [PATCH 023/107] Message and concurrent payload visitors (#2902) --- temporal-sdk/build.gradle | 35 + .../internal/common/AsyncSemaphore.java | 76 ++ .../payload/visitor/GeneratedVisitor.java | 11 + .../payload/visitor/MessageRegistryEntry.java | 18 + .../payload/visitor/MessageVisitor.java | 23 + .../visitor/MessageVisitorOptions.java | 53 + .../payload/visitor/MessageVisitors.java | 41 + .../payload/visitor/PayloadVisitor.java | 22 + .../visitor/PayloadVisitorOptions.java | 106 ++ .../payload/visitor/PayloadVisitors.java | 41 + .../internal/payload/visitor/Traversal.java | 243 +++++ .../payload/visitor/VisitorException.java | 15 + .../worker/tuning/FixedSizeSlotSupplier.java | 74 +- .../visitor/gen/PayloadVisitorGenerator.java | 594 +++++++++++ .../payload/visitor/gen/ProtoClosure.java | 139 +++ .../payload/visitor/MessageVisitorTest.java | 187 ++++ .../payload/visitor/PayloadVisitorTest.java | 925 ++++++++++++++++++ .../payload/visitor/TestVisitorException.java | 12 + 18 files changed, 2542 insertions(+), 73 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/common/AsyncSemaphore.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/GeneratedVisitor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageRegistryEntry.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitorOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitors.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/Traversal.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/VisitorException.java create mode 100644 temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java create mode 100644 temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/MessageVisitorTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/PayloadVisitorTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/TestVisitorException.java diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle index 9e914e31f4..d7b090e5b5 100644 --- a/temporal-sdk/build.gradle +++ b/temporal-sdk/build.gradle @@ -65,6 +65,41 @@ dependencies { java21Implementation files(sourceSets.main.output.classesDirs) { builtBy compileJava } } +// --- Payload visitor code generation --- +// A build-time generator (compiled in its own source set against the proto classes from +// temporal-serviceclient) emits GeneratedPayloadVisitor.java, which knows how to walk every +// payload-bearing Temporal API message. The generated source is added to the main source set. +sourceSets { + payloadVisitorGenerator { + java { + srcDirs = ['src/payloadVisitorGenerator/java'] + } + } +} + +dependencies { + payloadVisitorGeneratorImplementation project(':temporal-serviceclient') +} + +def generatedPayloadVisitorDir = layout.buildDirectory.dir('generated/payloadvisitor/java') + +def generatePayloadVisitor = tasks.register('generatePayloadVisitor', JavaExec) { + dependsOn 'compilePayloadVisitorGeneratorJava' + classpath = sourceSets.payloadVisitorGenerator.runtimeClasspath + mainClass = 'io.temporal.internal.payload.visitor.gen.PayloadVisitorGenerator' + args generatedPayloadVisitorDir.get().asFile.absolutePath + inputs.files(sourceSets.payloadVisitorGenerator.runtimeClasspath) + outputs.dir(generatedPayloadVisitorDir) +} + +sourceSets.main.java.srcDir(generatePayloadVisitor) + +tasks.named('compilePayloadVisitorGeneratorJava') { + options.encoding = 'UTF-8' + options.compilerArgs << '-Xlint:none' << '-Xlint:deprecation' << '-Werror' + options.errorprone.error('MissingCasesInEnumSwitch') +} + tasks.named('compileJava17Java') { options.release = 17 } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/AsyncSemaphore.java b/temporal-sdk/src/main/java/io/temporal/internal/common/AsyncSemaphore.java new file mode 100644 index 0000000000..c44ab3d4e4 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/AsyncSemaphore.java @@ -0,0 +1,76 @@ +package io.temporal.internal.common; + +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; + +/** + * A simple async semaphore. Unfortunately there's not any readily available properly licensed + * library I could find for this which is a bit shocking, but this implementation should be suitable + * for our needs. + */ +public final class AsyncSemaphore { + private final ReentrantLock lock = new ReentrantLock(); + private final Queue> waiters = new ArrayDeque<>(); + private int permits; + + public AsyncSemaphore(int initialPermits) { + this.permits = initialPermits; + } + + /** + * Acquire a permit asynchronously. If a permit is available, returns a completed future, + * otherwise returns a future that will be completed when a permit is released. + */ + public CompletableFuture acquire() { + lock.lock(); + try { + if (permits > 0) { + permits--; + return CompletableFuture.completedFuture(null); + } else { + CompletableFuture waiter = new CompletableFuture<>(); + waiters.add(waiter); + return waiter; + } + } finally { + lock.unlock(); + } + } + + public boolean tryAcquire() { + lock.lock(); + try { + if (permits > 0) { + permits--; + return true; + } + return false; + } finally { + lock.unlock(); + } + } + + /** + * Release a permit. If there are waiting futures, completes the next one instead of incrementing + * the permit count. + */ + public void release() { + lock.lock(); + try { + CompletableFuture waiter = waiters.poll(); + if (waiter != null) { + if (!waiter.complete(null) && waiter.isCancelled()) { + // If this waiter was cancelled, we need to release another permit, since this waiter + // is now useless + release(); + } + } else { + permits++; + } + } finally { + lock.unlock(); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/GeneratedVisitor.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/GeneratedVisitor.java new file mode 100644 index 0000000000..4e8325ba54 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/GeneratedVisitor.java @@ -0,0 +1,11 @@ +package io.temporal.internal.payload.visitor; + +import com.google.protobuf.Message; + +/** + * Generated traversal for one message type: visits the message's payload fields and recurses into + * its child messages. There is one per message type that can contain a payload. + */ +interface GeneratedVisitor { + void visit(Traversal traversal, Message.Builder builder); +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageRegistryEntry.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageRegistryEntry.java new file mode 100644 index 0000000000..2510878c62 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageRegistryEntry.java @@ -0,0 +1,18 @@ +package io.temporal.internal.payload.visitor; + +import com.google.protobuf.Message; +import java.util.function.Supplier; + +/** + * How to traverse one message type, and how to create an empty builder for it (used to unpack + * {@code google.protobuf.Any} values). + */ +final class MessageRegistryEntry { + final GeneratedVisitor visitor; + final Supplier newBuilder; + + MessageRegistryEntry(GeneratedVisitor visitor, Supplier newBuilder) { + this.visitor = visitor; + this.newBuilder = newBuilder; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java new file mode 100644 index 0000000000..21268e41d7 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java @@ -0,0 +1,23 @@ +package io.temporal.internal.payload.visitor; + +import com.google.protobuf.MessageOrBuilder; + +/** + * Callback invoked when traversal enters a proto message. The returned value becomes the contextual + * value in scope for that message and everything within it, and is restored to the enclosing value + * once traversal leaves the message. The message is provided as a builder and may be inspected or + * mutated. + * + * @param type of the contextual value + */ +@FunctionalInterface +interface MessageVisitor { + /** + * Handles a message being entered and returns the contextual value for it and its contents. + * + * @param current the contextual value in scope from the enclosing message + * @param message the message being entered + * @return the contextual value to use for this message and its contents + */ + C onEnter(C current, MessageOrBuilder message); +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitorOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitorOptions.java new file mode 100644 index 0000000000..2389a5dcbc --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitorOptions.java @@ -0,0 +1,53 @@ +package io.temporal.internal.payload.visitor; + +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Options for visiting the messages of a proto message, without visiting individual payloads. + * + * @param type of the contextual value supplied to the visitor + */ +final class MessageVisitorOptions { + private final @Nonnull MessageVisitor messageVisitor; + private final @Nullable C initialContext; + + private MessageVisitorOptions(Builder b) { + this.messageVisitor = b.messageVisitor; + this.initialContext = b.initialContext; + } + + public static Builder newBuilder(@Nonnull MessageVisitor messageVisitor) { + return new Builder<>(messageVisitor); + } + + @Nonnull + public MessageVisitor getMessageVisitor() { + return messageVisitor; + } + + @Nullable + public C getInitialContext() { + return initialContext; + } + + public static final class Builder { + private final @Nonnull MessageVisitor messageVisitor; + private C initialContext; + + private Builder(@Nonnull MessageVisitor messageVisitor) { + this.messageVisitor = Objects.requireNonNull(messageVisitor, "messageVisitor"); + } + + /** The contextual value in scope before any message is entered. */ + public Builder setInitialContext(@Nullable C initialContext) { + this.initialContext = initialContext; + return this; + } + + public MessageVisitorOptions build() { + return new MessageVisitorOptions<>(this); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitors.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitors.java new file mode 100644 index 0000000000..c749415382 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitors.java @@ -0,0 +1,41 @@ +package io.temporal.internal.payload.visitor; + +import com.google.protobuf.Message; +import javax.annotation.Nonnull; + +/** + * Visits the messages within a proto message, invoking the message visitor on each, without + * visiting individual payloads. Only messages that can contain a payload are visited. + * + *

This is an SDK-internal utility; it is not part of the public API. + */ +final class MessageVisitors { + private MessageVisitors() {} + + /** Visits the messages in {@code builder} in place. */ + public static void visit( + @Nonnull Message.Builder builder, @Nonnull MessageVisitorOptions options) { + Traversal traversal = + new Traversal( + null, + options.getMessageVisitor(), + options.getInitialContext(), + /* skipSearchAttributes= */ false, + /* skipHeaders= */ false, + 1, + GeneratedPayloadVisitor.REGISTRY); + traversal.dispatch(builder); + // No payload visits, so execute() completes inline; join() returns at once. Message-visitor + // errors throw from dispatch above. + traversal.execute().join(); + } + + /** Returns a copy with any changes applied; {@code message} is unchanged. */ + @SuppressWarnings("unchecked") + public static T visit( + @Nonnull T message, @Nonnull MessageVisitorOptions options) { + Message.Builder builder = message.toBuilder(); + visit(builder, options); + return (T) builder.build(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java new file mode 100644 index 0000000000..8872a44cde --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java @@ -0,0 +1,22 @@ +package io.temporal.internal.payload.visitor; + +import io.temporal.api.common.v1.Payload; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * Callback completing with the list that replaces {@code payloads}; complete with the same list to + * leave them unchanged. Asynchronous so I/O-backed implementations (e.g. external storage) compose + * without blocking a thread per call; a synchronous one returns {@link + * CompletableFuture#completedFuture}. + * + *

For a single-payload field the visitor must complete with exactly one payload. With + * concurrency greater than one, several visits may be in flight at once, so implementations must be + * thread-safe. + * + * @param type of the contextual value supplied to each visit + */ +@FunctionalInterface +interface PayloadVisitor { + CompletableFuture> visit(C context, List payloads); +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java new file mode 100644 index 0000000000..c834c2ec8e --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java @@ -0,0 +1,106 @@ +package io.temporal.internal.payload.visitor; + +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Options for visiting the payloads of a proto message. + * + * @param type of the contextual value supplied to the visitor + */ +final class PayloadVisitorOptions { + private final @Nonnull PayloadVisitor payloadVisitor; + private final @Nullable MessageVisitor messageVisitor; + private final @Nullable C initialContext; + private final boolean skipSearchAttributes; + private final boolean skipHeaders; + private final int concurrency; + + private PayloadVisitorOptions(Builder b) { + this.payloadVisitor = b.payloadVisitor; + this.messageVisitor = b.messageVisitor; + this.initialContext = b.initialContext; + this.skipSearchAttributes = b.skipSearchAttributes; + this.skipHeaders = b.skipHeaders; + this.concurrency = b.concurrency; + } + + public static Builder newBuilder(@Nonnull PayloadVisitor payloadVisitor) { + return new Builder<>(payloadVisitor); + } + + @Nonnull + public PayloadVisitor getPayloadVisitor() { + return payloadVisitor; + } + + @Nullable + public MessageVisitor getMessageVisitor() { + return messageVisitor; + } + + @Nullable + public C getInitialContext() { + return initialContext; + } + + public boolean isSkipSearchAttributes() { + return skipSearchAttributes; + } + + public boolean isSkipHeaders() { + return skipHeaders; + } + + public int getConcurrency() { + return concurrency; + } + + public static final class Builder { + private final @Nonnull PayloadVisitor payloadVisitor; + private MessageVisitor messageVisitor; + private C initialContext; + private boolean skipSearchAttributes; + private boolean skipHeaders; + private int concurrency = 1; + + private Builder(@Nonnull PayloadVisitor payloadVisitor) { + this.payloadVisitor = Objects.requireNonNull(payloadVisitor, "payloadVisitor"); + } + + public Builder setMessageVisitor(@Nullable MessageVisitor messageVisitor) { + this.messageVisitor = messageVisitor; + return this; + } + + /** The contextual value in scope before any message is entered. */ + public Builder setInitialContext(@Nullable C initialContext) { + this.initialContext = initialContext; + return this; + } + + public Builder setSkipSearchAttributes(boolean skipSearchAttributes) { + this.skipSearchAttributes = skipSearchAttributes; + return this; + } + + public Builder setSkipHeaders(boolean skipHeaders) { + this.skipHeaders = skipHeaders; + return this; + } + + /** At least {@code 1} (sequential). Bounds outstanding visit futures; no executor needed. */ + public Builder setConcurrency(int concurrency) { + this.concurrency = concurrency; + return this; + } + + public PayloadVisitorOptions build() { + if (concurrency < 1) { + throw new IllegalArgumentException("concurrency must be at least 1, got " + concurrency); + } + return new PayloadVisitorOptions<>(this); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java new file mode 100644 index 0000000000..69e9924123 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java @@ -0,0 +1,41 @@ +package io.temporal.internal.payload.visitor; + +import com.google.protobuf.Message; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +/** Visits every payload within a proto message. */ +final class PayloadVisitors { + private PayloadVisitors() {} + + /** Visits the payloads in {@code builder} in place. */ + public static CompletableFuture visit( + @Nonnull Message.Builder builder, @Nonnull PayloadVisitorOptions options) { + Traversal traversal = + new Traversal( + options.getPayloadVisitor(), + options.getMessageVisitor(), + options.getInitialContext(), + options.isSkipSearchAttributes(), + options.isSkipHeaders(), + options.getConcurrency(), + GeneratedPayloadVisitor.REGISTRY); + try { + traversal.dispatch(builder); + } catch (Throwable t) { + // Surface a walk failure through the future, so all failures reach the caller the same way. + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(t); + return failed; + } + return traversal.execute(); + } + + /** Completes with a copy that has the replacements applied; {@code message} is unchanged. */ + @SuppressWarnings("unchecked") + public static CompletableFuture visit( + @Nonnull T message, @Nonnull PayloadVisitorOptions options) { + Message.Builder builder = message.toBuilder(); + return visit(builder, options).thenApply(v -> (T) builder.build()); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/Traversal.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/Traversal.java new file mode 100644 index 0000000000..ffbdd712ce --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/Traversal.java @@ -0,0 +1,243 @@ +package io.temporal.internal.payload.visitor; + +import com.google.protobuf.Any; +import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.Message; +import com.google.protobuf.MessageOrBuilder; +import io.temporal.api.common.v1.Payload; +import io.temporal.internal.common.AsyncSemaphore; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * Mutable state for one traversal, called into by the generated per-message visitors. + * + *

The single-threaded walk only records a job and a write-back per payload sequence; {@link + * #execute()} runs the visits and applies the write-backs afterward in walk order, so the + * non-thread-safe builders are never mutated concurrently. Visits are asynchronous, so the engine + * needs no executor — it only bounds how many of their futures are outstanding. + */ +final class Traversal { + // Null for a message-only traversal: payload seams are skipped, only the MessageVisitor fires. + private final PayloadVisitor payloadVisitor; + private final MessageVisitor messageVisitor; + private final Map registry; + final boolean skipSearchAttributes; + final boolean skipHeaders; + private final int concurrency; + + private final List jobs = new ArrayList<>(); + private final List writeBacks = new ArrayList<>(); + private Object currentContext; + + @SuppressWarnings("unchecked") + Traversal( + PayloadVisitor payloadVisitor, + MessageVisitor messageVisitor, + Object initialContext, + boolean skipSearchAttributes, + boolean skipHeaders, + int concurrency, + Map registry) { + if (concurrency < 1) { + throw new IllegalArgumentException("concurrency must be at least 1, got " + concurrency); + } + this.payloadVisitor = (PayloadVisitor) payloadVisitor; + this.messageVisitor = (MessageVisitor) messageVisitor; + this.currentContext = initialContext; + this.skipSearchAttributes = skipSearchAttributes; + this.skipHeaders = skipHeaders; + this.concurrency = concurrency; + this.registry = registry; + } + + // --- Structural walk: called by generated code --- + + /** No-op for a type with no payloads. */ + void dispatch(Message.Builder builder) { + MessageRegistryEntry entry = registry.get(builder.getDescriptorForType().getFullName()); + if (entry != null) { + entry.visitor.visit(this, builder); + } + } + + /** Narrows the scoped context; returns the value {@link #exit} restores. */ + Object enter(MessageOrBuilder message) { + Object previous = currentContext; + if (messageVisitor != null) { + currentContext = messageVisitor.onEnter(previous, message); + } + return previous; + } + + void exit(Object previous) { + currentContext = previous; + } + + /** Record a visit of a payload sequence (a {@code Payloads} or {@code repeated Payload}). */ + void payloads(List batch, Consumer> writeBack) { + if (payloadVisitor == null) { + return; + } + LeafJob job = new LeafJob(batch, currentContext, false); + jobs.add(job); + writeBacks.add(() -> writeBack.accept(job.result)); + } + + /** The visitor must return exactly one payload (checked in {@link #record}). */ + void singlePayload(Payload value, Consumer writeBack) { + if (payloadVisitor == null) { + return; + } + LeafJob job = new LeafJob(Collections.singletonList(value), currentContext, true); + jobs.add(job); + writeBacks.add(() -> writeBack.accept(job.result.get(0))); + } + + /** Applied after all visits, single-threaded, in walk order. */ + void deferWriteBack(Runnable writeBack) { + writeBacks.add(writeBack); + } + + /** Unpack a {@code google.protobuf.Any}, traverse its contents, and re-pack after visits. */ + void any(Any.Builder anyBuilder) { + String typeUrl = anyBuilder.getTypeUrl(); + int slash = typeUrl.lastIndexOf('/'); + String fullName = slash >= 0 ? typeUrl.substring(slash + 1) : typeUrl; + MessageRegistryEntry entry = registry.get(fullName); + if (entry == null) { + // Unknown or payload-free type: leave the Any untouched. + return; + } + Message.Builder inner = entry.newBuilder.get(); + try { + inner.mergeFrom(anyBuilder.getValue()); + } catch (InvalidProtocolBufferException e) { + throw new VisitorException("failed to unpack Any of type " + fullName, e); + } + entry.visitor.visit(this, inner); + deferWriteBack(() -> anyBuilder.setValue(inner.build().toByteString())); + } + + // --- Execution: visits, then write-backs --- + + /** + * Completes the returned future once the visits and write-backs are done. Blocks no thread of its + * own: the caller decides how to wait and which executor to chain on. Write-backs run on whatever + * thread completes the last visit (inline if the visits are synchronous). A visit failure aborts + * the traversal — remaining visits unstarted, write-backs skipped — and completes the future + * exceptionally with the original throwable. + */ + CompletableFuture execute() { + CompletableFuture visitsDone = + jobs.isEmpty() ? CompletableFuture.completedFuture(null) : runVisits(); + CompletableFuture result = new CompletableFuture<>(); + visitsDone.whenComplete( + (v, err) -> { + if (err != null) { + result.completeExceptionally(unwrap(err)); + return; + } + try { + for (Runnable writeBack : writeBacks) { + writeBack.run(); + } + result.complete(null); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + return result; + } + + /** Run the visits with at most {@code concurrency} outstanding; fails with the first error. */ + private CompletableFuture runVisits() { + AsyncSemaphore permits = new AsyncSemaphore(concurrency); + AtomicReference firstError = new AtomicReference<>(); + List> all = new ArrayList<>(jobs.size()); + for (LeafJob job : jobs) { + all.add(permits.acquire().thenCompose(p -> runJob(job, permits, firstError))); + } + return CompletableFuture.allOf(all.toArray(new CompletableFuture[0])) + .thenCompose( + v -> { + Throwable error = firstError.get(); + if (error == null) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(error); + return failed; + }); + } + + /** Runs one job under a held permit, releasing it exactly once when the visit settles. */ + private CompletableFuture runJob( + LeafJob job, AsyncSemaphore permits, AtomicReference firstError) { + if (firstError.get() != null) { + permits.release(); + return CompletableFuture.completedFuture(null); + } + CompletableFuture> visit; + try { + visit = payloadVisitor.visit(job.context, job.input); + } catch (RuntimeException | Error t) { + firstError.compareAndSet(null, t); + permits.release(); + return CompletableFuture.completedFuture(null); + } + if (visit == null) { + firstError.compareAndSet(null, new IllegalStateException("payload visitor returned null")); + permits.release(); + return CompletableFuture.completedFuture(null); + } + return visit + .handle( + (result, err) -> { + record(job, result, err, firstError); + return (Void) null; + }) + .whenComplete((v, e) -> permits.release()); + } + + private void record( + LeafJob job, List result, Throwable err, AtomicReference firstError) { + if (err != null) { + firstError.compareAndSet(null, unwrap(err)); + } else if (result == null) { + firstError.compareAndSet(null, new IllegalStateException("payload visitor returned null")); + } else if (job.single && result.size() != 1) { + firstError.compareAndSet( + null, + new IllegalStateException( + "single-payload field requires exactly 1 returned payload, got " + result.size())); + } else { + job.result = result; + } + } + + /** Strip the {@link CompletionException} a dependent stage wraps around its cause. */ + private static Throwable unwrap(Throwable t) { + return (t instanceof CompletionException && t.getCause() != null) ? t.getCause() : t; + } + + /** A recorded visit and the slot its result lands in. */ + private static final class LeafJob { + final List input; + final Object context; + final boolean single; + volatile List result; + + LeafJob(List input, Object context, boolean single) { + this.input = input; + this.context = context; + this.single = single; + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/VisitorException.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/VisitorException.java new file mode 100644 index 0000000000..fd37d4ce05 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/VisitorException.java @@ -0,0 +1,15 @@ +package io.temporal.internal.payload.visitor; + +/** + * Thrown when visiting the payloads or messages of a proto message fails. The original failure, if + * any, is available via {@link #getCause()}. + */ +final class VisitorException extends RuntimeException { + VisitorException(String message, Throwable cause) { + super(message, cause); + } + + VisitorException(String message) { + super(message); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/worker/tuning/FixedSizeSlotSupplier.java b/temporal-sdk/src/main/java/io/temporal/worker/tuning/FixedSizeSlotSupplier.java index b62b8ec8d2..d376242788 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/tuning/FixedSizeSlotSupplier.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/tuning/FixedSizeSlotSupplier.java @@ -1,11 +1,9 @@ package io.temporal.worker.tuning; import com.google.common.base.Preconditions; -import java.util.ArrayDeque; +import io.temporal.internal.common.AsyncSemaphore; import java.util.Optional; -import java.util.Queue; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.locks.ReentrantLock; /** * This implementation of {@link SlotSupplier} provides a fixed number of slots backed by a @@ -17,76 +15,6 @@ public class FixedSizeSlotSupplier implements SlotSupplier< private final int numSlots; private final AsyncSemaphore executorSlotsSemaphore; - /** - * A simple version of an async semaphore. Unfortunately there's not any readily available - * properly licensed library I could find for this which is a bit shocking, but this - * implementation should be suitable for our needs - */ - static class AsyncSemaphore { - private final ReentrantLock lock = new ReentrantLock(); - private final Queue> waiters = new ArrayDeque<>(); - private int permits; - - AsyncSemaphore(int initialPermits) { - this.permits = initialPermits; - } - - /** - * Acquire a permit asynchronously. If a permit is available, returns a completed future, - * otherwise returns a future that will be completed when a permit is released. - */ - public CompletableFuture acquire() { - lock.lock(); - try { - if (permits > 0) { - permits--; - return CompletableFuture.completedFuture(null); - } else { - CompletableFuture waiter = new CompletableFuture<>(); - waiters.add(waiter); - return waiter; - } - } finally { - lock.unlock(); - } - } - - public boolean tryAcquire() { - lock.lock(); - try { - if (permits > 0) { - permits--; - return true; - } - return false; - } finally { - lock.unlock(); - } - } - - /** - * Release a permit. If there are waiting futures, completes the next one instead of - * incrementing the permit count. - */ - public void release() { - lock.lock(); - try { - CompletableFuture waiter = waiters.poll(); - if (waiter != null) { - if (!waiter.complete(null) && waiter.isCancelled()) { - // If this waiter was cancelled, we need to release another permit, since this waiter - // is now useless - release(); - } - } else { - permits++; - } - } finally { - lock.unlock(); - } - } - } - public FixedSizeSlotSupplier(int numSlots) { Preconditions.checkArgument(numSlots > 0, "FixedSizeSlotSupplier must have at least one slot"); this.numSlots = numSlots; diff --git a/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java new file mode 100644 index 0000000000..ad2924a51e --- /dev/null +++ b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/PayloadVisitorGenerator.java @@ -0,0 +1,594 @@ +package io.temporal.internal.payload.visitor.gen; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Build-time generator that emits {@code GeneratedPayloadVisitor}. + * + *

Starting from the WorkflowService and OperatorService file descriptors, it walks the proto + * closure, determines which message types can transitively contain a {@code Payload} (or a {@code + * google.protobuf.Any}, treated conservatively as payload-bearing), and emits one {@code visit_*} + * method per such type plus a registry keyed by descriptor full name. + * + *

Usage: {@code PayloadVisitorGenerator }. + */ +public final class PayloadVisitorGenerator { + + static final String PAYLOAD = "temporal.api.common.v1.Payload"; + static final String PAYLOADS = "temporal.api.common.v1.Payloads"; + static final String ANY = "google.protobuf.Any"; + static final String SEARCH_ATTRIBUTES = "temporal.api.common.v1.SearchAttributes"; + static final String HEADER = "temporal.api.common.v1.Header"; + + static final String OUTPUT_PACKAGE = "io.temporal.internal.payload.visitor"; + static final String OUTPUT_CLASS = "GeneratedPayloadVisitor"; + static final String PAYLOADS_FQN = "io.temporal.api.common.v1.Payloads"; + static final int REGISTER_CHUNK = 40; + + enum Kind { + SINGLE_PAYLOAD, + REPEATED_PAYLOAD, + PAYLOADS_SINGLE, + PAYLOADS_REPEATED, + MAP_PAYLOAD, + MAP_PAYLOADS, + ANY_SINGLE, + ANY_REPEATED, + MAP_ANY, + MESSAGE_SINGLE, + MESSAGE_REPEATED, + MAP_MESSAGE, + IGNORE + } + + /** Classification of a field: how it should be traversed, and its child message type if any. */ + static final class FieldPlan { + final Kind kind; + final Descriptor child; // child message descriptor for MESSAGE_* / MAP_MESSAGE, else null + + FieldPlan(Kind kind, Descriptor child) { + this.kind = kind; + this.child = child; + } + } + + public static void main(String[] args) throws Exception { + if (args.length < 1) { + throw new IllegalArgumentException("usage: PayloadVisitorGenerator "); + } + new PayloadVisitorGenerator().run(Paths.get(args[0])); + } + + private ProtoClosure closure; + + void run(Path outputRoot) throws IOException { + List seeds = + Arrays.asList( + io.temporal.api.workflowservice.v1.ServiceProto.getDescriptor(), + io.temporal.api.operatorservice.v1.ServiceProto.getDescriptor()); + + this.closure = ProtoClosure.of(seeds); + + // Deterministic output order, keyed by descriptor full name. + Map emitted = new TreeMap<>(); + for (Descriptor d : closure.allMessages) { + if (reaches(d)) { + emitted.put(d.getFullName(), d); + } + } + + verifyAccessors(emitted.values()); + + String source = emit(emitted); + + Path dir = outputRoot; + for (String part : OUTPUT_PACKAGE.split("\\.", -1)) { + dir = dir.resolve(part); + } + Files.createDirectories(dir); + Path out = dir.resolve(OUTPUT_CLASS + ".java"); + Files.write(out, source.getBytes(StandardCharsets.UTF_8)); + System.out.println("PayloadVisitorGenerator: wrote " + emitted.size() + " visitors to " + out); + } + + // --- Reachability + classification --- + + /** Whether {@code d} can transitively contain a payload; delegates to the shared closure. */ + private boolean reaches(Descriptor d) { + return closure.reaches(d); + } + + static FieldPlan classify(FieldDescriptor f) { + if (f.isMapField()) { + FieldDescriptor value = f.getMessageType().findFieldByNumber(2); + if (value.getJavaType() == FieldDescriptor.JavaType.MESSAGE) { + String name = value.getMessageType().getFullName(); + if (PAYLOAD.equals(name)) { + return new FieldPlan(Kind.MAP_PAYLOAD, null); + } + if (PAYLOADS.equals(name)) { + return new FieldPlan(Kind.MAP_PAYLOADS, null); + } + if (ANY.equals(name)) { + return new FieldPlan(Kind.MAP_ANY, null); + } + if (isTemporal(value.getMessageType())) { + return new FieldPlan(Kind.MAP_MESSAGE, value.getMessageType()); + } + return new FieldPlan(Kind.IGNORE, null); + } + return new FieldPlan(Kind.IGNORE, null); + } + if (f.getJavaType() != FieldDescriptor.JavaType.MESSAGE) { + return new FieldPlan(Kind.IGNORE, null); + } + String name = f.getMessageType().getFullName(); + boolean repeated = f.isRepeated(); + if (PAYLOAD.equals(name)) { + return new FieldPlan(repeated ? Kind.REPEATED_PAYLOAD : Kind.SINGLE_PAYLOAD, null); + } + if (PAYLOADS.equals(name)) { + return new FieldPlan(repeated ? Kind.PAYLOADS_REPEATED : Kind.PAYLOADS_SINGLE, null); + } + if (ANY.equals(name)) { + return new FieldPlan(repeated ? Kind.ANY_REPEATED : Kind.ANY_SINGLE, null); + } + if (!isTemporal(f.getMessageType())) { + // Non-Temporal messages (google well-known types, etc.) never carry Temporal payloads + // except inside an Any, which is handled separately. + return new FieldPlan(Kind.IGNORE, null); + } + return new FieldPlan( + repeated ? Kind.MESSAGE_REPEATED : Kind.MESSAGE_SINGLE, f.getMessageType()); + } + + static boolean isTemporal(Descriptor d) { + return d.getFullName().startsWith("temporal."); + } + + // --- Java naming --- + + /** Mirrors protoc's UnderscoresToCamelCase used to derive Java accessor names. */ + static String camel(String input, boolean capNext) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + if (c >= 'a' && c <= 'z') { + sb.append(capNext ? Character.toUpperCase(c) : c); + capNext = false; + } else if (c >= 'A' && c <= 'Z') { + if (i == 0 && !capNext) { + sb.append(Character.toLowerCase(c)); + } else { + sb.append(c); + } + capNext = false; + } else if (c >= '0' && c <= '9') { + sb.append(c); + capNext = true; + } else { + capNext = true; + } + } + return sb.toString(); + } + + /** Capitalized accessor base, e.g. {@code schedule_activity} -> {@code ScheduleActivity}. */ + static String base(FieldDescriptor f) { + return camel(f.getName(), true); + } + + static String javaPackage(Descriptor d) { + String pkg = d.getFile().getOptions().getJavaPackage(); + if (pkg == null || pkg.isEmpty()) { + throw new IllegalStateException("message " + d.getFullName() + " has no java_package option"); + } + return pkg; + } + + /** + * Source-form class name, e.g. {@code io.temporal.api.common.v1.Payload.ExternalPayloadDetails}. + */ + static String sourceClassName(Descriptor d) { + Deque names = new ArrayDeque<>(); + for (Descriptor c = d; c != null; c = c.getContainingType()) { + names.addFirst(c.getName()); + } + return javaPackage(d) + "." + String.join(".", names); + } + + /** Binary class name (nested types joined with {@code $}) for reflective verification. */ + static String binaryClassName(Descriptor d) { + Deque names = new ArrayDeque<>(); + for (Descriptor c = d; c != null; c = c.getContainingType()) { + names.addFirst(c.getName()); + } + return javaPackage(d) + "." + String.join("$", names); + } + + static String methodName(String full) { + return "visit_" + full.replace('.', '_'); + } + + // --- Accessor verification (build-time safety net for the naming rules) --- + + private void verifyAccessors(Iterable descriptors) { + for (Descriptor d : descriptors) { + Class builder; + try { + builder = Class.forName(binaryClassName(d) + "$Builder"); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("no builder class for " + d.getFullName(), e); + } + Set methods = new HashSet<>(); + for (java.lang.reflect.Method m : builder.getMethods()) { + methods.add(m.getName()); + } + for (FieldDescriptor f : d.getFields()) { + for (String required : requiredMethods(classify(f).kind, base(f))) { + if (!methods.contains(required)) { + throw new IllegalStateException( + "expected builder method " + + builder.getName() + + "#" + + required + + " for field " + + d.getFullName() + + "." + + f.getName() + + " (" + + classify(f).kind + + ")"); + } + } + } + } + } + + static List requiredMethods(Kind kind, String base) { + switch (kind) { + case SINGLE_PAYLOAD: + return Arrays.asList("has" + base, "get" + base, "set" + base); + case REPEATED_PAYLOAD: + return Arrays.asList("get" + base + "List", "clear" + base, "addAll" + base); + case PAYLOADS_SINGLE: + return Arrays.asList("has" + base, "get" + base, "set" + base); + case PAYLOADS_REPEATED: + return Arrays.asList("get" + base, "get" + base + "Count", "set" + base); + case MAP_PAYLOAD: + return Arrays.asList("get" + base + "Map", "put" + base); + case MAP_PAYLOADS: + case MAP_ANY: + case MAP_MESSAGE: + return Arrays.asList("get" + base + "Map", "put" + base); + case ANY_SINGLE: + case MESSAGE_SINGLE: + return Arrays.asList("has" + base, "get" + base + "Builder"); + case ANY_REPEATED: + case MESSAGE_REPEATED: + return Arrays.asList("get" + base + "BuilderList"); + case IGNORE: + return Arrays.asList(); + } + throw new AssertionError(kind); + } + + // --- Emission --- + + private String emit(Map emitted) { + StringBuilder sb = new StringBuilder(); + sb.append("// Code generated by PayloadVisitorGenerator; DO NOT EDIT.\n"); + sb.append("package ").append(OUTPUT_PACKAGE).append(";\n\n"); + sb.append("import java.util.ArrayList;\n"); + sb.append("import java.util.HashMap;\n"); + sb.append("import java.util.Map;\n\n"); + sb.append("@SuppressWarnings(\"deprecation\")\n"); + sb.append("final class ").append(OUTPUT_CLASS).append(" {\n"); + sb.append(" private ").append(OUTPUT_CLASS).append("() {}\n\n"); + + List list = new ArrayList<>(emitted.values()); + + sb.append(" static final Map REGISTRY = buildRegistry();\n\n"); + sb.append(" private static Map buildRegistry() {\n"); + sb.append(" Map m = new HashMap<>(") + .append(Math.max(16, list.size() * 2)) + .append(");\n"); + int chunks = (list.size() + REGISTER_CHUNK - 1) / REGISTER_CHUNK; + for (int i = 0; i < chunks; i++) { + sb.append(" register").append(i).append("(m);\n"); + } + sb.append(" return m;\n"); + sb.append(" }\n\n"); + + for (int i = 0; i < chunks; i++) { + sb.append(" private static void register") + .append(i) + .append("(Map m) {\n"); + int start = i * REGISTER_CHUNK; + int end = Math.min(start + REGISTER_CHUNK, list.size()); + for (int j = start; j < end; j++) { + Descriptor d = list.get(j); + String src = sourceClassName(d); + String mn = methodName(d.getFullName()); + sb.append(" m.put(\"") + .append(d.getFullName()) + .append("\", new MessageRegistryEntry((t, b) -> ") + .append(mn) + .append("(t, (") + .append(src) + .append(".Builder) b), ") + .append(src) + .append("::newBuilder));\n"); + } + sb.append(" }\n\n"); + } + + for (Descriptor d : list) { + emitVisitMethod(sb, d); + } + + sb.append("}\n"); + return sb.toString(); + } + + private void emitVisitMethod(StringBuilder sb, Descriptor d) { + String src = sourceClassName(d); + sb.append(" static void ") + .append(methodName(d.getFullName())) + .append("(Traversal t, ") + .append(src) + .append(".Builder b) {\n"); + sb.append(" Object __c = t.enter(b);\n"); + int fi = 0; + for (FieldDescriptor f : d.getFields()) { + FieldPlan plan = classify(f); + if (plan.kind == Kind.IGNORE) { + continue; + } + if ((plan.kind == Kind.MESSAGE_SINGLE + || plan.kind == Kind.MESSAGE_REPEATED + || plan.kind == Kind.MAP_MESSAGE) + && !reaches(plan.child)) { + continue; + } + emitField(sb, f, plan, fi++); + } + sb.append(" t.exit(__c);\n"); + sb.append(" }\n\n"); + } + + private void emitField(StringBuilder sb, FieldDescriptor f, FieldPlan plan, int fi) { + String B = base(f); + String k = "__key" + fi; + String v = "__v" + fi; + switch (plan.kind) { + case SINGLE_PAYLOAD: + sb.append(" if (b.has").append(B).append("()) {\n"); + sb.append(" t.singlePayload(b.get") + .append(B) + .append("(), p -> b.set") + .append(B) + .append("(p));\n"); + sb.append(" }\n"); + break; + case REPEATED_PAYLOAD: + sb.append(" t.payloads(b.get").append(B).append("List(), pl -> {\n"); + sb.append(" b.clear").append(B).append("();\n"); + sb.append(" b.addAll").append(B).append("(pl);\n"); + sb.append(" });\n"); + break; + case PAYLOADS_SINGLE: + sb.append(" if (b.has").append(B).append("()) {\n"); + sb.append(" t.payloads(b.get").append(B).append("().getPayloadsList(),\n"); + sb.append(" pl -> b.set") + .append(B) + .append("(") + .append(PAYLOADS_FQN) + .append(".newBuilder().addAllPayloads(pl).build()));\n"); + sb.append(" }\n"); + break; + case PAYLOADS_REPEATED: + sb.append(" for (int ") + .append(v) + .append(" = 0; ") + .append(v) + .append(" < b.get") + .append(B) + .append("Count(); ") + .append(v) + .append("++) {\n"); + sb.append(" final int ").append(k).append(" = ").append(v).append(";\n"); + sb.append(" t.payloads(b.get") + .append(B) + .append("(") + .append(k) + .append(").getPayloadsList(),\n"); + sb.append(" pl -> b.set") + .append(B) + .append("(") + .append(k) + .append(", ") + .append(PAYLOADS_FQN) + .append(".newBuilder().addAllPayloads(pl).build()));\n"); + sb.append(" }\n"); + break; + case MAP_PAYLOAD: + sb.append(" for (String ") + .append(k) + .append(" : new ArrayList<>(b.get") + .append(B) + .append("Map().keySet())) {\n"); + sb.append(" final String ").append(v).append(" = ").append(k).append(";\n"); + sb.append(" t.singlePayload(b.get") + .append(B) + .append("Map().get(") + .append(v) + .append("), p -> b.put") + .append(B) + .append("(") + .append(v) + .append(", p));\n"); + sb.append(" }\n"); + break; + case MAP_PAYLOADS: + sb.append(" for (String ") + .append(k) + .append(" : new ArrayList<>(b.get") + .append(B) + .append("Map().keySet())) {\n"); + sb.append(" final String ").append(v).append(" = ").append(k).append(";\n"); + sb.append(" t.payloads(b.get") + .append(B) + .append("Map().get(") + .append(v) + .append(").getPayloadsList(),\n"); + sb.append(" pl -> b.put") + .append(B) + .append("(") + .append(v) + .append(", ") + .append(PAYLOADS_FQN) + .append(".newBuilder().addAllPayloads(pl).build()));\n"); + sb.append(" }\n"); + break; + case ANY_SINGLE: + sb.append(" if (b.has").append(B).append("()) {\n"); + sb.append(" t.any(b.get").append(B).append("Builder());\n"); + sb.append(" }\n"); + break; + case ANY_REPEATED: + sb.append(" for (com.google.protobuf.Any.Builder ") + .append(v) + .append(" : b.get") + .append(B) + .append("BuilderList()) {\n"); + sb.append(" t.any(").append(v).append(");\n"); + sb.append(" }\n"); + break; + case MAP_ANY: + sb.append(" for (String ") + .append(k) + .append(" : new ArrayList<>(b.get") + .append(B) + .append("Map().keySet())) {\n"); + sb.append(" final String ").append(v).append(" = ").append(k).append(";\n"); + sb.append(" com.google.protobuf.Any.Builder ab") + .append(fi) + .append(" = b.get") + .append(B) + .append("Map().get(") + .append(v) + .append(").toBuilder();\n"); + sb.append(" t.any(ab").append(fi).append(");\n"); + sb.append(" t.deferWriteBack(() -> b.put") + .append(B) + .append("(") + .append(v) + .append(", ab") + .append(fi) + .append(".build()));\n"); + sb.append(" }\n"); + break; + case MESSAGE_SINGLE: + { + String guard = childGuard(plan.child); + sb.append(" if (").append(guard).append("b.has").append(B).append("()) {\n"); + sb.append(" ") + .append(methodName(plan.child.getFullName())) + .append("(t, b.get") + .append(B) + .append("Builder());\n"); + sb.append(" }\n"); + } + break; + case MESSAGE_REPEATED: + { + String childSrc = sourceClassName(plan.child); + String guard = childGuard(plan.child); + if (!guard.isEmpty()) { + sb.append(" if (").append(guard.substring(0, guard.length() - 4)).append(") {\n "); + } + sb.append(" for (") + .append(childSrc) + .append(".Builder ") + .append(v) + .append(" : b.get") + .append(B) + .append("BuilderList()) {\n"); + sb.append(" ") + .append(methodName(plan.child.getFullName())) + .append("(t, ") + .append(v) + .append(");\n"); + sb.append(" }\n"); + if (!guard.isEmpty()) { + sb.append(" }\n"); + } + } + break; + case MAP_MESSAGE: + { + String childSrc = sourceClassName(plan.child); + sb.append(" for (String ") + .append(k) + .append(" : new ArrayList<>(b.get") + .append(B) + .append("Map().keySet())) {\n"); + sb.append(" final String ").append(v).append(" = ").append(k).append(";\n"); + sb.append(" ") + .append(childSrc) + .append(".Builder vb") + .append(fi) + .append(" = b.get") + .append(B) + .append("Map().get(") + .append(v) + .append(").toBuilder();\n"); + sb.append(" ") + .append(methodName(plan.child.getFullName())) + .append("(t, vb") + .append(fi) + .append(");\n"); + sb.append(" t.deferWriteBack(() -> b.put") + .append(B) + .append("(") + .append(v) + .append(", vb") + .append(fi) + .append(".build()));\n"); + sb.append(" }\n"); + } + break; + case IGNORE: + break; + } + } + + /** Optional {@code &&}-terminated guard expression for SearchAttributes/Header skipping. */ + private String childGuard(Descriptor child) { + String name = child.getFullName(); + if (SEARCH_ATTRIBUTES.equals(name)) { + return "!t.skipSearchAttributes && "; + } + if (HEADER.equals(name)) { + return "!t.skipHeaders && "; + } + return ""; + } +} diff --git a/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java new file mode 100644 index 0000000000..9772d4b085 --- /dev/null +++ b/temporal-sdk/src/payloadVisitorGenerator/java/io/temporal/internal/payload/visitor/gen/ProtoClosure.java @@ -0,0 +1,139 @@ +package io.temporal.internal.payload.visitor.gen; + +import com.google.protobuf.Descriptors.Descriptor; +import com.google.protobuf.Descriptors.FieldDescriptor; +import com.google.protobuf.Descriptors.FileDescriptor; +import io.temporal.internal.payload.visitor.gen.PayloadVisitorGenerator.FieldPlan; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Shared proto-descriptor model for the build-time generators: the message closure reachable from a + * set of seed services, and which of those messages can transitively contain a {@code Payload}. + */ +final class ProtoClosure { + + /** All non-map-entry messages in the closure, in discovery order. */ + final List allMessages; + + /** Full names of the messages that can transitively contain a payload. */ + private final Set reaches; + + private ProtoClosure(List allMessages, Set reaches) { + this.allMessages = allMessages; + this.reaches = reaches; + } + + /** Whether {@code d} can transitively contain a payload. */ + boolean reaches(Descriptor d) { + return reaches.contains(d.getFullName()); + } + + /** Builds the closure and payload-reachability set from the given seed file descriptors. */ + static ProtoClosure of(List seeds) { + List all = collectMessages(fileClosure(seeds)); + return new ProtoClosure(all, computeReachability(all)); + } + + // --- Descriptor discovery --- + + private static Set fileClosure(List seeds) { + Set seen = new LinkedHashSet<>(); + Deque queue = new ArrayDeque<>(seeds); + while (!queue.isEmpty()) { + FileDescriptor f = queue.poll(); + if (seen.add(f)) { + queue.addAll(f.getDependencies()); + } + } + return seen; + } + + private static List collectMessages(Set files) { + List result = new ArrayList<>(); + for (FileDescriptor f : files) { + for (Descriptor d : f.getMessageTypes()) { + collectMessages(d, result); + } + } + return result; + } + + private static void collectMessages(Descriptor d, List out) { + if (d.getOptions().getMapEntry()) { + return; // synthetic map entry type; handled via the owning map field + } + out.add(d); + for (Descriptor nested : d.getNestedTypes()) { + collectMessages(nested, out); + } + } + + // --- Reachability --- + + /** + * Least-fixpoint reachability over the message-reference graph. A message reaches a payload if it + * has a direct payload/Any field, or it references (via a message or map-message field) another + * message that does. Iterating to a fixpoint handles cycles (e.g. {@code Failure.cause}) + * correctly without over-approximating payload-free cycles. + */ + private static Set computeReachability(List all) { + Set reaches = new HashSet<>(); + Map> children = new HashMap<>(); + for (Descriptor d : all) { + boolean direct = false; + List refs = new ArrayList<>(); + for (FieldDescriptor f : d.getFields()) { + FieldPlan plan = PayloadVisitorGenerator.classify(f); + switch (plan.kind) { + case SINGLE_PAYLOAD: + case REPEATED_PAYLOAD: + case PAYLOADS_SINGLE: + case PAYLOADS_REPEATED: + case MAP_PAYLOAD: + case MAP_PAYLOADS: + case ANY_SINGLE: + case ANY_REPEATED: + case MAP_ANY: + direct = true; + break; + case MESSAGE_SINGLE: + case MESSAGE_REPEATED: + case MAP_MESSAGE: + refs.add(plan.child); + break; + case IGNORE: + break; + } + } + if (direct) { + reaches.add(d.getFullName()); + } + children.put(d.getFullName(), refs); + } + boolean changed = true; + while (changed) { + changed = false; + for (Descriptor d : all) { + if (reaches.contains(d.getFullName())) { + continue; + } + for (Descriptor c : children.get(d.getFullName())) { + if (reaches.contains(c.getFullName())) { + reaches.add(d.getFullName()); + changed = true; + break; + } + } + } + } + return reaches; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/MessageVisitorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/MessageVisitorTest.java new file mode 100644 index 0000000000..8cc7cc7bd6 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/MessageVisitorTest.java @@ -0,0 +1,187 @@ +package io.temporal.internal.payload.visitor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.RecordMarkerCommandAttributes; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.common.v1.Memo; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.enums.v1.CommandType; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +/** Tests for {@link MessageVisitors}: message traversal with scoped context, and validation. */ +public class MessageVisitorTest { + + static Payload p(String s) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(s)).build(); + } + + static Command activity(String id, String... inputs) { + Payloads.Builder in = Payloads.newBuilder(); + for (String s : inputs) { + in.addPayloads(p(s)); + } + return Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK) + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder().setActivityId(id).setInput(in)) + .build(); + } + + @Test + public void visitsBuilderInPlace() { + Memo.Builder builder = Memo.newBuilder().putFields("k", p("v")); + List entered = new ArrayList<>(); + MessageVisitors.visit( + builder, + MessageVisitorOptions.newBuilder( + (current, msg) -> { + entered.add(msg.getDescriptorForType().getFullName()); + return current; + }) + .build()); + assertEquals(Arrays.asList("temporal.api.common.v1.Memo"), entered); + } + + @Test + public void messageVisitorMutatesInPlace() { + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder().addCommands(activity("orig", "x")).build(); + + RespondWorkflowTaskCompletedRequest result = + MessageVisitors.visit( + request, + MessageVisitorOptions.newBuilder( + (current, msg) -> { + if (msg instanceof ScheduleActivityTaskCommandAttributes.Builder) { + ((ScheduleActivityTaskCommandAttributes.Builder) msg) + .setActivityId("rewritten"); + } + return current; + }) + .build()); + + assertEquals( + "rewritten", + result.getCommands(0).getScheduleActivityTaskCommandAttributes().getActivityId()); + } + + @Test + public void visitsEachMessageWithScopedContext() { + // Three commands with distinct types exercise per-command scoping and scope restoration + // between siblings. + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands(activity("a", "x")) + .addCommands( + Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION) + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder()) + .build()) + .addCommands( + Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_RECORD_MARKER) + .setRecordMarkerCommandAttributes( + RecordMarkerCommandAttributes.newBuilder().setMarkerName("m")) + .build()) + .build(); + + // MessageVisitors traversal is single-threaded, so the entered messages have a stable order. + List entered = new ArrayList<>(); + List contextOnEnter = new ArrayList<>(); + + MessageVisitorOptions opts = + MessageVisitorOptions.newBuilder( + (current, msg) -> { + entered.add(msg.getDescriptorForType().getFullName()); + contextOnEnter.add(current); + return msg instanceof Command.Builder + ? ((Command.Builder) msg).getCommandType() + : current; + }) + .build(); + + MessageVisitors.visit(request, opts); + + // Exact order: the root, then each (repeated) command followed by its oneof attributes message. + assertEquals( + Arrays.asList( + "temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest", + "temporal.api.command.v1.Command", + "temporal.api.command.v1.ScheduleActivityTaskCommandAttributes", + "temporal.api.command.v1.Command", + "temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes", + "temporal.api.command.v1.Command", + "temporal.api.command.v1.RecordMarkerCommandAttributes"), + entered); + // Each command is entered with scope reset to null (restored between siblings), then its own + // type flows down into its attributes message. + assertEquals( + Arrays.asList( + null, + null, + CommandType.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK, + null, + CommandType.COMMAND_TYPE_COMPLETE_WORKFLOW_EXECUTION, + null, + CommandType.COMMAND_TYPE_RECORD_MARKER), + contextOnEnter); + } + + @Test + public void messageOnlyVisitorValidatesPerMessageType() { + int maxMemoFields = 2; + MessageVisitorOptions opts = + MessageVisitorOptions.newBuilder( + (current, msg) -> { + if (msg instanceof Memo.Builder + && ((Memo.Builder) msg).getFieldsCount() > maxMemoFields) { + throw new TestVisitorException("too many memo fields"); + } + return current; + }) + .build(); + + Memo ok = Memo.newBuilder().putFields("a", p("1")).putFields("b", p("2")).build(); + MessageVisitors.visit(ok, opts); // no throw + + Memo tooMany = + Memo.newBuilder() + .putFields("a", p("1")) + .putFields("b", p("2")) + .putFields("c", p("3")) + .build(); + assertThrows(TestVisitorException.class, () -> MessageVisitors.visit(tooMany, opts)); + } + + @Test + public void initialContextObservedAtRoot() { + Memo memo = Memo.newBuilder().putFields("k", p("v")).build(); + List observed = new ArrayList<>(); + MessageVisitors.visit( + memo, + MessageVisitorOptions.newBuilder( + (current, msg) -> { + observed.add(current); + return current; + }) + .setInitialContext("root") + .build()); + assertEquals(Arrays.asList("root"), observed); + } + + @Test + public void rejectsNullMessageVisitor() { + assertThrows(NullPointerException.class, () -> MessageVisitorOptions.newBuilder(null)); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/PayloadVisitorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/PayloadVisitorTest.java new file mode 100644 index 0000000000..d0e36bfc6e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/PayloadVisitorTest.java @@ -0,0 +1,925 @@ +package io.temporal.internal.payload.visitor; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.Any; +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.RecordMarkerCommandAttributes; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.command.v1.ScheduleNexusOperationCommandAttributes; +import io.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.UpsertWorkflowSearchAttributesCommandAttributes; +import io.temporal.api.common.v1.Header; +import io.temporal.api.common.v1.Memo; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.common.v1.SearchAttributes; +import io.temporal.api.enums.v1.CommandType; +import io.temporal.api.failure.v1.ApplicationFailureInfo; +import io.temporal.api.failure.v1.Failure; +import io.temporal.api.protocol.v1.Message; +import io.temporal.api.query.v1.WorkflowQueryResult; +import io.temporal.api.workflowservice.v1.CountWorkflowExecutionsResponse; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.concurrent.BrokenBarrierException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class PayloadVisitorTest { + + static Payload p(String s) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(s)).build(); + } + + static String data(Payload p) { + return p.getData().toStringUtf8(); + } + + static Payloads payloads(String... values) { + Payloads.Builder b = Payloads.newBuilder(); + for (String v : values) { + b.addPayloads(p(v)); + } + return b.build(); + } + + /** A synchronous visit; {@link #toAsync} adapts it to the asynchronous {@link PayloadVisitor}. */ + @FunctionalInterface + interface SyncPayloadVisitor { + List visit(Object ctx, List payloads); + } + + static PayloadVisitor toAsync(SyncPayloadVisitor visitor) { + return (ctx, pls) -> CompletableFuture.completedFuture(visitor.visit(ctx, pls)); + } + + /** + * Records every payload seen (in order) and the number of visit calls, leaving payloads + * unchanged. + */ + static final class CollectingVisitor implements SyncPayloadVisitor { + final List seen = Collections.synchronizedList(new ArrayList<>()); + final AtomicInteger visits = new AtomicInteger(); + + @Override + public List visit(Object ctx, List payloads) { + visits.incrementAndGet(); + for (Payload p : payloads) { + seen.add(data(p)); + } + return payloads; + } + } + + static PayloadVisitorOptions options(SyncPayloadVisitor visitor) { + return PayloadVisitorOptions.newBuilder(toAsync(visitor)).build(); + } + + /** + * Blocks and unwraps the {@link CompletionException} {@code join} adds, exposing the original. + */ + static T visit( + T message, PayloadVisitorOptions options) { + return join(PayloadVisitors.visit(message, options)); + } + + static void visit(com.google.protobuf.Message.Builder builder, PayloadVisitorOptions options) { + join(PayloadVisitors.visit(builder, options)); + } + + private static V join(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw e; + } + } + + static Command activity(String activityId, Payloads input) { + return Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK) + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setActivityId(activityId) + .setInput(input)) + .build(); + } + + /** Backs the simulated async visitors in the concurrency tests; the engine needs no executor. */ + private ExecutorService executor; + + @Before + public void setUpExecutor() { + executor = Executors.newCachedThreadPool(); + } + + @After + public void tearDownExecutor() { + executor.shutdownNow(); + } + + @Test + public void visitsAndMutatesAllPayloads() { + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands(activity("a", payloads("one", "two"))) + .addCommands(activity("b", payloads("three"))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + RespondWorkflowTaskCompletedRequest unchanged = visit(request, options(counter)); + assertEquals(java.util.Arrays.asList("one", "two", "three"), counter.seen); + // Two Payloads sequences (one per command's input): two visits, three payloads. + assertEquals(2, counter.visits.get()); + assertEquals(request, unchanged); + + // Mutating: uppercase every payload's data. + RespondWorkflowTaskCompletedRequest mutated = + visit( + request, + options( + (ctx, pls) -> + pls.stream() + .map( + p -> + p.toBuilder() + .setData(ByteString.copyFromUtf8(data(p).toUpperCase())) + .build()) + .collect(Collectors.toList()))); + assertEquals( + payloads("ONE", "TWO"), + mutated.getCommands(0).getScheduleActivityTaskCommandAttributes().getInput()); + assertEquals( + payloads("THREE"), + mutated.getCommands(1).getScheduleActivityTaskCommandAttributes().getInput()); + } + + @Test + public void visitsSinglePayloadField() { + Command command = + Command.newBuilder() + .setScheduleNexusOperationCommandAttributes( + ScheduleNexusOperationCommandAttributes.newBuilder().setInput(p("nexus"))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + Command result = visit(command, options(counter)); + assertEquals(Collections.singletonList("nexus"), counter.seen); + + // A single-payload field can be replaced with one payload. + Command observed = + visit(command, options((ctx, pls) -> Collections.singletonList(p("replaced")))); + assertEquals( + "replaced", + observed.getScheduleNexusOperationCommandAttributes().getInput().getData().toStringUtf8()); + assertEquals(Collections.singletonList("nexus"), counter.seen); + assertEquals(command, result); + } + + @Test + public void singlePayloadFieldRequiresExactlyOnePayload() { + Command command = + Command.newBuilder() + .setScheduleNexusOperationCommandAttributes( + ScheduleNexusOperationCommandAttributes.newBuilder().setInput(p("nexus"))) + .build(); + + // Returning zero payloads for a single-payload field is rejected. + assertThrows( + IllegalStateException.class, + () -> visit(command, options((ctx, pls) -> Collections.emptyList()))); + + // Returning more than one payload for a single-payload field is rejected. + assertThrows( + IllegalStateException.class, + () -> visit(command, options((ctx, pls) -> java.util.Arrays.asList(p("a"), p("b"))))); + } + + @Test + public void visitsMapOfPayloads() { + Command command = + Command.newBuilder() + .setUpsertWorkflowSearchAttributesCommandAttributes( + UpsertWorkflowSearchAttributesCommandAttributes.newBuilder() + .setSearchAttributes( + SearchAttributes.newBuilder() + .putIndexedFields("k1", p("v1")) + .putIndexedFields("k2", p("v2")))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(command, options(counter)); + // A map is visited once per entry; map iteration order is unspecified, so + // assert the exact visit count and the value set rather than positional offsets. + assertEquals(2, counter.visits.get()); + assertEquals(new HashSet<>(java.util.Arrays.asList("v1", "v2")), new HashSet<>(counter.seen)); + + Command mutated = + visit(command, options((ctx, pls) -> Collections.singletonList(p(data(pls.get(0)) + "!")))); + Map fields = + mutated + .getUpsertWorkflowSearchAttributesCommandAttributes() + .getSearchAttributes() + .getIndexedFieldsMap(); + assertEquals("v1!", data(fields.get("k1"))); + assertEquals("v2!", data(fields.get("k2"))); + } + + @Test + public void visitsMapOfPayloadsSequences() { + Command command = + Command.newBuilder() + .setRecordMarkerCommandAttributes( + RecordMarkerCommandAttributes.newBuilder() + .setMarkerName("m") + .putDetails("d1", payloads("x", "y"))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(command, options(counter)); + // A single map entry is one sequence: one visit, two payloads. + assertEquals(1, counter.visits.get()); + assertEquals(java.util.Arrays.asList("x", "y"), counter.seen); + } + + @Test + public void visitsMapOfMessages() { + // RespondWorkflowTaskCompletedRequest.query_results is map, whose + // values carry payloads: exercises the map-of-messages path (rebuild value + write back). + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .putQueryResults( + "q1", WorkflowQueryResult.newBuilder().setAnswer(payloads("a")).build()) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(request, options(counter)); + assertEquals(Collections.singletonList("a"), counter.seen); + + RespondWorkflowTaskCompletedRequest mutated = + visit(request, options((ctx, pls) -> Collections.singletonList(p(data(pls.get(0)) + "!")))); + assertEquals(payloads("a!"), mutated.getQueryResultsMap().get("q1").getAnswer()); + } + + @Test + public void visitsRepeatedPayloadField() { + // CountWorkflowExecutionsResponse.AggregationGroup.group_values is a bare repeated Payload. + CountWorkflowExecutionsResponse response = + CountWorkflowExecutionsResponse.newBuilder() + .addGroups( + CountWorkflowExecutionsResponse.AggregationGroup.newBuilder() + .addGroupValues(p("g1")) + .addGroupValues(p("g2"))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(response, options(counter)); + // A repeated Payload is one sequence: one visit, two payloads. + assertEquals(1, counter.visits.get()); + assertEquals(java.util.Arrays.asList("g1", "g2"), counter.seen); + + CountWorkflowExecutionsResponse mutated = + visit( + response, + options( + (ctx, pls) -> + pls.stream().map(pl -> p(data(pl) + "!")).collect(Collectors.toList()))); + assertEquals("g1!", data(mutated.getGroups(0).getGroupValues(0))); + assertEquals("g2!", data(mutated.getGroups(0).getGroupValues(1))); + } + + @Test + public void visitsPayloadsAsRoot() { + Payloads root = payloads("a", "b"); + + CollectingVisitor counter = new CollectingVisitor(); + Payloads unchanged = visit(root, options(counter)); + // The repeated Payload inside Payloads is one sequence: one visit, two payloads. + assertEquals(1, counter.visits.get()); + assertEquals(java.util.Arrays.asList("a", "b"), counter.seen); + assertEquals(root, unchanged); + + Payloads mutated = visit(root, options((ctx, pls) -> Collections.singletonList(p("x")))); + assertEquals(payloads("x"), mutated); + } + + @Test + public void visitsBuilderInPlace() { + RespondWorkflowTaskCompletedRequest.Builder builder = + RespondWorkflowTaskCompletedRequest.newBuilder().addCommands(activity("a", payloads("x"))); + + visit(builder, options((ctx, pls) -> Collections.singletonList(p("y")))); + + assertEquals( + payloads("y"), + builder.getCommands(0).getScheduleActivityTaskCommandAttributes().getInput()); + } + + @Test + public void visitCountDistinguishesSequencesFromMapEntries() { + // A Memo with two fields is visited once per entry: two visits, two payloads. + Memo memo = Memo.newBuilder().putFields("a", p("1")).putFields("b", p("2")).build(); + CollectingVisitor memoVisitor = new CollectingVisitor(); + visit(memo, options(memoVisitor)); + // Memo fields are a map (unspecified order): assert visit count and the value set. + assertEquals(2, memoVisitor.visits.get()); + assertEquals(new HashSet<>(java.util.Arrays.asList("1", "2")), new HashSet<>(memoVisitor.seen)); + + // An activity command with two inputs is one Payloads sequence: one visit, two payloads. + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder().setInput(payloads("1", "2"))) + .build(); + CollectingVisitor inputVisitor = new CollectingVisitor(); + visit(command, options(inputVisitor)); + // A Payloads sequence preserves order, so assert the exact ordered values. + assertEquals(1, inputVisitor.visits.get()); + assertEquals(java.util.Arrays.asList("1", "2"), inputVisitor.seen); + } + + @Test + public void visitsHeaders() { + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setInput(payloads("in")) + .setHeader(Header.newBuilder().putFields("h", p("hv")))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(command, options(counter)); + // With headers not skipped (the default), the header payload is visited too. + assertEquals(new HashSet<>(java.util.Arrays.asList("in", "hv")), new HashSet<>(counter.seen)); + } + + @Test + public void visitsSearchAttributes() { + Command command = + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setInput(payloads("in")) + .setSearchAttributes( + SearchAttributes.newBuilder().putIndexedFields("k", p("v")))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(command, options(counter)); + // With search attributes not skipped (the default), the search attribute payload is visited. + assertEquals(new HashSet<>(java.util.Arrays.asList("in", "v")), new HashSet<>(counter.seen)); + } + + @Test + public void skipsHeaders() { + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setInput(payloads("in")) + .setHeader(Header.newBuilder().putFields("h", p("hv")))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(command, PayloadVisitorOptions.newBuilder(toAsync(counter)).setSkipHeaders(true).build()); + // The header payload is skipped; other payloads are still visited. + assertEquals(Collections.singletonList("in"), counter.seen); + } + + @Test + public void skipsSearchAttributes() { + Command command = + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setInput(payloads("in")) + .setSearchAttributes( + SearchAttributes.newBuilder().putIndexedFields("k", p("v")))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit( + command, + PayloadVisitorOptions.newBuilder(toAsync(counter)).setSkipSearchAttributes(true).build()); + // The search attribute payload is skipped; other payloads are still visited. + assertEquals(Collections.singletonList("in"), counter.seen); + } + + @Test + public void contextScopesPerCommand() { + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands(activity("a", payloads("act"))) + .addCommands( + Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION) + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setInput(payloads("child"))) + .build()) + .build(); + + // Default concurrency (1) visits the two repeated commands in declaration order. + List dataOrder = new ArrayList<>(); + List contextOrder = new ArrayList<>(); + PayloadVisitorOptions opts = + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> { + for (Payload p : pls) { + dataOrder.add(data(p)); + contextOrder.add(ctx); + } + return CompletableFuture.completedFuture(pls); + }) + .setMessageVisitor( + (current, msg) -> + msg instanceof Command.Builder + ? ((Command.Builder) msg).getCommandType() + : current) + .build(); + + visit(request, opts); + assertEquals(java.util.Arrays.asList("act", "child"), dataOrder); + assertEquals( + java.util.Arrays.asList( + CommandType.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK, + CommandType.COMMAND_TYPE_START_CHILD_WORKFLOW_EXECUTION), + contextOrder); + } + + @Test + public void initialContextUsedWhenNoMessageVisitor() { + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder().setInput(payloads("x"))) + .build(); + List observed = new ArrayList<>(); + PayloadVisitorOptions opts = + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> { + observed.add(ctx); + return CompletableFuture.completedFuture(pls); + }) + .setInitialContext("root") + .build(); + visit(command, opts); + assertEquals(Collections.singletonList("root"), observed); + } + + @Test + public void limitsStyleValidatorComposesBothSeams() { + // The payload-limits feature is a read-only validator using both seams of PayloadVisitors: + // - per-payload (blob size) on the payload seam + // - per-message (e.g. memo field count) on the message seam + int blobLimit = 8; + int maxMemoFields = 2; + + PayloadVisitorOptions validator = + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> { + for (Payload pl : pls) { + if (pl.getData().size() > blobLimit) { + throw new TestVisitorException("blob too large"); + } + } + return CompletableFuture.completedFuture(pls); // read-only + }) + .setMessageVisitor( + (current, msg) -> { + if (msg instanceof Memo.Builder + && ((Memo.Builder) msg).getFieldsCount() > maxMemoFields) { + throw new TestVisitorException("too many memo fields"); + } + return current; + }) + .build(); + + // Within both limits (small input, small memo): both seams run, neither trips. + RespondWorkflowTaskCompletedRequest ok = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setInput(payloads("small")) + .setMemo( + Memo.newBuilder().putFields("a", p("1")).putFields("b", p("2"))))) + .build(); + visit(ok, validator); + + // Oversized blob trips the payload seam. + RespondWorkflowTaskCompletedRequest bigBlob = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands(activity("a", payloads("way-too-large-payload"))) + .build(); + TestVisitorException blobError = + assertThrows(TestVisitorException.class, () -> visit(bigBlob, validator)); + assertEquals("blob too large", blobError.getMessage()); + + // Too many memo fields trips the message seam (its payloads are individually small). + RespondWorkflowTaskCompletedRequest bigMemo = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setMemo( + Memo.newBuilder() + .putFields("a", p("1")) + .putFields("b", p("2")) + .putFields("c", p("3"))))) + .build(); + TestVisitorException memoError = + assertThrows(TestVisitorException.class, () -> visit(bigMemo, validator)); + assertEquals("too many memo fields", memoError.getMessage()); + } + + @Test + public void visitsNestedFailureCauses() { + // Failure.cause is itself a Failure, so the visitor recurses into its own type; payloads at + // each level of the cause chain must be visited. + Failure failure = + Failure.newBuilder() + .setMessage("outer") + .setApplicationFailureInfo( + ApplicationFailureInfo.newBuilder().setDetails(payloads("d1"))) + .setCause( + Failure.newBuilder() + .setMessage("inner") + .setApplicationFailureInfo( + ApplicationFailureInfo.newBuilder().setDetails(payloads("d2")))) + .build(); + + CollectingVisitor counter = new CollectingVisitor(); + visit(failure, options(counter)); + assertEquals(2, counter.seen.size()); + assertTrue(counter.seen.contains("d1")); + assertTrue(counter.seen.contains("d2")); + } + + @Test + public void roundTripsPayloadInsideAny() throws Exception { + Memo memo = Memo.newBuilder().putFields("k", p("inside-any")).build(); + Message message = Message.newBuilder().setBody(Any.pack(memo)).build(); + + CollectingVisitor counter = new CollectingVisitor(); + Message result = visit(message, options(counter)); + assertEquals(Collections.singletonList("inside-any"), counter.seen); + + // Mutating through the Any re-packs correctly. + Message mutated = + visit(message, options((ctx, pls) -> Collections.singletonList(p("changed")))); + Memo unpacked = mutated.getBody().unpack(Memo.class); + assertEquals("changed", data(unpacked.getFieldsMap().get("k"))); + // Unrelated content unchanged. + assertEquals(result.getBody().getTypeUrl(), mutated.getBody().getTypeUrl()); + } + + @Test + public void leavesUnknownAnyUntouched() throws Exception { + // An Any whose type is not in the registry is left as-is. + Message message = + Message.newBuilder() + .setBody( + Any.newBuilder() + .setTypeUrl("type.googleapis.com/some.unknown.Type") + .setValue(ByteString.copyFromUtf8("opaque"))) + .build(); + CollectingVisitor counter = new CollectingVisitor(); + Message result = visit(message, options(counter)); + assertTrue(counter.seen.isEmpty()); + assertEquals(message, result); + } + + @Test + public void messageWithoutPayloadsReturnedUnchanged() { + Command command = + Command.newBuilder() + .setCancelWorkflowExecutionCommandAttributes( + io.temporal.api.command.v1.CancelWorkflowExecutionCommandAttributes.newBuilder()) + .build(); + CollectingVisitor counter = new CollectingVisitor(); + Command result = visit(command, options(counter)); + assertTrue(counter.seen.isEmpty()); + assertEquals(command, result); + } + + @Test + public void propagatesVisitorError() { + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands(activity("a", payloads("x"))) + .build(); + TestVisitorException boom = new TestVisitorException("boom"); + TestVisitorException thrown = + assertThrows( + TestVisitorException.class, + () -> + visit( + request, + options( + (ctx, pls) -> { + throw boom; + }))); + assertSame(boom, thrown); + } + + @Test + public void messageVisitorErrorPropagates() { + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder().setInput(payloads("x"))) + .build(); + TestVisitorException boom = new TestVisitorException("message visitor boom"); + TestVisitorException thrown = + assertThrows( + TestVisitorException.class, + () -> + visit( + command, + PayloadVisitorOptions.newBuilder(toAsync((ctx, pls) -> pls)) + .setMessageVisitor( + (current, msg) -> { + throw boom; + }) + .build())); + assertSame(boom, thrown); + } + + @Test + public void registryCoversPayloadBearingTypesAndExcludesOthers() { + Map registry = GeneratedPayloadVisitor.REGISTRY; + // Representative payload-bearing types must be present. + for (String fullName : + new String[] { + "temporal.api.command.v1.Command", + "temporal.api.command.v1.ScheduleActivityTaskCommandAttributes", + "temporal.api.command.v1.RecordMarkerCommandAttributes", + "temporal.api.failure.v1.Failure", + "temporal.api.common.v1.Memo", + "temporal.api.common.v1.Header", + "temporal.api.common.v1.SearchAttributes", + "temporal.api.common.v1.Payloads", + "temporal.api.protocol.v1.Message", + "temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest" + }) { + assertTrue("missing visitor for " + fullName, registry.containsKey(fullName)); + } + // Types without reachable payloads must be excluded. + assertFalse(registry.containsKey("temporal.api.common.v1.Payload")); + assertFalse(registry.containsKey("temporal.api.common.v1.WorkflowExecution")); + assertFalse(registry.containsKey("google.protobuf.DescriptorProto")); + } + + @Test + public void rejectsNullPayloadVisitor() { + assertThrows(NullPointerException.class, () -> PayloadVisitorOptions.newBuilder(null)); + } + + @Test + public void nullReturnFromVisitorFails() { + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder().setInput(payloads("x"))) + .build(); + assertThrows(IllegalStateException.class, () -> visit(command, options((ctx, pls) -> null))); + } + + // --- Concurrency --- + // + // These tests simulate an I/O-backed visitor with futures completed on a test-local thread pool, + // so concurrency produces real overlap. + + @Test + public void rejectsConcurrencyBelowOne() { + assertThrows( + IllegalArgumentException.class, + () -> + PayloadVisitorOptions.newBuilder(toAsync((ctx, pls) -> pls)).setConcurrency(0).build()); + } + + /** + * A request with {@code n} activity commands, each carrying one distinct single-payload input. + */ + static RespondWorkflowTaskCompletedRequest requestWithInputs(int n) { + RespondWorkflowTaskCompletedRequest.Builder b = + RespondWorkflowTaskCompletedRequest.newBuilder(); + for (int i = 0; i < n; i++) { + b.addCommands(activity("a" + i, payloads("p" + i))); + } + return b.build(); + } + + @Test + public void concurrencyEqualToWorkAllowsFullOverlap() { + int n = 4; + RespondWorkflowTaskCompletedRequest request = requestWithInputs(n); + CyclicBarrier barrier = new CyclicBarrier(n); + + // All n visits must reach the barrier at once; with fewer than n in flight it would time out. + visit( + request, + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> + CompletableFuture.supplyAsync( + () -> { + try { + barrier.await(5, TimeUnit.SECONDS); + } catch (InterruptedException + | BrokenBarrierException + | TimeoutException e) { + throw new RuntimeException(e); + } + return pls; + }, + executor)) + .setConcurrency(n) + .build()); + } + + @Test + public void boundedConcurrencyNeverExceedsLimit() { + int n = 8; + int limit = 3; + RespondWorkflowTaskCompletedRequest request = requestWithInputs(n); + + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + + visit( + request, + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> + CompletableFuture.supplyAsync( + () -> { + int now = inFlight.incrementAndGet(); + maxInFlight.accumulateAndGet(now, Math::max); + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + inFlight.decrementAndGet(); + return pls; + }, + executor)) + .setConcurrency(limit) + .build()); + + assertTrue( + "max in-flight " + maxInFlight.get() + " > limit " + limit, maxInFlight.get() <= limit); + assertTrue("expected some overlap, got " + maxInFlight.get(), maxInFlight.get() > 1); + } + + @Test + public void sequentialConcurrencyVisitsOneAtATimeInOrder() { + int n = 5; + RespondWorkflowTaskCompletedRequest request = requestWithInputs(n); + + AtomicInteger inFlight = new AtomicInteger(); + AtomicInteger maxInFlight = new AtomicInteger(); + List order = Collections.synchronizedList(new ArrayList<>()); + + // Concurrency 1 awaits each visit before the next, so even async visits run one at a time. + visit( + request, + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> + CompletableFuture.supplyAsync( + () -> { + int now = inFlight.incrementAndGet(); + maxInFlight.accumulateAndGet(now, Math::max); + order.add(pls.get(0).getData().toStringUtf8()); + inFlight.decrementAndGet(); + return pls; + }, + executor)) + .setConcurrency(1) + .build()); + + assertEquals(1, maxInFlight.get()); + List expected = new ArrayList<>(); + for (int i = 0; i < n; i++) { + expected.add("p" + i); + } + assertEquals(expected, order); + } + + @Test + public void concurrentVisitorErrorPropagates() { + RespondWorkflowTaskCompletedRequest request = requestWithInputs(8); + TestVisitorException boom = new TestVisitorException("boom"); + // The failure arrives as an exceptionally-completed future; it must surface unchanged. + TestVisitorException thrown = + assertThrows( + TestVisitorException.class, + () -> + visit( + request, + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> + CompletableFuture.supplyAsync( + () -> { + if (pls.get(0).getData().toStringUtf8().equals("p5")) { + throw boom; + } + return pls; + }, + executor)) + .setConcurrency(4) + .build())); + assertSame(boom, thrown); + } + + @Test + public void mutationsAppliedCorrectlyUnderConcurrency() { + int n = 16; + RespondWorkflowTaskCompletedRequest request = requestWithInputs(n); + // Visits complete out of order, but write-backs apply in walk order, so each input is correct. + RespondWorkflowTaskCompletedRequest mutated = + visit( + request, + PayloadVisitorOptions.newBuilder( + (ctx, pls) -> + CompletableFuture.supplyAsync( + () -> { + Payload p = pls.get(0); + return Collections.singletonList( + p.toBuilder() + .setData( + ByteString.copyFromUtf8(p.getData().toStringUtf8() + "!")) + .build()); + }, + executor)) + .setConcurrency(8) + .build()); + for (int i = 0; i < n; i++) { + assertEquals( + "p" + i + "!", + mutated + .getCommands(i) + .getScheduleActivityTaskCommandAttributes() + .getInput() + .getPayloads(0) + .getData() + .toStringUtf8()); + } + } + + @Test + public void entryPointReturnsPendingFutureWhileVisitInFlight() throws Exception { + RespondWorkflowTaskCompletedRequest request = requestWithInputs(1); + // A visit future we complete by hand, to observe the traversal future's state meanwhile. + CompletableFuture> gate = new CompletableFuture<>(); + + CompletableFuture result = + PayloadVisitors.visit( + request, PayloadVisitorOptions.newBuilder((ctx, pls) -> gate).build()); + + // The caller is not blocked: the traversal future is pending while the visit is outstanding. + assertFalse(result.isDone()); + + gate.complete(Collections.singletonList(p("done"))); + RespondWorkflowTaskCompletedRequest mutated = result.get(5, TimeUnit.SECONDS); + assertEquals( + "done", + mutated + .getCommands(0) + .getScheduleActivityTaskCommandAttributes() + .getInput() + .getPayloads(0) + .getData() + .toStringUtf8()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/TestVisitorException.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/TestVisitorException.java new file mode 100644 index 0000000000..ecbfab7829 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/visitor/TestVisitorException.java @@ -0,0 +1,12 @@ +package io.temporal.internal.payload.visitor; + +/** + * Exception thrown only by test visitor/message callbacks. Using a dedicated type keeps "the + * visitor threw" assertions from being satisfied by an unrelated {@link IllegalStateException} that + * production code might raise. + */ +class TestVisitorException extends RuntimeException { + TestVisitorException(String message) { + super(message); + } +} From 6e2386147bace739f0061ce8bc00d204b36c73f7 Mon Sep 17 00:00:00 2001 From: Dan Plyukhin Date: Tue, 30 Jun 2026 14:53:31 -0400 Subject: [PATCH 024/107] Fix deadlock detection error message to reflect user-configured timeout (#2934) --- .../sync/PotentialDeadlockException.java | 17 +++++++++++++++-- .../internal/sync/WorkflowThreadContext.java | 6 ++++-- .../deadlockdetector/DeadlockDetectorTest.java | 2 ++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java index a5d0d4098e..4f907783b0 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java @@ -26,17 +26,30 @@ public class PotentialDeadlockException extends RuntimeException { * @param threadName name of the thread that is in a potential deadlock state * @param workflowThreadContext context of the thread that is in a potential deadlock state * @param detectionTimestamp a timestamp the deadlock was detected + * @param timeoutMillis configured deadlock detection timeout in milliseconds */ PotentialDeadlockException( - String threadName, WorkflowThreadContext workflowThreadContext, long detectionTimestamp) { + String threadName, + WorkflowThreadContext workflowThreadContext, + long detectionTimestamp, + long timeoutMillis) { super( "[TMPRL1101] Potential deadlock detected. Workflow thread \"" + threadName - + "\" didn't yield control for over a second."); + + "\" didn't yield control for over " + + formatTimeout(timeoutMillis) + + "."); this.workflowThreadContext = workflowThreadContext; this.detectionTimestamp = detectionTimestamp; } + private static String formatTimeout(long timeoutMillis) { + if (timeoutMillis % 1000 == 0) { + return timeoutMillis / 1000 + "s"; + } + return timeoutMillis + "ms"; + } + /** * @param triggerThreadStackTrace stacktrace of the thread that triggered the Deadlock Detector * @param otherThreadsDump stack dump of other threads of the workflow excluding the thread that diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java index 744ce27af4..49dd9f9468 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java @@ -238,14 +238,16 @@ public boolean runUntilBlocked(long deadlockDetectionTimeoutMs) { if (WorkflowThreadScheduler.WaitForYieldResult.DEADLOCK_DETECTED.equals(yieldResult)) { long detectionTimestamp = System.currentTimeMillis(); if (currentThread != null) { - throw new PotentialDeadlockException(currentThread.getName(), this, detectionTimestamp); + throw new PotentialDeadlockException( + currentThread.getName(), this, detectionTimestamp, deadlockDetectionTimeoutMs); } else { // This should never happen. // We clear currentThread only after setting the status to DONE. // And we check for it by the status condition check after waking up on the condition // and acquiring the lock back log.warn("Illegal State: WorkflowThreadContext has no currentThread in {} state", status); - throw new PotentialDeadlockException("UnknownThread", this, detectionTimestamp); + throw new PotentialDeadlockException( + "UnknownThread", this, detectionTimestamp, deadlockDetectionTimeoutMs); } } Preconditions.checkState( diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/deadlockdetector/DeadlockDetectorTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/deadlockdetector/DeadlockDetectorTest.java index 0714947cd7..3439b7cb83 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/deadlockdetector/DeadlockDetectorTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/deadlockdetector/DeadlockDetectorTest.java @@ -78,6 +78,7 @@ public void testDefaultDeadlockDetector() { failure = failure.getCause(); } assertTrue(failure.getMessage().contains("Potential deadlock detected")); + assertTrue(failure.getMessage().contains("1s")); assertTrue(failure.getMessage().contains("Workflow.await")); } } @@ -100,6 +101,7 @@ public void testSetDeadlockDetector() { failure = failure.getCause(); } assertTrue(failure.getMessage().contains("Potential deadlock detected")); + assertTrue(failure.getMessage().contains("500ms")); assertTrue(failure.getMessage().contains("Workflow.await")); } } From 93b84f2638004bd45a61cc825290d97eda7d866e Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 1 Jul 2026 11:33:20 -0700 Subject: [PATCH 025/107] Add Standalone Nexus Operation links (#2932) * Add SANO links * Test more SANO links * Clean up * Fix link converter * Use BeforeClass for checking for SANO support in tests --------- Co-authored-by: Alex Mazzeo --- .../internal/common/InternalUtils.java | 18 +- .../internal/common/LinkConverter.java | 107 ++++++++ .../nexus/NexusStartWorkflowHelper.java | 13 +- .../internal/nexus/NexusTaskHandlerImpl.java | 23 +- .../StandaloneNexusBackingWorkflowTest.java | 241 ++++++++++++++++++ .../StandaloneNexusClientCancelTest.java | 141 ---------- .../StandaloneNexusSignalLinkingTest.java | 210 +++++++++++++++ .../internal/common/LinkConverterTest.java | 202 +++++++++++++++ 8 files changed, 775 insertions(+), 180 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusBackingWorkflowTest.java delete mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSignalLinkingTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java index 4c5ec49b12..0886802de9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java @@ -22,14 +22,11 @@ import io.temporal.internal.nexus.OperationTokenUtil; import java.util.*; import java.util.stream.Collectors; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** Utility functions shared by the implementation code. */ public final class InternalUtils { public static String TEMPORAL_RESERVED_PREFIX = "__temporal_"; - private static final Logger log = LoggerFactory.getLogger(InternalUtils.class); private static String QUERY_TYPE_STACK_TRACE = "__stack_trace"; private static String ENHANCED_QUERY_TYPE_STACK_TRACE = "__enhanced_stack_trace"; @@ -93,21 +90,12 @@ public static NexusWorkflowStarter createNexusBoundStub( ? null : request.getLinks().stream() .map( - (link) -> { - if (io.temporal.api.common.v1.Link.WorkflowEvent.getDescriptor() - .getFullName() - .equals(link.getType())) { - io.temporal.api.nexus.v1.Link nexusLink = + (link) -> + LinkConverter.nexusLinkToLink( io.temporal.api.nexus.v1.Link.newBuilder() .setType(link.getType()) .setUrl(link.getUri().toString()) - .build(); - return LinkConverter.nexusLinkToWorkflowEvent(nexusLink); - } else { - log.warn("ignoring unsupported link data type: {}", link.getType()); - return null; - } - }) + .build())) .filter(Objects::nonNull) .collect(Collectors.toList()); WorkflowOptions.Builder nexusWorkflowOptions = 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 6d270eec63..1eef63af25 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 @@ -20,6 +20,8 @@ public class LinkConverter { private static final Logger log = LoggerFactory.getLogger(LinkConverter.class); private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history"; + private static final String nexusOperationLinkPathFormat = + "temporal:///namespaces/%s/nexus-operations/%s/%s/details"; private static final String linkReferenceTypeKey = "referenceType"; private static final String linkEventIDKey = "eventID"; private static final String linkEventTypeKey = "eventType"; @@ -29,6 +31,10 @@ public class LinkConverter { Link.WorkflowEvent.EventReference.getDescriptor().getName(); private static final String requestIDReferenceType = Link.WorkflowEvent.RequestIdReference.getDescriptor().getName(); + private static final String workflowEventLinkType = + Link.WorkflowEvent.getDescriptor().getFullName(); + private static final String nexusOperationLinkType = + Link.NexusOperation.getDescriptor().getFullName(); public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { try { @@ -160,6 +166,107 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL return link.build(); } + /** + * Dispatches on the oneof variant of {@code commonLink} and converts to the matching {@link + * io.temporal.api.nexus.v1.Link}. Returns {@code null} if no variant is set or encoding fails. + */ + public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { + if (commonLink.hasWorkflowEvent()) { + return workflowEventToNexusLink(commonLink.getWorkflowEvent()); + } + if (commonLink.hasNexusOperation()) { + return nexusOperationToNexusLink(commonLink.getNexusOperation()); + } + return null; + } + + /** + * Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()} and converts to the matching + * {@link Link} variant. Returns {@code null} for unknown or unparseable types. + */ + public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) { + String type = nexusLink.getType(); + if (workflowEventLinkType.equals(type)) { + return nexusLinkToWorkflowEvent(nexusLink); + } + if (nexusOperationLinkType.equals(type)) { + return nexusLinkToNexusOperation(nexusLink); + } + log.warn("ignoring unsupported nexus link type: {}", type); + return null; + } + + public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) { + try { + String url = + String.format( + nexusOperationLinkPathFormat, + URLEncoder.encode(no.getNamespace(), StandardCharsets.UTF_8.toString()), + // See the WorkflowId comment in workflowEventToNexusLink for why '+' is rewritten to + // '%20'. OperationId is user-supplied and can legally contain spaces. + URLEncoder.encode(no.getOperationId(), StandardCharsets.UTF_8.toString()) + .replace("+", "%20"), + URLEncoder.encode(no.getRunId(), StandardCharsets.UTF_8.toString())); + return io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl(url) + .setType(nexusOperationLinkType) + .build(); + } catch (Exception e) { + log.error("Failed to encode Nexus operation link URL", e); + } + return null; + } + + public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) { + if (!nexusOperationLinkType.equals(nexusLink.getType())) { + log.error( + "Failed to parse Nexus link URL: cannot parse link type {} to {}", + nexusLink.getType(), + nexusOperationLinkType); + return null; + } + Link.Builder link = Link.newBuilder(); + try { + URI uri = new URI(nexusLink.getUrl()); + + if (!"temporal".equals(uri.getScheme())) { + log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); + return null; + } + + StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); + if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens() || !st.nextToken().equals("nexus-operations")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String operationId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens()) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + + link.setNexusOperation( + Link.NexusOperation.newBuilder() + .setNamespace(namespace) + .setOperationId(operationId) + .setRunId(runId)); + } catch (Exception e) { + log.error("Failed to parse Nexus link URL", e); + return null; + } + return link.build(); + } + private static Map parseQueryParams(URI uri) throws UnsupportedEncodingException { final String query = uri.getQuery(); if (query == null || query.isEmpty()) { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java index 5cd24018f9..de1da6c7a0 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartWorkflowHelper.java @@ -1,5 +1,6 @@ package io.temporal.internal.nexus; +import static io.temporal.internal.common.LinkConverter.linkToNexusLink; import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; import static io.temporal.internal.common.NexusUtil.nexusProtoLinkToLink; @@ -50,12 +51,10 @@ public static NexusStartWorkflowResponse startWorkflowAndAttachLinks( // If the start workflow response returned a link use it, otherwise // create the link information about the new workflow and return to the caller. - Link.WorkflowEvent workflowEventLink = - nexusCtx.getStartWorkflowResponseLink().hasWorkflowEvent() - ? nexusCtx.getStartWorkflowResponseLink().getWorkflowEvent() - : null; - if (workflowEventLink == null) { - workflowEventLink = + io.temporal.api.nexus.v1.Link nexusLink = + linkToNexusLink(nexusCtx.getStartWorkflowResponseLink()); + if (nexusLink == null) { + Link.WorkflowEvent synthesized = Link.WorkflowEvent.newBuilder() .setNamespace(nexusCtx.getNamespace()) .setWorkflowId(workflowExec.getWorkflowId()) @@ -64,8 +63,8 @@ public static NexusStartWorkflowResponse startWorkflowAndAttachLinks( Link.WorkflowEvent.EventReference.newBuilder() .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) .build(); + nexusLink = workflowEventToNexusLink(synthesized); } - io.temporal.api.nexus.v1.Link nexusLink = workflowEventToNexusLink(workflowEventLink); if (nexusLink != null) { try { ctx.addLinks(nexusProtoLinkToLink(nexusLink)); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index 0fac5263a9..adaecca330 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -301,20 +301,13 @@ private StartOperationResponse handleStartOperation( "Invalid link URL: " + link.getUrl(), e); } - // LinkConverter only returns a WorkflowEvent-shaped common.v1.Link; nexus links of - // other shapes (e.g. non-temporal URLs) come back null and are intentionally not - // forwarded onto the RPCs the handler issues, which require the WorkflowEvent - // variant. Log so a debugging session can see what was dropped. - io.temporal.api.common.v1.Link commonLink = - LinkConverter.nexusLinkToWorkflowEvent(link); + // Convert inbound Nexus links into common.v1.Link so RPCs issued by the handler + // (e.g. signal, signalWithStart) can attach them as request links. Both + // WorkflowEvent (caller workflow → nexus op scheduled) and NexusOperation (SANO + // record) variants flow through; other shapes are dropped. + io.temporal.api.common.v1.Link commonLink = LinkConverter.nexusLinkToLink(link); if (commonLink != null) { inboundCommonLinks.add(commonLink); - } else { - log.warn( - "Dropping inbound Nexus link from outbound link propagation: type='{}'," - + " url='{}' (not a parseable temporal WorkflowEvent link)", - link.getType(), - link.getUrl()); } }); CurrentNexusOperationContext.get().setRequestLinks(inboundCommonLinks); @@ -335,11 +328,7 @@ private StartOperationResponse handleStartOperation( List responseLinks = new ArrayList<>(); for (io.temporal.api.common.v1.Link responseLink : CurrentNexusOperationContext.get().getResponseLinks()) { - if (!responseLink.hasWorkflowEvent()) { - continue; - } - io.temporal.api.nexus.v1.Link converted = - LinkConverter.workflowEventToNexusLink(responseLink.getWorkflowEvent()); + io.temporal.api.nexus.v1.Link converted = LinkConverter.linkToNexusLink(responseLink); if (converted != null) { responseLinks.add(converted); } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusBackingWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusBackingWorkflowTest.java new file mode 100644 index 0000000000..dea4b8b8d8 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusBackingWorkflowTest.java @@ -0,0 +1,241 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Callback; +import io.temporal.api.common.v1.Link; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.WorkflowExecutionStartedEventAttributes; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.failure.CanceledFailure; +import io.temporal.nexus.Nexus; +import io.temporal.nexus.WorkflowRunOperation; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +/** + * Behavior tests for standalone Nexus operations whose handler is {@link WorkflowRunOperation}, + * i.e. each SANO is backed by a workflow. Shares one fixture (a workflow that awaits forever) so + * individual tests can exercise cancel propagation, bidirectional link plumbing, and any other + * behavior that depends on the SANO ↔ backing-workflow relationship. + */ +public class StandaloneNexusBackingWorkflowTest { + + static final AtomicReference capturedWorkflowId = new AtomicReference<>(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(CancelTargetWorkflowImpl.class) + .setNexusServiceImplementation(new CancelTargetNexusServiceImpl()) + .build(); + + @BeforeClass + public static void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + SDKTestWorkflowRule.useExternalService); + } + + @Before + public void resetCapturedWorkflowId() { + capturedWorkflowId.set(null); + } + + @Test + public void cancelPropagatesToBackingWorkflow() throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svc = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), CancelTargetNexusService.class.getSimpleName()); + + UntypedNexusOperationHandle handle = + svc.start( + "operation", + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "ignored"); + + String workflowId = waitForWorkflowIdCaptured(Duration.ofSeconds(8)); + + handle.cancel("standalone-client-cancel-test"); + + WorkflowStub stub = testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(workflowId); + try { + stub.getResult(Void.class); + Assert.fail("expected backing workflow to terminate with cancellation"); + } catch (WorkflowFailedException expected) { + Throwable cause = expected.getCause(); + Assert.assertTrue( + "expected cause to be CanceledFailure, got " + + (cause == null ? "null" : cause.getClass().getSimpleName()), + cause instanceof CanceledFailure); + } + } + + /** + * Verifies bidirectional linking between the standalone Nexus operation and the backing workflow + * it starts. + * + *
    + *
  • Forward: a {@code Link.NexusOperation} pointing at the SANO record is attached to the + * backing workflow's WorkflowExecutionStarted completion callback (visible on {@code + * attrs.getCompletionCallbacks(i).getLinks(j)}). + *
  • Backward: a {@code Link.WorkflowEvent} pointing at the backing workflow's + * WorkflowExecutionStarted event is stored on the SANO record's {@code + * NexusOperationExecutionInfo.links} (visible via {@code handle.describe()}). + *
+ */ + @Test + public void linkForwardedToBackingWorkflowCallback() throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svc = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), CancelTargetNexusService.class.getSimpleName()); + + String operationId = UUID.randomUUID().toString(); + UntypedNexusOperationHandle handle = + svc.start( + "operation", + StartNexusOperationOptions.newBuilder() + .setId(operationId) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + "ignored"); + String operationRunId = handle.getNexusOperationRunId(); + Assert.assertNotNull("expected SANO run id to be populated by start", operationRunId); + + String workflowId = waitForWorkflowIdCaptured(Duration.ofSeconds(8)); + + try { + History history = testWorkflowRule.getWorkflowClient().fetchHistory(workflowId).getHistory(); + HistoryEvent startedEvent = history.getEventsList().get(0); + WorkflowExecutionStartedEventAttributes attrs = + startedEvent.getWorkflowExecutionStartedEventAttributes(); + + Link.NexusOperation found = null; + for (Callback cb : attrs.getCompletionCallbacksList()) { + for (Link link : cb.getLinksList()) { + if (link.hasNexusOperation()) { + found = link.getNexusOperation(); + break; + } + } + if (found != null) { + break; + } + } + Assert.assertNotNull( + "expected Link.NexusOperation on a completion callback of the backing workflow", found); + Assert.assertEquals( + testWorkflowRule.getWorkflowClient().getOptions().getNamespace(), found.getNamespace()); + Assert.assertEquals(operationId, found.getOperationId()); + Assert.assertEquals(operationRunId, found.getRunId()); + + // Backward direction: SANO record's info.links should carry a Link.WorkflowEvent referencing + // the backing workflow's WorkflowExecutionStarted event. + NexusOperationExecutionDescription desc = handle.describe(); + Link.WorkflowEvent backLink = null; + for (Link link : desc.getRawInfo().getLinksList()) { + if (link.hasWorkflowEvent() && workflowId.equals(link.getWorkflowEvent().getWorkflowId())) { + backLink = link.getWorkflowEvent(); + break; + } + } + Assert.assertNotNull( + "expected Link.WorkflowEvent on the SANO record's info.links pointing at the backing" + + " workflow", + backLink); + Assert.assertEquals( + testWorkflowRule.getWorkflowClient().getOptions().getNamespace(), + backLink.getNamespace()); + EventType backLinkEventType = + backLink.hasRequestIdRef() + ? backLink.getRequestIdRef().getEventType() + : backLink.getEventRef().getEventType(); + Assert.assertEquals(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED, backLinkEventType); + } finally { + // Workflow awaits forever; cancel so the test rule shuts down cleanly. + handle.cancel("link-test-cleanup"); + } + } + + private static String waitForWorkflowIdCaptured(Duration budget) throws InterruptedException { + long deadlineNanos = System.nanoTime() + budget.toNanos(); + while (capturedWorkflowId.get() == null && System.nanoTime() < deadlineNanos) { + Thread.sleep(100); + } + String id = capturedWorkflowId.get(); + Assert.assertNotNull( + "handler workflow did not start (workflowId never captured) within " + budget, id); + return id; + } + + @WorkflowInterface + public interface CancelTargetWorkflow { + @WorkflowMethod + Void execute(String ignored); + } + + public static class CancelTargetWorkflowImpl implements CancelTargetWorkflow { + @Override + public Void execute(String ignored) { + capturedWorkflowId.set(Workflow.getInfo().getWorkflowId()); + Workflow.await(() -> false); + return null; + } + } + + @Service + public interface CancelTargetNexusService { + @Operation + Void operation(String ignored); + } + + @ServiceImpl(service = CancelTargetNexusService.class) + public static class CancelTargetNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return WorkflowRunOperation.fromWorkflowMethod( + (context, details, input) -> + Nexus.getOperationContext() + .getWorkflowClient() + .newWorkflowStub( + CancelTargetWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("cancel-target-" + details.getRequestId()) + .build()) + ::execute); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java deleted file mode 100644 index ec95b8a47c..0000000000 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusClientCancelTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package io.temporal.client.nexus; - -import static org.junit.Assume.assumeTrue; - -import io.nexusrpc.Operation; -import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; -import io.nexusrpc.handler.ServiceImpl; -import io.temporal.api.nexus.v1.Endpoint; -import io.temporal.client.StartNexusOperationOptions; -import io.temporal.client.UntypedNexusOperationHandle; -import io.temporal.client.UntypedNexusServiceClient; -import io.temporal.client.WorkflowFailedException; -import io.temporal.client.WorkflowOptions; -import io.temporal.client.WorkflowStub; -import io.temporal.failure.CanceledFailure; -import io.temporal.nexus.Nexus; -import io.temporal.nexus.WorkflowRunOperation; -import io.temporal.testing.internal.SDKTestWorkflowRule; -import io.temporal.workflow.Workflow; -import io.temporal.workflow.WorkflowInterface; -import io.temporal.workflow.WorkflowMethod; -import java.time.Duration; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicReference; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; - -/** - * Verifies that {@link UntypedNexusOperationHandle#cancel()} from a standalone client propagates - * through the server to the handler workflow backing a Nexus operation. Mirrors {@code - * sdk-go/test/nexus_test.go TestNexusWorkflowRunOperation}: start a Nexus operation backed by a - * workflow that awaits forever, cancel via the standalone client handle, then assert the backing - * workflow ends with {@link CanceledFailure}. - */ -public class StandaloneNexusClientCancelTest { - - static final AtomicReference capturedWorkflowId = new AtomicReference<>(); - - @Rule - public SDKTestWorkflowRule testWorkflowRule = - SDKTestWorkflowRule.newBuilder() - .setWorkflowTypes(CancelTargetWorkflowImpl.class) - .setNexusServiceImplementation(new CancelTargetNexusServiceImpl()) - .build(); - - @Before - public void requireStandaloneNexusSupportAndReset() { - assumeTrue( - "server does not support standalone Nexus operations", - testWorkflowRule.isUseExternalService()); - capturedWorkflowId.set(null); - } - - @Test - public void cancelPropagatesToBackingWorkflow() throws Exception { - Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); - UntypedNexusServiceClient svc = - testWorkflowRule - .getNexusClient() - .newUntypedNexusServiceClient( - endpoint.getSpec().getName(), CancelTargetNexusService.class.getSimpleName()); - - UntypedNexusOperationHandle handle = - svc.start( - "operation", - StartNexusOperationOptions.newBuilder() - .setId(UUID.randomUUID().toString()) - .setScheduleToCloseTimeout(Duration.ofSeconds(30)) - .build(), - "ignored"); - - String workflowId = waitForWorkflowIdCaptured(Duration.ofSeconds(8)); - - handle.cancel("standalone-client-cancel-test"); - - WorkflowStub stub = testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(workflowId); - try { - stub.getResult(Void.class); - Assert.fail("expected backing workflow to terminate with cancellation"); - } catch (WorkflowFailedException expected) { - Throwable cause = expected.getCause(); - Assert.assertTrue( - "expected cause to be CanceledFailure, got " - + (cause == null ? "null" : cause.getClass().getSimpleName()), - cause instanceof CanceledFailure); - } - } - - private static String waitForWorkflowIdCaptured(Duration budget) throws InterruptedException { - long deadlineNanos = System.nanoTime() + budget.toNanos(); - while (capturedWorkflowId.get() == null && System.nanoTime() < deadlineNanos) { - Thread.sleep(100); - } - String id = capturedWorkflowId.get(); - Assert.assertNotNull( - "handler workflow did not start (workflowId never captured) within " + budget, id); - return id; - } - - @WorkflowInterface - public interface CancelTargetWorkflow { - @WorkflowMethod - Void execute(String ignored); - } - - public static class CancelTargetWorkflowImpl implements CancelTargetWorkflow { - @Override - public Void execute(String ignored) { - capturedWorkflowId.set(Workflow.getInfo().getWorkflowId()); - Workflow.await(() -> false); - return null; - } - } - - @Service - public interface CancelTargetNexusService { - @Operation - Void operation(String ignored); - } - - @ServiceImpl(service = CancelTargetNexusService.class) - public static class CancelTargetNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return WorkflowRunOperation.fromWorkflowMethod( - (context, details, input) -> - Nexus.getOperationContext() - .getWorkflowClient() - .newWorkflowStub( - CancelTargetWorkflow.class, - WorkflowOptions.newBuilder() - .setWorkflowId("cancel-target-" + details.getRequestId()) - .build()) - ::execute); - } - } -} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSignalLinkingTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSignalLinkingTest.java new file mode 100644 index 0000000000..2864ed2ca7 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSignalLinkingTest.java @@ -0,0 +1,210 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Link; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.BatchRequest; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.Nexus; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies bidirectional link propagation when a Nexus operation handler signal-with-starts a + * workflow and the operation itself was kicked off through a standalone Nexus client (SANO). + * + *
    + *
  • Forward: the inbound Nexus task carries a {@code Link.NexusOperation} pointing at the SANO + * record. The handler forwards it onto the signal-with-start request so the callee's {@code + * WorkflowExecutionSignaled} (and {@code WorkflowExecutionStarted}) events carry that + * backlink. + *
  • Backward: the server returns a {@code signal_link} on {@code + * SignalWithStartWorkflowExecutionResponse} pointing at the callee's signal event. The + * handler drains it onto the {@code StartOperationResponse.links}, and the server stores it + * on the SANO record's {@code NexusOperationExecutionInfo.links}. + *
+ * + *

Requires a real server: standalone Nexus operations and {@code EnableCHASMSignalBacklinks} are + * not implemented by the in-memory test server. Test skips locally and runs in CI. + */ +public class StandaloneNexusSignalLinkingTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(SanoSignalCalleeWorkflowImpl.class) + .setNexusServiceImplementation(new SanoSignalingNexusServiceImpl()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations and signal backlinks", + testWorkflowRule.isUseExternalService()); + } + + @Test + public void linksFlowBothDirectionsForSanoTriggeredSignal() throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient svc = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), SanoSignalingNexusService.class.getSimpleName()); + + String operationId = UUID.randomUUID().toString(); + String calleeWorkflowId = "sano-signal-callee-" + UUID.randomUUID(); + UntypedNexusOperationHandle handle = + svc.start( + "operation", + StartNexusOperationOptions.newBuilder() + .setId(operationId) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + calleeWorkflowId); + String operationRunId = handle.getNexusOperationRunId(); + Assert.assertNotNull("expected SANO run id to be populated by start", operationRunId); + + String result = handle.getResult(30, TimeUnit.SECONDS, String.class); + Assert.assertEquals("signaled", result); + + // The callee was signal-with-started by the handler and exits once it sees one signal. + String calleeResult = + testWorkflowRule + .getWorkflowClient() + .newUntypedWorkflowStub(calleeWorkflowId) + .getResult(String.class); + Assert.assertEquals("from-sano", calleeResult); + + // Forward direction: callee's WorkflowExecutionSignaled event carries a Link.NexusOperation + // pointing at the SANO record. The same backlink also lands on WorkflowExecutionStarted via + // the SWS request's links field; assert on the signal event because that's the one the SANO + // backlink is principally for. + History calleeHistory = + testWorkflowRule.getWorkflowClient().fetchHistory(calleeWorkflowId).getHistory(); + HistoryEvent signaledEvent = + findEventOfType(calleeHistory, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED); + Assert.assertNotNull("expected a WorkflowExecutionSignaled event on the callee", signaledEvent); + Link.NexusOperation forwardLink = null; + for (Link link : signaledEvent.getLinksList()) { + if (link.hasNexusOperation()) { + forwardLink = link.getNexusOperation(); + break; + } + } + Assert.assertNotNull( + "expected Link.NexusOperation on callee's WorkflowExecutionSignaled event", forwardLink); + Assert.assertEquals( + testWorkflowRule.getWorkflowClient().getOptions().getNamespace(), + forwardLink.getNamespace()); + Assert.assertEquals(operationId, forwardLink.getOperationId()); + Assert.assertEquals(operationRunId, forwardLink.getRunId()); + + // Backward direction: the signal_link returned on the SWS response is drained onto the + // StartOperationResponse.links by the handler, and the server stores it on the SANO record. + // Read via describe → NexusOperationExecutionInfo.links. + NexusOperationExecutionDescription desc = handle.describe(); + Link backwardLink = null; + for (Link link : desc.getRawInfo().getLinksList()) { + if (link.hasWorkflowEvent() + && calleeWorkflowId.equals(link.getWorkflowEvent().getWorkflowId())) { + backwardLink = link; + break; + } + } + Assert.assertNotNull( + "expected the signal response link on the SANO record's info.links", backwardLink); + EventType backwardLinkEventType = + backwardLink.getWorkflowEvent().hasRequestIdRef() + ? backwardLink.getWorkflowEvent().getRequestIdRef().getEventType() + : backwardLink.getWorkflowEvent().getEventRef().getEventType(); + Assert.assertEquals(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED, backwardLinkEventType); + } + + private static HistoryEvent findEventOfType(History history, EventType type) { + for (HistoryEvent e : history.getEventsList()) { + if (e.getEventType() == type) { + return e; + } + } + return null; + } + + @Service + public interface SanoSignalingNexusService { + @Operation + String operation(String calleeWorkflowId); + } + + @ServiceImpl(service = SanoSignalingNexusService.class) + public static class SanoSignalingNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (OperationContext ctx, + io.nexusrpc.handler.OperationStartDetails details, + String input) -> { + WorkflowClient client = Nexus.getOperationContext().getWorkflowClient(); + String tq = Nexus.getOperationContext().getInfo().getTaskQueue(); + SanoSignalCalleeWorkflow startStub = + client.newWorkflowStub( + SanoSignalCalleeWorkflow.class, + WorkflowOptions.newBuilder().setWorkflowId(input).setTaskQueue(tq).build()); + BatchRequest batch = client.newSignalWithStartRequest(); + batch.add(startStub::execute); + batch.add(startStub::ping, "from-sano"); + client.signalWithStart(batch); + return "signaled"; + }); + } + } + + @WorkflowInterface + public interface SanoSignalCalleeWorkflow { + @WorkflowMethod + String execute(); + + @SignalMethod + void ping(String msg); + } + + public static class SanoSignalCalleeWorkflowImpl implements SanoSignalCalleeWorkflow { + private String received; + + @Override + public String execute() { + Workflow.await(() -> received != null); + return received; + } + + @Override + public void ping(String msg) { + received = msg; + } + } +} 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 60b67b1b81..9024cbff6f 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 @@ -1,6 +1,10 @@ package io.temporal.internal.common; +import static io.temporal.internal.common.LinkConverter.linkToNexusLink; +import static io.temporal.internal.common.LinkConverter.nexusLinkToLink; +import static io.temporal.internal.common.LinkConverter.nexusLinkToNexusOperation; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowEvent; +import static io.temporal.internal.common.LinkConverter.nexusOperationToNexusLink; import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; import static org.junit.Assert.*; @@ -352,4 +356,202 @@ public void testConvertNexusToWorkflowEvent_InvalidEventType() { assertNull(nexusLinkToWorkflowEvent(input)); } + + @Test + public void testConvertNexusOperationToNexus_Valid() { + Link.NexusOperation input = + Link.NexusOperation.newBuilder() + .setNamespace("ns") + .setOperationId("op-id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + assertEquals(expected, nexusOperationToNexusLink(input)); + } + + @Test + public void testConvertNexusOperationToNexus_ValidSlash() { + Link.NexusOperation input = + Link.NexusOperation.newBuilder() + .setNamespace("ns") + .setOperationId("op/id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + assertEquals(expected, nexusOperationToNexusLink(input)); + } + + @Test + public void testConvertNexusToNexusOperation_Valid() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + Link expected = + Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder() + .setNamespace("ns") + .setOperationId("op-id") + .setRunId("run-id")) + .build(); + + assertEquals(expected, nexusLinkToNexusOperation(input)); + } + + @Test + public void testConvertNexusToNexusOperation_ValidSlash() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + Link expected = + Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder() + .setNamespace("ns") + .setOperationId("op/id") + .setRunId("run-id")) + .build(); + + assertEquals(expected, nexusLinkToNexusOperation(input)); + } + + @Test + public void testConvertNexusToNexusOperation_WrongType() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") + .setType("temporal.api.common.v1.Link.WorkflowEvent") + .build(); + + assertNull(nexusLinkToNexusOperation(input)); + } + + @Test + public void testConvertNexusToNexusOperation_InvalidScheme() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("random:///namespaces/ns/nexus-operations/op-id/run-id/details") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + assertNull(nexusLinkToNexusOperation(input)); + } + + @Test + public void testConvertNexusToNexusOperation_InvalidPathMissingDetails() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + assertNull(nexusLinkToNexusOperation(input)); + } + + @Test + public void testNexusLinkToLink_WorkflowEventRoundTrip() { + Link.WorkflowEvent we = + Link.WorkflowEvent.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventId(1) + .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) + .build(); + + io.temporal.api.nexus.v1.Link nexusLink = workflowEventToNexusLink(we); + assertEquals("temporal.api.common.v1.Link.WorkflowEvent", nexusLink.getType()); + + Link converted = nexusLinkToLink(nexusLink); + assertNotNull(converted); + assertEquals(Link.newBuilder().setWorkflowEvent(we).build(), converted); + } + + @Test + public void testNexusLinkToLink_NexusOperation() { + io.temporal.api.nexus.v1.Link nexusLink = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") + .setType("temporal.api.common.v1.Link.NexusOperation") + .build(); + + Link expected = + Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder() + .setNamespace("ns") + .setOperationId("op-id") + .setRunId("run-id")) + .build(); + + assertEquals(expected, nexusLinkToLink(nexusLink)); + } + + @Test + public void testNexusLinkToLink_UnknownType() { + io.temporal.api.nexus.v1.Link nexusLink = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id/history") + .setType("unknown.type") + .build(); + + assertNull(nexusLinkToLink(nexusLink)); + } + + @Test + public void testLinkToNexusLink_WorkflowEvent() { + Link.WorkflowEvent we = + Link.WorkflowEvent.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventId(1) + .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) + .build(); + + io.temporal.api.nexus.v1.Link actual = + linkToNexusLink(Link.newBuilder().setWorkflowEvent(we).build()); + assertEquals(workflowEventToNexusLink(we), actual); + } + + @Test + public void testLinkToNexusLink_NexusOperation() { + Link.NexusOperation no = + Link.NexusOperation.newBuilder() + .setNamespace("ns") + .setOperationId("op-id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link actual = + linkToNexusLink(Link.newBuilder().setNexusOperation(no).build()); + assertEquals(nexusOperationToNexusLink(no), actual); + } + + @Test + public void testLinkToNexusLink_Empty() { + assertNull(linkToNexusLink(Link.newBuilder().build())); + } } From 777c3ec3f084bb0345e8345114f9116333dc0dac Mon Sep 17 00:00:00 2001 From: Sheepman <38871525+444am@users.noreply.github.com> Date: Thu, 2 Jul 2026 05:35:36 +1000 Subject: [PATCH 026/107] Fix metric tags for Start Standalone Activity (#2930) --- .../external/GenericWorkflowClientImpl.java | 11 +++- .../client/functional/MetricsTest.java | 52 +++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) 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 a40e66bc4a..cee4ffc893 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 @@ -547,15 +547,24 @@ public ExecuteMultiOperationResponse executeMultiOperation( @Override public StartActivityExecutionResponse startActivity(StartActivityExecutionRequest request) { + Map tags = tagsForStartActivity(request); + Scope scope = metricsScope.tagged(tags); return grpcRetryer.retryWithResult( () -> service .blockingStub() - .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, scope) .startActivityExecution(request), grpcRetryerOptions); } + private static Map tagsForStartActivity(StartActivityExecutionRequest request) { + return new ImmutableMap.Builder(2) + .put(MetricsTag.ACTIVITY_TYPE, request.getActivityType().getName()) + .put(MetricsTag.TASK_QUEUE, request.getTaskQueue().getName()) + .build(); + } + @Override public PollActivityExecutionResponse pollActivity(PollActivityExecutionRequest request) { return grpcRetryer.retryWithResult( diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/MetricsTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/MetricsTest.java index ce353e9409..a7452fb37d 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/MetricsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/MetricsTest.java @@ -3,6 +3,7 @@ import static io.temporal.testUtils.Eventually.assertEventually; import static io.temporal.testing.internal.SDKTestWorkflowRule.NAMESPACE; import static junit.framework.TestCase.*; +import static org.junit.Assume.assumeTrue; import com.uber.m3.tally.RootScopeBuilder; import io.micrometer.core.instrument.*; @@ -12,9 +13,7 @@ import io.temporal.activity.LocalActivityOptions; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.WorkflowTaskFailedCause; -import io.temporal.client.WorkflowFailedException; -import io.temporal.client.WorkflowOptions; -import io.temporal.client.WorkflowStub; +import io.temporal.client.*; import io.temporal.common.reporter.MicrometerClientStatsReporter; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.CanceledFailure; @@ -45,13 +44,18 @@ public class MetricsTest { public final SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(QuicklyCompletingWorkflowImpl.class, MultiScenarioWorkflowImpl.class) - .setActivityImplementations(runCallbackActivity) + .setActivityImplementations(runCallbackActivity, new StandaloneMetricsActivityImpl()) .setMetricsScope( new RootScopeBuilder() .reporter(new MicrometerClientStatsReporter(registry)) .reportEvery(com.uber.m3.util.Duration.ofMillis(REPORTING_FLUSH_TIME >> 1))) .build(); + private final ActivityClient activityClient = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder().setNamespace(SDKTestWorkflowRule.NAMESPACE).build()); + private static final List TAGS_NAMESPACE = MetricsTag.defaultTags(NAMESPACE).entrySet().stream() .map( @@ -137,6 +141,35 @@ public void testAsynchronousStartAndGetResult() throws InterruptedException, Exe }); } + @Test + public void testStandaloneActivityStartRequestTags() { + // SAA is not supported by in-mem test server yet + assumeTrue(SDKTestWorkflowRule.useExternalService); + + activityClient.execute( + StandaloneMetricsActivity.class, + StandaloneMetricsActivity::run, + StartActivityOptions.newBuilder() + .setId("metrics-standalone-act-" + UUID.randomUUID()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()); + + List startActivityRequestTags = + replaceTags( + tagsNamespaceQueue, + MetricsTag.OPERATION_NAME, + "StartActivityExecution", + MetricsTag.ACTIVITY_TYPE, + "StandaloneMetricsActivity"); + + assertEventually( + Duration.ofSeconds(2), + () -> + assertIntCounter( + 1, registry.counter(MetricsType.TEMPORAL_REQUEST, startActivityRequestTags))); + } + @Test public void testWorkflowSuccess() { String result = @@ -369,4 +402,15 @@ public void runCallback() { } } } + + @ActivityInterface + public interface StandaloneMetricsActivity { + @ActivityMethod(name = "StandaloneMetricsActivity") + void run(); + } + + public static class StandaloneMetricsActivityImpl implements StandaloneMetricsActivity { + @Override + public void run() {} + } } From fcfbb36495bb652556b66739f0528b0e0fa138a7 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Wed, 1 Jul 2026 18:46:02 -0500 Subject: [PATCH 027/107] Respect SDK flags already present in history regardless of server capability detection. (#2936) * Add regression test for GetSystemInfo default capability masking Add a replay regression test that routes WorkflowService calls through a small proxy which returns UNIMPLEMENTED for GetSystemInfo and forwards all other RPCs. The test injects VERSION_WAIT_FOR_MARKER into a known interleaved getVersion history, verifies the history replays without the proxy, then expects the same history to replay through the proxy. On current buggy code this fails with TMPRL1100 because default capabilities disable SDK metadata support and mask the recorded SDK flag. * Always respect SDK flags when included in metadata, regardless of capabilities. * Only set default capabilities when gRPC `UNIMPLEMENTED` is for method not found, rather than compression * Add per-RPC gzip downgrade on compression errors Retry gzip-compressed unary RPCs once without request compression when the server returns UNIMPLEMENTED with compression-related error text. Cache the affected gRPC method so subsequent calls to that method use identity compression, while unrelated RPCs continue using gzip. This mirrors sdk-go's per-RPC compression negotiation and avoids treating proxy/server gzip support failures as missing GetSystemInfo capability data. Keep generic UNIMPLEMENTED errors unchanged. Add service-client tests covering downgrade, method-level caching, opt-out behavior when compression is NONE, and generic UNIMPLEMENTED errors that must not trigger downgrade. * Reorder rather than removing server capability check. * Make thread-safety of `compressionUnsupportedMethods` explicit * Don't check for compressor name when checking `UNIMPLEMENTED` response for compression * Fix nits --- .../io/temporal/internal/common/SdkFlags.java | 32 +-- .../internal/common/SdkFlagsTest.java | 65 +++++ ...GetVersionInterleavedUpdateReplayTest.java | 169 +++++++++++++ .../serviceclient/GrpcCompression.java | 5 +- .../GrpcCompressionInterceptor.java | 169 ++++++++++++- .../serviceclient/ServiceStubsOptions.java | 6 +- .../serviceclient/SystemInfoInterceptor.java | 21 +- .../serviceclient/ChannelManagerTest.java | 74 +++++- .../serviceclient/GrpcCompressionTest.java | 233 +++++++++++++++--- .../TestActivityEnvironmentInternal.java | 9 + 10 files changed, 729 insertions(+), 54 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/common/SdkFlagsTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlags.java b/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlags.java index 60584512c5..7fdb84b952 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlags.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlags.java @@ -18,40 +18,40 @@ public SdkFlags(boolean supportSdkMetadata, Functions.Func replaying) { this.replaying = replaying; } - /** - * Marks a flag as usable regardless of replay status. - * - * @return True, as long as the server supports SDK flags - */ - public boolean setSdkFlag(SdkFlag flag) { - if (!supportSdkMetadata) { - return false; - } + /** Marks a flag as usable regardless of replay status. */ + public void setSdkFlag(SdkFlag flag) { sdkFlags.add(flag); - return true; } /** * @return True if this flag may currently be used. */ public boolean tryUseSdkFlag(SdkFlag flag) { + if (sdkFlags.contains(flag)) { + return true; + } + if (!supportSdkMetadata) { return false; } - if (!replaying.apply()) { - sdkFlags.add(flag); - unsentSdkFlags.add(flag); - return true; - } else { - return sdkFlags.contains(flag); + if (replaying.apply()) { + return false; } + + sdkFlags.add(flag); + unsentSdkFlags.add(flag); + return true; } /** * @return True if this flag is set. */ public boolean checkSdkFlag(SdkFlag flag) { + if (sdkFlags.contains(flag)) { + return true; + } + if (!supportSdkMetadata) { return false; } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/common/SdkFlagsTest.java b/temporal-sdk/src/test/java/io/temporal/internal/common/SdkFlagsTest.java new file mode 100644 index 0000000000..0046f273f5 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/common/SdkFlagsTest.java @@ -0,0 +1,65 @@ +package io.temporal.internal.common; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.Collections; +import java.util.EnumSet; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Test; + +public class SdkFlagsTest { + + @Test + public void setSdkFlagIsRespectedWithoutMetadataCapability() { + SdkFlags flags = new SdkFlags(false, () -> true); + + flags.setSdkFlag(SdkFlag.VERSION_WAIT_FOR_MARKER); + + assertTrue(flags.checkSdkFlag(SdkFlag.VERSION_WAIT_FOR_MARKER)); + assertTrue(flags.tryUseSdkFlag(SdkFlag.VERSION_WAIT_FOR_MARKER)); + assertEquals(Collections.emptySet(), flags.takeNewSdkFlags()); + } + + @Test + public void tryUseDoesNotRecordNewFlagsWithoutMetadataCapability() { + SdkFlags flags = new SdkFlags(false, () -> false); + + assertFalse(flags.tryUseSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION)); + + assertFalse(flags.checkSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION)); + assertEquals(Collections.emptySet(), flags.takeNewSdkFlags()); + } + + @Test + public void checkSdkFlagReturnsFalseForMissingFlagsWithoutMetadataCapability() { + SdkFlags flags = new SdkFlags(false, () -> false); + + assertFalse(flags.checkSdkFlag(SdkFlag.VERSION_WAIT_FOR_MARKER)); + } + + @Test + public void tryUseRecordsNewFlagsWithMetadataCapability() { + SdkFlags flags = new SdkFlags(true, () -> false); + + assertTrue(flags.tryUseSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION)); + + assertTrue(flags.checkSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION)); + assertEquals(EnumSet.of(SdkFlag.SKIP_YIELD_ON_VERSION), flags.takeNewSdkFlags()); + assertEquals(Collections.emptySet(), flags.takeNewSdkFlags()); + } + + @Test + public void tryUseInReplayRequiresFlagInHistory() { + AtomicBoolean replaying = new AtomicBoolean(true); + SdkFlags flags = new SdkFlags(true, replaying::get); + + assertFalse(flags.tryUseSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION)); + + flags.setSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION); + + assertTrue(flags.tryUseSdkFlag(SdkFlag.SKIP_YIELD_ON_VERSION)); + assertEquals(Collections.emptySet(), flags.takeNewSdkFlags()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java index 73f895a744..5aa8813e00 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/GetVersionInterleavedUpdateReplayTest.java @@ -4,12 +4,24 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.MethodDescriptor; +import io.grpc.Server; +import io.grpc.ServerServiceDefinition; +import io.grpc.Status; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.ClientCalls; +import io.grpc.stub.ServerCalls; +import io.grpc.stub.StreamObserver; import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityMethod; import io.temporal.activity.ActivityOptions; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.EventType; import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; import io.temporal.client.WorkflowStub; @@ -18,6 +30,7 @@ import io.temporal.internal.common.SdkFlag; import io.temporal.internal.history.VersionMarkerUtils; import io.temporal.internal.statemachines.WorkflowStateMachines; +import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.testing.WorkflowHistoryLoader; import io.temporal.testing.WorkflowReplayer; @@ -27,6 +40,7 @@ import io.temporal.workflow.Workflow; import io.temporal.workflow.WorkflowInterface; import io.temporal.workflow.WorkflowMethod; +import java.io.IOException; import java.time.Duration; import java.time.OffsetDateTime; import java.util.ArrayList; @@ -34,6 +48,8 @@ import java.util.Collections; import java.util.List; import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; import org.slf4j.Logger; @@ -113,6 +129,57 @@ public void testReplayHistoryWithWaitForMarkerFlagReplaysWithoutDefaultEnable() } } + /** + * Regression test for the interaction between GetSystemInfo capability detection and SDK flags. + * + *

The base fixture is an old interleaved-update/getVersion history that fails replay unless + * replay waits for the real version marker event before resuming workflow code. That newer replay + * behavior is gated by {@link SdkFlag#VERSION_WAIT_FOR_MARKER}, so this test first edits the + * fixture in-memory to add that flag to every WorkflowTaskCompleted sdkMetadata.langUsedFlags + * field. The unproxied replay immediately after that edit proves the modified history has enough + * SDK metadata to select the fixed behavior. + * + *

The second replay runs the same history through a minimal gRPC proxy. The proxy forwards all + * WorkflowService RPCs to an in-memory test server except GetSystemInfo, which returns the + * UNIMPLEMENTED "unknown method" error shape used when the server is too old to know about the + * GetSystemInfo RPC. The SDK interprets that as default server capabilities. Default capabilities + * report sdkMetadata as unsupported, which previously caused the replay state machines to ignore + * langUsedFlags that are present in history and not enable VERSION_WAIT_FOR_MARKER. + * + *

The intended behavior is that this replay still succeeds: default server capabilities should + * only stop the worker from writing new SDK flag metadata; they should not make the worker forget + * SDK flags already recorded in workflow history. On buggy code, this test fails with the same + * TMPRL1100 NonDeterministicException as the original unflagged fixture, which demonstrates that + * default capabilities masked the recorded SDK flag. + */ + @Test + public void testGetSystemInfoUnimplementedDoesNotMaskSdkFlags() throws Exception { + WorkflowExecutionHistory history = + withSdkFlag( + WorkflowHistoryLoader.readHistoryFromResource(HISTORY_RESOURCE), + SdkFlag.VERSION_WAIT_FOR_MARKER); + assertTrue( + "The modified history must advertise VERSION_WAIT_FOR_MARKER.", + hasSdkFlag(history, SdkFlag.VERSION_WAIT_FOR_MARKER)); + WorkflowReplayer.replayWorkflowExecution(history, GreetingWorkflowImpl.class); + + try (TestWorkflowEnvironment backingEnvironment = TestWorkflowEnvironment.newInstance(); + GetSystemInfoUnimplementedProxy proxy = + GetSystemInfoUnimplementedProxy.start( + backingEnvironment.getWorkflowServiceStubs().getRawChannel()); + TestWorkflowEnvironment proxiedEnvironment = + TestWorkflowEnvironment.newInstance( + TestEnvironmentOptions.newBuilder() + .setUseExternalService(true) + .setTarget(proxy.getTarget()) + .build())) { + + WorkflowReplayer.replayWorkflowExecution( + history, proxiedEnvironment, GreetingWorkflowImpl.class); + assertTrue("Expected the proxy to receive GetSystemInfo.", proxy.getGetSystemInfoCalls() > 0); + } + } + public static WorkflowExecutionHistory captureReplayableHistory() { List savedInitialFlags = WorkflowStateMachines.initialFlags; List replayableFlags = new ArrayList<>(savedInitialFlags); @@ -184,6 +251,108 @@ private static boolean hasEvent(List events, EventType eventType) return false; } + private static WorkflowExecutionHistory withSdkFlag( + WorkflowExecutionHistory history, SdkFlag flag) { + io.temporal.api.history.v1.History.Builder historyBuilder = history.getHistory().toBuilder(); + for (int i = 0; i < historyBuilder.getEventsCount(); i++) { + HistoryEvent.Builder event = historyBuilder.getEventsBuilder(i); + if (event.getEventType() != EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED) { + continue; + } + if (!event + .getWorkflowTaskCompletedEventAttributes() + .getSdkMetadata() + .getLangUsedFlagsList() + .contains(flag.getValue())) { + event + .getWorkflowTaskCompletedEventAttributesBuilder() + .getSdkMetadataBuilder() + .addLangUsedFlags(flag.getValue()); + } + } + return new WorkflowExecutionHistory( + historyBuilder.build(), history.getWorkflowExecution().getWorkflowId()); + } + + private static final class GetSystemInfoUnimplementedProxy implements AutoCloseable { + private final Server server; + private final AtomicInteger getSystemInfoCalls; + + private GetSystemInfoUnimplementedProxy(Server server, AtomicInteger getSystemInfoCalls) { + this.server = server; + this.getSystemInfoCalls = getSystemInfoCalls; + } + + static GetSystemInfoUnimplementedProxy start(Channel target) throws IOException { + AtomicInteger getSystemInfoCalls = new AtomicInteger(); + Server server = + NettyServerBuilder.forPort(0) + .addService(buildProxyService(target, getSystemInfoCalls)) + .build() + .start(); + return new GetSystemInfoUnimplementedProxy(server, getSystemInfoCalls); + } + + String getTarget() { + return "127.0.0.1:" + server.getPort(); + } + + int getGetSystemInfoCalls() { + return getSystemInfoCalls.get(); + } + + @Override + public void close() throws InterruptedException { + server.shutdownNow(); + server.awaitTermination(1, TimeUnit.SECONDS); + } + + private static ServerServiceDefinition buildProxyService( + Channel target, AtomicInteger getSystemInfoCalls) { + ServerServiceDefinition.Builder builder = + ServerServiceDefinition.builder(WorkflowServiceGrpc.getServiceDescriptor()); + for (MethodDescriptor method : + WorkflowServiceGrpc.getServiceDescriptor().getMethods()) { + addProxyMethod(builder, method, target, getSystemInfoCalls); + } + return builder.build(); + } + + private static void addProxyMethod( + ServerServiceDefinition.Builder builder, + MethodDescriptor method, + Channel target, + AtomicInteger getSystemInfoCalls) { + if (method + .getFullMethodName() + .equals(WorkflowServiceGrpc.getGetSystemInfoMethod().getFullMethodName())) { + builder.addMethod( + method, + ServerCalls.asyncUnaryCall( + (ReqT request, StreamObserver responseObserver) -> { + getSystemInfoCalls.incrementAndGet(); + responseObserver.onError(unimplementedGetSystemInfo()); + })); + return; + } + + builder.addMethod( + method, + ServerCalls.asyncUnaryCall( + (ReqT request, StreamObserver responseObserver) -> { + ClientCall call = target.newCall(method, CallOptions.DEFAULT); + ClientCalls.asyncUnaryCall(call, request, responseObserver); + })); + } + + private static RuntimeException unimplementedGetSystemInfo() { + return Status.UNIMPLEMENTED + .withDescription( + "unknown method GetSystemInfo for service " + WorkflowServiceGrpc.SERVICE_NAME) + .asRuntimeException(); + } + } + public static class Request { private final String name; private final OffsetDateTime date; diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java index 1e2e5d60a4..1415560bca 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompression.java @@ -7,7 +7,10 @@ public enum GrpcCompression { /** Do not compress requests. */ NONE(null), - /** Gzip-compress requests. */ + /** + * Gzip-compress requests. If a specific server RPC does not support gzip, the SDK may retry that + * RPC without compression and continue using gzip for other RPCs. + */ GZIP("gzip"); private final @Nullable String compressorName; diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java index 4d605ab332..f6ad439fac 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/GrpcCompressionInterceptor.java @@ -4,10 +4,19 @@ import io.grpc.Channel; import io.grpc.ClientCall; import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCallListener; +import io.grpc.Metadata; import io.grpc.MethodDescriptor; +import io.grpc.Status; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; final class GrpcCompressionInterceptor implements ClientInterceptor { private final GrpcCompression compression; + private final Set compressionUnsupportedMethods = ConcurrentHashMap.newKeySet(); GrpcCompressionInterceptor(GrpcCompression compression) { this.compression = compression; @@ -16,6 +25,164 @@ final class GrpcCompressionInterceptor implements ClientInterceptor { @Override public ClientCall interceptCall( MethodDescriptor method, CallOptions callOptions, Channel next) { - return next.newCall(method, callOptions.withCompression(compression.getCompressorName())); + String compressorName = compression.getCompressorName(); + if (compressorName == null + || compressionUnsupportedMethods.contains(method.getFullMethodName())) { + return next.newCall(method, callOptions.withCompression(null)); + } + if (!MethodDescriptor.MethodType.UNARY.equals(method.getType())) { + return next.newCall(method, callOptions.withCompression(compressorName)); + } + return new GrpcCompressionRetryingClientCall<>( + next, method, callOptions, compression, compressorName, compressionUnsupportedMethods); + } + + static boolean isCompressionUnsupported(Status status, GrpcCompression compression) { + if (!Status.Code.UNIMPLEMENTED.equals(status.getCode()) + || compression.getCompressorName() == null) { + return false; + } + String description = status.getDescription(); + if (description == null) { + return false; + } + String lowerDescription = description.toLowerCase(Locale.ROOT); + return lowerDescription.contains("decompress") + || lowerDescription.contains("grpc-encoding") + || lowerDescription.contains("compressor"); + } + + private static final class GrpcCompressionRetryingClientCall + extends ClientCall { + private final Channel next; + private final MethodDescriptor method; + private final CallOptions callOptions; + private final GrpcCompression compression; + private final Set compressionUnsupportedMethods; + private final List messages = new ArrayList<>(1); + + private ClientCall delegate; + private Listener responseListener; + private Metadata headers; + private int requestedMessages; + private boolean halfClosed; + private boolean cancelled; + private Boolean messageCompressionEnabled; + + private GrpcCompressionRetryingClientCall( + Channel next, + MethodDescriptor method, + CallOptions callOptions, + GrpcCompression compression, + String compressorName, + Set compressionUnsupportedMethods) { + this.next = next; + this.method = method; + this.callOptions = callOptions; + this.compression = compression; + this.compressionUnsupportedMethods = compressionUnsupportedMethods; + this.delegate = next.newCall(method, callOptions.withCompression(compressorName)); + } + + @Override + public void start(Listener responseListener, Metadata headers) { + this.responseListener = responseListener; + this.headers = headers; + delegate.start(new FirstAttemptListener(responseListener), headers); + } + + @Override + public void request(int numMessages) { + requestedMessages += numMessages; + delegate.request(numMessages); + } + + @Override + public void cancel(String message, Throwable cause) { + cancelled = true; + delegate.cancel(message, cause); + } + + @Override + public void halfClose() { + halfClosed = true; + delegate.halfClose(); + } + + @Override + public void sendMessage(ReqT message) { + messages.add(message); + delegate.sendMessage(message); + } + + @Override + public boolean isReady() { + return delegate.isReady(); + } + + @Override + public void setMessageCompression(boolean enabled) { + messageCompressionEnabled = enabled; + delegate.setMessageCompression(enabled); + } + + private boolean retryWithoutCompression(Status status) { + if (cancelled || !isCompressionUnsupported(status, compression)) { + return false; + } + compressionUnsupportedMethods.add(method.getFullMethodName()); + Metadata retryHeaders = new Metadata(); + retryHeaders.merge(headers); + ClientCall retryCall = next.newCall(method, callOptions.withCompression(null)); + delegate = retryCall; + retryCall.start(responseListener, retryHeaders); + if (messageCompressionEnabled != null) { + retryCall.setMessageCompression(messageCompressionEnabled); + } + if (requestedMessages > 0) { + retryCall.request(requestedMessages); + } + for (ReqT message : messages) { + retryCall.sendMessage(message); + } + if (halfClosed) { + retryCall.halfClose(); + } + return true; + } + + private final class FirstAttemptListener + extends ForwardingClientCallListener.SimpleForwardingClientCallListener { + private Metadata firstHeaders; + private final List firstMessages = new ArrayList<>(1); + + private FirstAttemptListener(Listener responseListener) { + super(responseListener); + } + + @Override + public void onHeaders(Metadata headers) { + firstHeaders = headers; + } + + @Override + public void onMessage(RespT message) { + firstMessages.add(message); + } + + @Override + public void onClose(Status status, Metadata trailers) { + if (firstMessages.isEmpty() && retryWithoutCompression(status)) { + return; + } + if (firstHeaders != null) { + super.onHeaders(firstHeaders); + } + for (RespT message : firstMessages) { + super.onMessage(message); + } + super.onClose(status, trailers); + } + } } } diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java index 5ab5ddce6e..4161dca7f3 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/ServiceStubsOptions.java @@ -742,8 +742,10 @@ public T setMetricsScope(Scope metricsScope) { } /** - * Sets outbound transport-level gRPC compression. Defaults to {@link GrpcCompression#GZIP}. Set - * to {@link GrpcCompression#NONE} to opt out of compressing requests. + * Sets outbound transport-level gRPC compression. Defaults to {@link GrpcCompression#GZIP}. If + * a specific server RPC does not support gzip, the SDK may retry that RPC without compression + * and continue using gzip for other RPCs. Set to {@link GrpcCompression#NONE} to opt out of + * compressing requests. * *

The SDK uses the default gRPC response decompression registry for all compression options, * so disabling request compression does not disable accepting compressed responses. diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/SystemInfoInterceptor.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/SystemInfoInterceptor.java index a5c3be11d6..4b35c96459 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/SystemInfoInterceptor.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/SystemInfoInterceptor.java @@ -1,5 +1,6 @@ package io.temporal.serviceclient; +import com.google.common.annotations.VisibleForTesting; import io.grpc.*; import io.temporal.api.workflowservice.v1.GetSystemInfoRequest; import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; @@ -48,7 +49,7 @@ public void onMessage(RespT message) { @Override public void onClose(Status status, Metadata trailers) { - if (Status.UNIMPLEMENTED.getCode().equals(status.getCode())) { + if (isGetSystemInfoUnknownMethod(status)) { serverCapabilitiesFuture.complete(Capabilities.getDefaultInstance()); } super.onClose(status, trailers); @@ -112,10 +113,26 @@ public static Capabilities getServerCapabilitiesOrThrow( .getSystemInfo(GetSystemInfoRequest.newBuilder().build()) .getCapabilities(); } catch (StatusRuntimeException ex) { - if (Status.Code.UNIMPLEMENTED.equals(ex.getStatus().getCode())) { + if (isGetSystemInfoUnknownMethod(ex.getStatus())) { return Capabilities.getDefaultInstance(); } throw ex; } } + + @VisibleForTesting + static boolean isGetSystemInfoUnknownMethod(Status status) { + if (!Status.Code.UNIMPLEMENTED.equals(status.getCode())) { + return false; + } + String description = status.getDescription(); + if (description == null) { + return false; + } + String fullMethodName = WorkflowServiceGrpc.getGetSystemInfoMethod().getFullMethodName(); + return description.contains( + "unknown method GetSystemInfo for service " + WorkflowServiceGrpc.SERVICE_NAME) + || description.contains("Method not found: " + fullMethodName) + || description.contains("Method not found: /" + fullMethodName); + } } diff --git a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ChannelManagerTest.java b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ChannelManagerTest.java index 23cc0c5a2a..6e7264f14f 100644 --- a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ChannelManagerTest.java +++ b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/ChannelManagerTest.java @@ -1,6 +1,7 @@ package io.temporal.serviceclient; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import io.grpc.ManagedChannel; @@ -51,6 +52,7 @@ public class ChannelManagerTest { private final AtomicInteger getSystemInfoCount = new AtomicInteger(0); private final AtomicInteger getSystemInfoUnavailable = new AtomicInteger(0); private final AtomicInteger getSystemInfoUnimplemented = new AtomicInteger(0); + private String getSystemInfoUnimplementedDescription; private final HealthImplBase healthImpl = new HealthImplBase() { @@ -76,7 +78,11 @@ public void getSystemInfo( if (getSystemInfoUnavailable.getAndDecrement() > 0) { responseObserver.onError(Status.fromCode(Status.Code.UNAVAILABLE).asException()); } else if (getSystemInfoUnimplemented.getAndDecrement() > 0) { - responseObserver.onError(Status.fromCode(Status.Code.UNIMPLEMENTED).asException()); + Status status = Status.fromCode(Status.Code.UNIMPLEMENTED); + if (getSystemInfoUnimplementedDescription != null) { + status = status.withDescription(getSystemInfoUnimplementedDescription); + } + responseObserver.onError(status.asException()); } else { getSystemInfoCount.getAndIncrement(); responseObserver.onNext(GET_SYSTEM_INFO_RESPONSE); @@ -94,6 +100,7 @@ public void setUp() throws Exception { getSystemInfoCount.set(0); getSystemInfoUnavailable.set(0); getSystemInfoUnimplemented.set(0); + getSystemInfoUnimplementedDescription = null; String serverName = InProcessServerBuilder.generateName(); grpcCleanupRule.register( InProcessServerBuilder.forName(serverName) @@ -120,6 +127,28 @@ public void tearDown() { } } + @Test + public void testGetSystemInfoUnknownMethodDescriptions() { + assertTrue( + SystemInfoInterceptor.isGetSystemInfoUnknownMethod( + Status.UNIMPLEMENTED.withDescription( + "unknown method GetSystemInfo for service " + + "temporal.api.workflowservice.v1.WorkflowService"))); + assertTrue( + SystemInfoInterceptor.isGetSystemInfoUnknownMethod( + Status.UNIMPLEMENTED.withDescription( + "Method not found: temporal.api.workflowservice.v1.WorkflowService/GetSystemInfo"))); + assertFalse( + SystemInfoInterceptor.isGetSystemInfoUnknownMethod( + Status.UNIMPLEMENTED.withDescription( + "grpc: Decompressor is not installed for grpc-encoding \"gzip\""))); + assertFalse( + SystemInfoInterceptor.isGetSystemInfoUnknownMethod( + Status.UNAVAILABLE.withDescription( + "unknown method GetSystemInfo for service " + + "temporal.api.workflowservice.v1.WorkflowService"))); + } + @Test public void testGetServerCapabilities() { Capabilities capabilities = channelManager.getServerCapabilities().get(); @@ -154,8 +183,10 @@ public void testGetServerCapabilitiesUnavailable() { } @Test - public void testGetServerCapabilitiesUnimplemented() { + public void testGetServerCapabilitiesUnimplementedUnknownMethod() { getSystemInfoUnimplemented.set(1); + getSystemInfoUnimplementedDescription = + "unknown method GetSystemInfo for service temporal.api.workflowservice.v1.WorkflowService"; Capabilities capabilities = channelManager.getServerCapabilities().get(); assertEquals(Capabilities.getDefaultInstance(), capabilities); assertEquals(0, getSystemInfoCount.get()); @@ -163,6 +194,23 @@ public void testGetServerCapabilitiesUnimplemented() { assertEquals(0, getSystemInfoUnimplemented.get()); } + @Test + public void testGetServerCapabilitiesUnimplementedOtherDescription() { + getSystemInfoUnimplemented.set(Integer.MAX_VALUE); + getSystemInfoUnimplementedDescription = + "grpc: Decompressor is not installed for grpc-encoding \"gzip\""; + try { + Capabilities unused = channelManager.getServerCapabilities().get(); + Assert.fail("expected StatusRuntimeException"); + } catch (StatusRuntimeException e) { + assertEquals(Status.Code.UNIMPLEMENTED, e.getStatus().getCode()); + assertEquals(getSystemInfoUnimplementedDescription, e.getStatus().getDescription()); + assertEquals(0, getSystemInfoCount.get()); + assertEquals(-2, getSystemInfoUnavailable.get()); + assertTrue(getSystemInfoUnimplemented.get() >= 0); + } + } + @Test public void testGetServerCapabilitiesWithConnect() { channelManager.connect(HEALTH_CHECK_NAME, Duration.ofMillis(100)); @@ -200,8 +248,10 @@ public void testGetServerCapabilitiesUnavailableWithConnect() { } @Test - public void testGetServerCapabilitiesUnimplementedWithConnect() { + public void testGetServerCapabilitiesUnimplementedUnknownMethodWithConnect() { getSystemInfoUnimplemented.set(1); + getSystemInfoUnimplementedDescription = + "unknown method GetSystemInfo for service temporal.api.workflowservice.v1.WorkflowService"; channelManager.connect(HEALTH_CHECK_NAME, Duration.ofMillis(100)); Capabilities capabilities = channelManager.getServerCapabilities().get(); assertEquals(Capabilities.getDefaultInstance(), capabilities); @@ -209,4 +259,22 @@ public void testGetServerCapabilitiesUnimplementedWithConnect() { assertEquals(-1, getSystemInfoUnavailable.get()); assertEquals(0, getSystemInfoUnimplemented.get()); } + + @Test + public void testGetServerCapabilitiesUnimplementedOtherDescriptionWithConnect() { + getSystemInfoUnimplemented.set(Integer.MAX_VALUE); + getSystemInfoUnimplementedDescription = + "grpc: Decompressor is not installed for grpc-encoding \"gzip\""; + try { + channelManager.connect(HEALTH_CHECK_NAME, Duration.ofMillis(100)); + Capabilities unused = channelManager.getServerCapabilities().get(); + Assert.fail("expected StatusRuntimeException"); + } catch (StatusRuntimeException e) { + assertEquals(Status.Code.UNIMPLEMENTED, e.getStatus().getCode()); + assertEquals(getSystemInfoUnimplementedDescription, e.getStatus().getDescription()); + assertEquals(0, getSystemInfoCount.get()); + assertEquals(-2, getSystemInfoUnavailable.get()); + assertTrue(getSystemInfoUnimplemented.get() >= 0); + } + } } diff --git a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java index 9488627f80..12afed8d84 100644 --- a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java +++ b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/GrpcCompressionTest.java @@ -2,19 +2,29 @@ import static org.junit.Assert.*; +import io.grpc.Context; +import io.grpc.Contexts; import io.grpc.Metadata; import io.grpc.Server; import io.grpc.ServerCall; import io.grpc.ServerCallHandler; import io.grpc.ServerInterceptor; import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; import io.grpc.stub.StreamObserver; import io.grpc.testing.GrpcCleanupRule; import io.temporal.api.workflowservice.v1.GetSystemInfoRequest; import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionResponse; import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; -import java.util.concurrent.atomic.AtomicReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import org.junit.Rule; import org.junit.Test; @@ -23,6 +33,7 @@ public class GrpcCompressionTest { Metadata.Key.of("grpc-encoding", Metadata.ASCII_STRING_MARSHALLER); private static final Metadata.Key GRPC_ACCEPT_ENCODING = Metadata.Key.of("grpc-accept-encoding", Metadata.ASCII_STRING_MARSHALLER); + private static final Context.Key REQUEST_COMPRESSION = Context.key("request-compression"); @Rule public final GrpcCleanupRule grpcCleanupRule = new GrpcCleanupRule(); @@ -42,50 +53,214 @@ public void noneCompressionDoesNotSendGzipButStillAcceptsGzip() throws Exception assertTrue(headers.get(GRPC_ACCEPT_ENCODING).contains("gzip")); } + @Test + public void gzipCompressionDowngradesUnsupportedMethod() throws Exception { + TestWorkflowService service = new TestWorkflowService(); + service.rejectGzipGetSystemInfo = true; + CompressionHistoryInterceptor compressionHistory = new CompressionHistoryInterceptor(); + Server server = startServer(service, compressionHistory); + WorkflowServiceStubs serviceStubs = newServiceStubs(server, GrpcCompression.GZIP); + try { + serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); + List getSystemInfoHistory = + compressionHistory.compressionHistoryForMethod(getSystemInfoMethod()); + assertEquals(2, getSystemInfoHistory.size()); + assertEquals("gzip", getSystemInfoHistory.get(0)); + assertNull(getSystemInfoHistory.get(1)); + + serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); + getSystemInfoHistory = compressionHistory.compressionHistoryForMethod(getSystemInfoMethod()); + assertEquals(3, getSystemInfoHistory.size()); + assertEquals(1, Collections.frequency(getSystemInfoHistory, "gzip")); + assertNull(getSystemInfoHistory.get(2)); + + serviceStubs + .blockingStub() + .signalWorkflowExecution( + SignalWorkflowExecutionRequest.newBuilder() + .setNamespace("test-namespace") + .setSignalName("test-signal") + .build()); + assertEquals( + "gzip", compressionHistory.lastCompressionForMethod(signalWorkflowExecutionMethod())); + } finally { + serviceStubs.shutdownNow(); + } + } + + @Test + public void gzipCompressionDowngradesCompressionErrorWithoutEncodingName() throws Exception { + TestWorkflowService service = new TestWorkflowService(); + service.rejectGzipGetSystemInfo = true; + service.rejectGzipGetSystemInfoDescription = "grpc: Decompressor is not installed"; + CompressionHistoryInterceptor compressionHistory = new CompressionHistoryInterceptor(); + Server server = startServer(service, compressionHistory); + WorkflowServiceStubs serviceStubs = newServiceStubs(server, GrpcCompression.GZIP); + try { + serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); + } finally { + serviceStubs.shutdownNow(); + } + + List getSystemInfoHistory = + compressionHistory.compressionHistoryForMethod(getSystemInfoMethod()); + assertEquals(2, getSystemInfoHistory.size()); + assertEquals("gzip", getSystemInfoHistory.get(0)); + assertNull(getSystemInfoHistory.get(1)); + } + + @Test + public void noneCompressionDoesNotInstallDowngrade() throws Exception { + TestWorkflowService service = new TestWorkflowService(); + service.rejectGzipGetSystemInfo = true; + CompressionHistoryInterceptor compressionHistory = new CompressionHistoryInterceptor(); + Server server = startServer(service, compressionHistory); + WorkflowServiceStubs serviceStubs = newServiceStubs(server, GrpcCompression.NONE); + try { + serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); + List getSystemInfoHistory = + compressionHistory.compressionHistoryForMethod(getSystemInfoMethod()); + assertEquals(1, getSystemInfoHistory.size()); + assertNull(getSystemInfoHistory.get(0)); + } finally { + serviceStubs.shutdownNow(); + } + } + + @Test + public void gzipCompressionDoesNotDowngradeGenericUnimplemented() throws Exception { + TestWorkflowService service = new TestWorkflowService(); + service.getSystemInfoError = + Status.UNIMPLEMENTED.withDescription("gzip feature is not implemented"); + CompressionHistoryInterceptor compressionHistory = new CompressionHistoryInterceptor(); + Server server = startServer(service, compressionHistory); + WorkflowServiceStubs serviceStubs = newServiceStubs(server, GrpcCompression.GZIP); + try { + serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); + fail("expected StatusRuntimeException"); + } catch (StatusRuntimeException e) { + assertEquals(Status.Code.UNIMPLEMENTED, e.getStatus().getCode()); + } finally { + serviceStubs.shutdownNow(); + } + List getSystemInfoHistory = + compressionHistory.compressionHistoryForMethod(getSystemInfoMethod()); + assertEquals(1, getSystemInfoHistory.size()); + assertEquals("gzip", getSystemInfoHistory.get(0)); + } + private Metadata callGetSystemInfo(GrpcCompression compression) throws Exception { - AtomicReference capturedHeaders = new AtomicReference<>(); - ServerInterceptor captureHeadersInterceptor = - new ServerInterceptor() { - @Override - public ServerCall.Listener interceptCall( - ServerCall call, Metadata headers, ServerCallHandler next) { - capturedHeaders.set(headers); - return next.startCall(call, headers); - } - }; - Server server = - grpcCleanupRule.register( - NettyServerBuilder.forPort(0) - .addService( - ServerInterceptors.intercept( - new TestWorkflowService(), captureHeadersInterceptor)) - .build() - .start()); - - WorkflowServiceStubs serviceStubs = - WorkflowServiceStubs.newServiceStubs( - WorkflowServiceStubsOptions.newBuilder() - .setTarget("127.0.0.1:" + server.getPort()) - .setEnableHttps(false) - .setGrpcCompression(compression) - .build()); + CompressionHistoryInterceptor compressionHistory = new CompressionHistoryInterceptor(); + Server server = startServer(new TestWorkflowService(), compressionHistory); + WorkflowServiceStubs serviceStubs = newServiceStubs(server, compression); try { serviceStubs.blockingStub().getSystemInfo(GetSystemInfoRequest.getDefaultInstance()); } finally { serviceStubs.shutdownNow(); } - assertNotNull(capturedHeaders.get()); - return capturedHeaders.get(); + assertNotNull(compressionHistory.lastHeaders()); + return compressionHistory.lastHeaders(); + } + + private Server startServer( + TestWorkflowService service, CompressionHistoryInterceptor compressionHistory) + throws Exception { + return grpcCleanupRule.register( + NettyServerBuilder.forPort(0) + .addService(ServerInterceptors.intercept(service, compressionHistory)) + .build() + .start()); + } + + private WorkflowServiceStubs newServiceStubs(Server server, GrpcCompression compression) { + return WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setTarget("127.0.0.1:" + server.getPort()) + .setEnableHttps(false) + .setGrpcCompression(compression) + .build()); + } + + private static String getSystemInfoMethod() { + return WorkflowServiceGrpc.getGetSystemInfoMethod().getFullMethodName(); + } + + private static String signalWorkflowExecutionMethod() { + return WorkflowServiceGrpc.getSignalWorkflowExecutionMethod().getFullMethodName(); + } + + private static final class CompressionHistoryInterceptor implements ServerInterceptor { + private final Map> compressionHistoryByMethod = new HashMap<>(); + private Metadata lastHeaders; + + @Override + public synchronized ServerCall.Listener interceptCall( + ServerCall call, Metadata headers, ServerCallHandler next) { + String compression = headers.get(GRPC_ENCODING); + String methodName = call.getMethodDescriptor().getFullMethodName(); + List compressionHistory = compressionHistoryByMethod.get(methodName); + if (compressionHistory == null) { + compressionHistory = new ArrayList<>(); + compressionHistoryByMethod.put(methodName, compressionHistory); + } + compressionHistory.add(compression); + lastHeaders = headers; + return Contexts.interceptCall( + Context.current().withValue(REQUEST_COMPRESSION, compression), call, headers, next); + } + + synchronized Metadata lastHeaders() { + return lastHeaders; + } + + synchronized List compressionHistoryForMethod(String methodName) { + List compressionHistory = compressionHistoryByMethod.get(methodName); + return compressionHistory == null + ? Collections.emptyList() + : new ArrayList<>(compressionHistory); + } + + synchronized String lastCompressionForMethod(String methodName) { + List compressionHistory = compressionHistoryByMethod.get(methodName); + if (compressionHistory == null || compressionHistory.isEmpty()) { + return null; + } + return compressionHistory.get(compressionHistory.size() - 1); + } } private static final class TestWorkflowService extends WorkflowServiceGrpc.WorkflowServiceImplBase { + private boolean rejectGzipGetSystemInfo; + private String rejectGzipGetSystemInfoDescription = + "grpc: Decompressor is not installed for grpc-encoding \"gzip\""; + private Status getSystemInfoError; + @Override public void getSystemInfo( GetSystemInfoRequest request, StreamObserver responseObserver) { + if (rejectGzipGetSystemInfo && "gzip".equals(REQUEST_COMPRESSION.get())) { + responseObserver.onError( + Status.UNIMPLEMENTED + .withDescription(rejectGzipGetSystemInfoDescription) + .asRuntimeException()); + return; + } + if (getSystemInfoError != null) { + responseObserver.onError(getSystemInfoError.asRuntimeException()); + return; + } responseObserver.onNext(GetSystemInfoResponse.getDefaultInstance()); responseObserver.onCompleted(); } + + @Override + public void signalWorkflowExecution( + SignalWorkflowExecutionRequest request, + StreamObserver responseObserver) { + responseObserver.onNext(SignalWorkflowExecutionResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } } } diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java b/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java index 1221dec557..1d6ebb92de 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java @@ -11,6 +11,8 @@ import io.temporal.api.common.v1.Payloads; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.RetryState; +import io.temporal.api.workflowservice.v1.GetSystemInfoRequest; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; import io.temporal.api.workflowservice.v1.PollActivityTaskQueueResponse; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatResponse; @@ -119,6 +121,13 @@ public TestActivityEnvironmentInternal(@Nullable TestEnvironmentOptions options) } private class HeartbeatInterceptingService extends WorkflowServiceGrpc.WorkflowServiceImplBase { + @Override + public void getSystemInfo( + GetSystemInfoRequest request, StreamObserver responseObserver) { + responseObserver.onNext(GetSystemInfoResponse.getDefaultInstance()); + responseObserver.onCompleted(); + } + @Override public void recordActivityTaskHeartbeat( RecordActivityTaskHeartbeatRequest request, From 0f133ea8bf81771793677576119e1b8f370c0de3 Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Wed, 1 Jul 2026 19:50:58 -0500 Subject: [PATCH 028/107] Release Java SDK v1.36.1 (#2937) --- releases/v1.36.1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 releases/v1.36.1 diff --git a/releases/v1.36.1 b/releases/v1.36.1 new file mode 100644 index 0000000000..e38df896f4 --- /dev/null +++ b/releases/v1.36.1 @@ -0,0 +1,14 @@ +# Bugfixes + +- Added fallback to uncompressed transport when server doesn't support transport compression. +- Fixed Non-Determinism exception when GetSystemInfo RPC call fails in certain scenarios. +- Fixed metrics tags for Standalone Activity Start client call. +- Standalone Nexus Operations now populate Links for UI navigation. + +# What's Changed + +2026-06-24 - 3da1f1db - Message and concurrent payload visitors (#2902) +2026-06-30 - 6e238614 - Fix deadlock detection error message to reflect user-configured timeout (#2934) +2026-07-01 - 777c3ec3 - Fix metric tags for Start Standalone Activity (#2930) +2026-07-01 - 93b84f26 - Add Standalone Nexus Operation links (#2932) +2026-07-01 - fcfbb364 - Respect SDK flags already present in history regardless of server capability detection. (#2936) From ac9c7ddbc02c7252854610d1321c978f1d81a7f1 Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Thu, 2 Jul 2026 11:13:47 -0500 Subject: [PATCH 029/107] Change references to `master` branch to `main` (#2938) --- .github/workflows/build-native-image.yml | 4 ++-- .github/workflows/ci.yml | 2 +- .github/workflows/coverage.yml | 2 +- .github/workflows/prepare-release.yml | 2 +- .github/workflows/publish-snapshot.yml | 1 - .whitesource | 2 +- README.md | 2 +- .../internal/history/LocalActivityMarkerMetadata.java | 2 +- .../internal/testservice/TestWorkflowMutableStateImpl.java | 2 +- temporal-test-server/src/main/proto/Makefile | 6 +----- 10 files changed, 10 insertions(+), 15 deletions(-) diff --git a/.github/workflows/build-native-image.yml b/.github/workflows/build-native-image.yml index 42daac94f7..f928951d83 100644 --- a/.github/workflows/build-native-image.yml +++ b/.github/workflows/build-native-image.yml @@ -11,7 +11,7 @@ on: type: string description: "Git ref from which to release" required: true - default: "master" + default: "main" upload_artifact: type: boolean description: "Upload the native test server executable as an artifact" @@ -23,7 +23,7 @@ on: type: string description: "Git ref from which to release" required: true - default: "master" + default: "main" upload_artifact: type: boolean description: "Upload the native test server executable as an artifact" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76aba1538c..598d8d02ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: pull_request: push: branches: - - master + - main jobs: unit_test_edge: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 4b573144f0..8270d45496 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -2,7 +2,7 @@ name: Code Coverage on: push: branches: - - master + - main permissions: contents: read diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 291ed59d8c..585f162499 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -13,7 +13,7 @@ on: type: string description: "Git ref from which to release" required: true - default: "master" + default: "main" do_build_native_images: type: boolean description: "Native Test Server" diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index 91d5b07a6f..c511246862 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -13,7 +13,6 @@ on: push: branches: - 'main' - - 'master' paths-ignore: - 'releases/**' - 'docker/buildkite/**' diff --git a/.whitesource b/.whitesource index 9cff630f0c..42249f6357 100644 --- a/.whitesource +++ b/.whitesource @@ -2,6 +2,6 @@ "settingsInheritedFrom": "temporalio/whitesource-config@main", "scanSettings": { "configMode": "EXTERNAL", - "configExternalURL": "https://raw.githubusercontent.com/temporalio/sdk-java/master/whitesource.config" + "configExternalURL": "https://raw.githubusercontent.com/temporalio/sdk-java/main/whitesource.config" } } diff --git a/README.md b/README.md index 52be87a5bc..a1ee72ce12 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ![Temporal Java SDK](https://raw.githubusercontent.com/temporalio/assets/main/files/w/java.png) -# Temporal Java SDK [![Build status](https://github.com/temporalio/sdk-java/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/temporalio/sdk-java/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/temporalio/sdk-java/badge.svg?branch=master)](https://coveralls.io/github/temporalio/sdk-java?branch=master) +# Temporal Java SDK [![Build status](https://github.com/temporalio/sdk-java/actions/workflows/ci.yml/badge.svg?event=push)](https://github.com/temporalio/sdk-java/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/temporalio/sdk-java/badge.svg?branch=main)](https://coveralls.io/github/temporalio/sdk-java?branch=main) [Temporal](https://github.com/temporalio/temporal) is a Workflow-as-Code platform for building and operating resilient applications using developer-friendly primitives, instead of constantly fighting your infrastructure. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/history/LocalActivityMarkerMetadata.java b/temporal-sdk/src/main/java/io/temporal/internal/history/LocalActivityMarkerMetadata.java index 8e29a6704f..03e00f9e82 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/history/LocalActivityMarkerMetadata.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/history/LocalActivityMarkerMetadata.java @@ -7,7 +7,7 @@ /** * See Core + * href="https://github.com/temporalio/sdk-core/blob/main/protos/local/temporal/sdk/core/external_data/external_data.proto#L12">Core * Data Structure */ public class LocalActivityMarkerMetadata { 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 ba45f52518..b99753b96d 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 @@ -1498,7 +1498,7 @@ private void processFailWorkflowExecution( String identity) { // This should probably follow the retry logic from - // https://github.com/temporalio/temporal/blob/master/service/history/retry.go#L95 + // https://github.com/temporalio/temporal/blob/main/service/history/retry.go#L95 Failure failure = d.getFailure(); WorkflowData data = workflow.getData(); diff --git a/temporal-test-server/src/main/proto/Makefile b/temporal-test-server/src/main/proto/Makefile index 20e7a73c17..a849481ef9 100644 --- a/temporal-test-server/src/main/proto/Makefile +++ b/temporal-test-server/src/main/proto/Makefile @@ -29,7 +29,7 @@ $(PROTO_OUT): mkdir $(PROTO_OUT) ##### Compile proto files for go ##### -grpc: buf-lint api-linter buf-breaking fix-path +grpc: buf-lint api-linter fix-path go-grpc: clean $(PROTO_OUT) printf $(COLOR) "Compile for go-gRPC..." @@ -63,10 +63,6 @@ buf-lint: printf $(COLOR) "Run buf linter..." (cd $(PROTO_ROOT) && buf lint) -buf-breaking: -# @printf $(COLOR) "Run buf breaking changes check against master branch..." -# @(cd $(PROTO_ROOT) && buf breaking --against '../../../../.git#branch=master') - ##### Clean ##### clean: printf $(COLOR) "Delete generated go files..." From f86c76648e608f6b51eea8bcbd2c0880c4c8cbc6 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Mon, 6 Jul 2026 13:38:38 -0400 Subject: [PATCH 030/107] fix flakey WorkflowUpdateTest.duplicateRejectedUpdate logic where ADMITTED was reported for updates that were actually COMPLETED. (#2940) --- .../testservice/TestWorkflowMutableStateImpl.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) 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 b99753b96d..19c7376ee7 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 @@ -3172,13 +3172,16 @@ public UpdateWorkflowExecutionLifecycleStage waitForStage( } public UpdateWorkflowExecutionLifecycleStage getStage() { - if (!accepted.isDone()) { - return UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ADMITTED; - } else if (!outcome.isDone()) { + // A resolved outcome is terminal (a success result or a rejection/failure), so it always + // means COMPLETED. Checking it first keeps stage derivation independent of the order in + // which the `accepted` and `outcome` futures complete. The `accepted` future only + // distinguishes ADMITTED from ACCEPTED. + if (outcome.isDone()) { + return UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED; + } else if (accepted.isDone()) { return UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ACCEPTED; } - return UpdateWorkflowExecutionLifecycleStage - .UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_COMPLETED; + return UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ADMITTED; } public String getId() { From 23590a1cf8dc44e24efbebed19c9e58a6824b32d Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Thu, 9 Jul 2026 12:04:10 -0500 Subject: [PATCH 031/107] AWS Lambda (Java) (#2901) * Lambda worker support for Java SDK * Support dynamic worker and activity registrations in Lambda * Behavioral fixes * Corrections and completions * Add test injection comments * Make LambdaWorkerOptions immutable * Add per-invocation Lambda worker configuration Add LambdaWorker overloads that accept an invocation configurator so handlers can adjust worker options using the AWS Lambda Context before Temporal service stubs, clients, and workers are created. Copy the base options for each invocation, allow required fields such as task queue to be supplied dynamically, and keep invocation-local shutdown hooks from leaking across warm invocations. Run those hooks if the per-invocation configurator or final option validation fails. Document the new overload and cover the lifecycle behavior with unit tests. * Factor out OpenTelemetry into separate contrib package * Review comments * Make OTel implementation a plugin * No magic constants! * Rename `LambdaWorker.run()` -> `LambdaWorker.define()` --- contrib/temporal-aws-lambda/README.md | 80 ++ contrib/temporal-aws-lambda/build.gradle | 29 + .../lambda/DefaultLambdaWorkerRuntime.java | 149 +++ .../io/temporal/aws/lambda/LambdaWorker.java | 531 +++++++++++ .../aws/lambda/LambdaWorkerOptions.java | 646 +++++++++++++ .../aws/lambda/LambdaWorkerRuntime.java | 32 + .../OtelLambdaWorkerConfigurationHelper.java | 280 ++++++ .../temporal/aws/lambda/WorkerRegistrar.java | 27 + .../aws/lambda/LambdaWorkerLifecycleTest.java | 848 ++++++++++++++++++ .../aws/lambda/LambdaWorkerOptionsTest.java | 339 +++++++ ...elLambdaWorkerConfigurationHelperTest.java | 458 ++++++++++ .../aws/lambda/TestLambdaContext.java | 96 ++ contrib/temporal-opentelemetry/README.md | 65 ++ contrib/temporal-opentelemetry/build.gradle | 32 + .../opentelemetry/OpenTelemetryFlushHook.java | 120 +++ .../opentelemetry/OpenTelemetryPlugin.java | 252 ++++++ .../OpenTelemetryStatsReporter.java | 160 ++++ .../opentelemetry/OpenTelemetryWorker.java | 400 +++++++++ .../opentelemetry/TallyScopeFlushHook.java | 34 + .../opentelemetry/TimedShutdownHook.java | 9 + .../OpenTelemetryPluginTest.java | 211 +++++ .../OpenTelemetryWorkerTest.java | 618 +++++++++++++ settings.gradle | 4 + temporal-bom/build.gradle | 2 + 24 files changed, 5422 insertions(+) create mode 100644 contrib/temporal-aws-lambda/README.md create mode 100644 contrib/temporal-aws-lambda/build.gradle create mode 100644 contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/DefaultLambdaWorkerRuntime.java create mode 100644 contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorker.java create mode 100644 contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerOptions.java create mode 100644 contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerRuntime.java create mode 100644 contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java create mode 100644 contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/WorkerRegistrar.java create mode 100644 contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerLifecycleTest.java create mode 100644 contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerOptionsTest.java create mode 100644 contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelperTest.java create mode 100644 contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/TestLambdaContext.java create mode 100644 contrib/temporal-opentelemetry/README.md create mode 100644 contrib/temporal-opentelemetry/build.gradle create mode 100644 contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryFlushHook.java create mode 100644 contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java create mode 100644 contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryStatsReporter.java create mode 100644 contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java create mode 100644 contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TallyScopeFlushHook.java create mode 100644 contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TimedShutdownHook.java create mode 100644 contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryPluginTest.java create mode 100644 contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryWorkerTest.java diff --git a/contrib/temporal-aws-lambda/README.md b/contrib/temporal-aws-lambda/README.md new file mode 100644 index 0000000000..0619796fdf --- /dev/null +++ b/contrib/temporal-aws-lambda/README.md @@ -0,0 +1,80 @@ +# Temporal AWS Lambda worker module + +This module provides a direct AWS Lambda Java handler for running a Temporal worker for one Lambda invocation. + +## Usage + +Add `temporal-aws-lambda` next to your Temporal SDK dependency, then expose the returned handler from your Lambda class: + +```java +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import io.temporal.aws.lambda.LambdaWorker; +import io.temporal.common.WorkerDeploymentVersion; + +public final class Handler implements RequestHandler { + private static final RequestHandler WORKER = + LambdaWorker.define( + new WorkerDeploymentVersion("orders-worker", "2026-06-02"), + builder -> + builder + .setTaskQueue("orders") + .registerWorkflowImplementationTypes(OrderWorkflowImpl.class) + .registerActivitiesImplementations(new OrderActivitiesImpl())); + + @Override + public Void handleRequest(Object input, Context context) { + return WORKER.handleRequest(input, context); + } +} +``` + +`TEMPORAL_TASK_QUEUE` can provide the task queue. If it is not set, call `setTaskQueue`. + +Connection options are loaded with `temporal-envconfig` when the handler is constructed during Lambda cold start. The Lambda worker checks `TEMPORAL_CONFIG_FILE` first, then `$LAMBDA_TASK_ROOT/temporal.toml`, then `./temporal.toml`, then falls back to the envconfig defaults and Temporal environment variables. The `configure` callback, passed as the second parameter to `LambdaWorker.define`, also runs during handler construction, so non-invocation configuration is prepared once and reused. + +Use the per-invocation configuration overload when final options depend on the Lambda `Context` or on resources opened for one invocation. The cold-start `configure` callback passed as the second parameter to `LambdaWorker.define` still runs once; the invocation callback runs before Temporal service stubs, client, and worker are created: + +```java +private static final RequestHandler WORKER = + LambdaWorker.define( + new WorkerDeploymentVersion("orders-worker", "2026-06-02"), + builder -> builder.registerWorkflowImplementationTypes(OrderWorkflowImpl.class), + (builder, context) -> { + builder.setTaskQueue(taskQueueFor(context)); + builder.addShutdownHook(() -> cleanupInvocationResources(context)); + }); +``` + +Each invocation receives a fresh copy of the base options, so mutations and shutdown hooks added by the invocation callback do not leak across warm invocations. If the invocation callback throws after adding shutdown hooks, those hooks still run for cleanup. Reusable resources such as AWS SDK clients should usually be created during cold start and reused across warm invocations. + +If you need to assemble options outside the `define` callback, call `LambdaWorkerOptions.newBuilderFromEnvironment()`, configure the returned builder, call `build()`, and pass the options to `LambdaWorker.newHandler(...)`. + +Dynamic workflow and activity implementations can be registered with `registerDynamicWorkflowImplementationType(...)` and `registerDynamicActivityImplementation(...)`. Java SDK worker rules still apply: only one dynamic workflow implementation type and one dynamic activity implementation can be registered per worker. + +The handler creates one worker per invocation, starts the worker, shuts it down before the Lambda deadline, runs shutdown hooks in order, and closes service stubs. Worker deployment versioning is always enabled for the supplied `WorkerDeploymentVersion`. If neither client nor worker identity is set by the user, each invocation uses `@` as the Temporal identity. + +`shutdownDeadlineBuffer` is the full shutdown window reserved at the end of the Lambda invocation. The default is 7 seconds: 5 seconds for `gracefulShutdownTimeout` and 2 seconds for hooks and service stubs. The worker runs until `remainingTime - shutdownDeadlineBuffer`, then stops and awaits termination for `gracefulShutdownTimeout`. If you change `gracefulShutdownTimeout` without explicitly setting `shutdownDeadlineBuffer`, the buffer is recomputed as `gracefulShutdownTimeout + 2s`. +If you explicitly set `shutdownDeadlineBuffer`, it must be greater than or equal to `gracefulShutdownTimeout`. + +## OpenTelemetry + +`OtelLambdaWorkerConfigurationHelper.configure(builder)` is a Lambda-specific facade over `temporal-opentelemetry`. It creates an `OpenTelemetryPlugin`, configures it with AWS X-Ray-compatible trace ID generation, installs it on service stubs options so it propagates to the workflow client and worker factory, and registers the plugin's timed flush hook for each Lambda invocation. The hook reports buffered Tally values before the OpenTelemetry provider hook force-flushes exporters. To enable it, call the helper from the handler initializer: + +```java +private static final RequestHandler WORKER = + LambdaWorker.define( + new WorkerDeploymentVersion("orders-worker", "2026-06-02"), + builder -> { + OtelLambdaWorkerConfigurationHelper.configure(builder); + builder + .setTaskQueue("orders") + .registerWorkflowImplementationTypes(OrderWorkflowImpl.class); + }); +``` + +The helper defaults the OTLP endpoint from `OTEL_EXPORTER_OTLP_ENDPOINT`, then `http://localhost:4317`. It defaults the service name from `OTEL_SERVICE_NAME`, then `AWS_LAMBDA_FUNCTION_NAME`, then `temporal-lambda-worker`, and sets it on the OpenTelemetry resource. To use an application-owned provider, call `builder.setOpenTelemetry(...)`; in that path, no exporters are created and the helper only installs the plugin and per-invocation flush hook. Providers and scopes are not closed after each invocation. + +Use `OtelLambdaWorkerConfigurationHelper.configureMetrics(...)`, `OtelLambdaWorkerConfigurationHelper.configureTracing(...)`, and `OtelLambdaWorkerConfigurationHelper.configureFlushHook(...)` when you want to compose metrics, tracing, or provider flushing separately around an application-owned OpenTelemetry instance. Use `temporal-opentelemetry` directly for non-Lambda serverless adapters or long-running workers. + +For Java logging, this module depends on `slf4j-api` only. It does not bundle a runtime logging binding, so Lambda log formatting remains owned by the application. diff --git a/contrib/temporal-aws-lambda/build.gradle b/contrib/temporal-aws-lambda/build.gradle new file mode 100644 index 0000000000..de4203109b --- /dev/null +++ b/contrib/temporal-aws-lambda/build.gradle @@ -0,0 +1,29 @@ +description = '''Temporal Java SDK AWS Lambda Worker Support Module''' + +ext { + awsLambdaJavaCoreVersion = '1.4.0' + otelVersion = '1.25.0' +} + +dependencies { + api platform("io.opentelemetry:opentelemetry-bom:$otelVersion") + + // This module shouldn't carry temporal-sdk with it, especially for situations when users may + // be using a shaded artifact. + compileOnly project(':temporal-sdk') + compileOnly "javax.annotation:javax.annotation-api:$annotationApiVersion" + + api "com.amazonaws:aws-lambda-java-core:$awsLambdaJavaCoreVersion" + api project(':temporal-opentelemetry') + + implementation project(':temporal-envconfig') + api "io.opentelemetry:opentelemetry-api" + implementation "io.opentelemetry.contrib:opentelemetry-aws-xray:$otelVersion" + implementation "org.slf4j:slf4j-api:$slf4jVersion" + + testImplementation project(':temporal-sdk') + testImplementation "io.opentelemetry:opentelemetry-sdk" + testImplementation "junit:junit:${junitVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/DefaultLambdaWorkerRuntime.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/DefaultLambdaWorkerRuntime.java new file mode 100644 index 0000000000..56efc4a7fd --- /dev/null +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/DefaultLambdaWorkerRuntime.java @@ -0,0 +1,149 @@ +package io.temporal.aws.lambda; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.EncodedValues; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.Functions; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +final class DefaultLambdaWorkerRuntime implements LambdaWorkerRuntime { + @Override + public Invocation create( + WorkflowServiceStubsOptions serviceStubsOptions, + WorkflowClientOptions clientOptions, + WorkerFactoryOptions workerFactoryOptions, + String taskQueue, + WorkerOptions workerOptions) { + WorkflowServiceStubs stubs = WorkflowServiceStubs.newServiceStubs(serviceStubsOptions); + WorkerFactory factory = null; + try { + WorkflowClient client = WorkflowClient.newInstance(stubs, clientOptions); + factory = WorkerFactory.newInstance(client, workerFactoryOptions); + Worker worker = factory.newWorker(taskQueue, workerOptions); + return new DefaultInvocation(stubs, factory, worker); + } catch (RuntimeException e) { + if (factory != null) { + try { + factory.shutdownNow(); + } catch (RuntimeException shutdownException) { + e.addSuppressed(shutdownException); + } + } + try { + stubs.shutdownNow(); + } catch (RuntimeException shutdownException) { + e.addSuppressed(shutdownException); + } + throw e; + } + } + + private static final class DefaultInvocation implements Invocation { + private final WorkflowServiceStubs stubs; + private final WorkerFactory factory; + private final WorkerRegistrar registrar; + + private DefaultInvocation(WorkflowServiceStubs stubs, WorkerFactory factory, Worker worker) { + this.stubs = stubs; + this.factory = factory; + this.registrar = new DefaultWorkerRegistrar(worker); + } + + @Override + public WorkerRegistrar getWorkerRegistrar() { + return registrar; + } + + @Override + public void start() { + factory.start(); + } + + @Override + public void shutdown() { + factory.shutdown(); + } + + @Override + public void shutdownNow() { + factory.shutdownNow(); + } + + @Override + public void awaitTermination(Duration timeout) { + factory.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + @Override + public boolean isTerminated() { + return factory.isTerminated(); + } + + @Override + public void closeStubs(Duration timeout) { + stubs.shutdown(); + if (!stubs.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + stubs.shutdownNow(); + } + } + } + + private static final class DefaultWorkerRegistrar implements WorkerRegistrar { + private final Worker worker; + + private DefaultWorkerRegistrar(Worker worker) { + this.worker = worker; + } + + @Override + public void registerWorkflowImplementationTypes(Class... workflowImplementationClasses) { + worker.registerWorkflowImplementationTypes(workflowImplementationClasses); + } + + @Override + public void registerWorkflowImplementationTypes( + WorkflowImplementationOptions options, Class... workflowImplementationClasses) { + worker.registerWorkflowImplementationTypes(options, workflowImplementationClasses); + } + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, Functions.Func factory) { + worker.registerWorkflowImplementationFactory(workflowInterface, factory); + } + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, + Functions.Func1 factory, + WorkflowImplementationOptions options) { + worker.registerWorkflowImplementationFactory(workflowInterface, factory, options); + } + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, + Functions.Func factory, + WorkflowImplementationOptions options) { + worker.registerWorkflowImplementationFactory(workflowInterface, factory, options); + } + + @Override + public void registerActivitiesImplementations(Object... activityImplementations) { + worker.registerActivitiesImplementations(activityImplementations); + } + + @Override + public void registerNexusServiceImplementation(Object... nexusServiceImplementations) { + worker.registerNexusServiceImplementation(nexusServiceImplementations); + } + } +} diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorker.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorker.java new file mode 100644 index 0000000000..7badec476a --- /dev/null +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorker.java @@ -0,0 +1,531 @@ +package io.temporal.aws.lambda; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import io.temporal.common.WorkerDeploymentVersion; +import io.temporal.opentelemetry.TimedShutdownHook; +import java.io.IOException; +import java.time.Duration; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import javax.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Creates AWS Lambda handlers that run one Temporal worker per Lambda invocation. */ +public final class LambdaWorker { + private static final Logger log = LoggerFactory.getLogger(LambdaWorker.class); + + private static final Duration MINIMUM_AVAILABLE_RUNTIME = Duration.ofSeconds(1); + private static final Duration LOW_AVAILABLE_RUNTIME_WARNING = Duration.ofSeconds(5); + + private LambdaWorker() {} + + /** + * Configures options for one Lambda invocation before Temporal service stubs, client, and worker + * are created. + * + *

The supplied builder is a fresh copy of the base options for the current invocation. + * Shutdown hooks added by this callback run for only that invocation, including when this + * callback throws. + */ + @FunctionalInterface + public interface InvocationConfigurator { + void configure(@Nonnull LambdaWorkerOptions.Builder builder, @Nonnull Context context); + } + + /** + * Returns an AWS Lambda Java handler that creates, starts, and shuts down one Temporal worker per + * invocation. + * + * @param version worker deployment version to advertise for this worker. + * @param configure callback invoked once while the Lambda handler is constructed. + */ + public static RequestHandler define( + @Nonnull WorkerDeploymentVersion version, + @Nonnull Consumer configure) { + LambdaWorkerOptions.validateVersion(version); + Objects.requireNonNull(configure, "configure"); + try { + LambdaWorkerOptions.Builder builder = + LambdaWorkerOptions.newBuilderFromEnvironment(System.getenv()); + configure.accept(builder); + return newHandler(version, builder.build()); + } catch (IOException e) { + throw new RuntimeException("Unable to load Temporal client configuration", e); + } + } + + /** + * Returns an AWS Lambda Java handler with both cold-start and per-invocation configuration. + * + * @param version worker deployment version to advertise for this worker. + * @param configure callback invoked once while the Lambda handler is constructed. + * @param invocationConfigure callback invoked for each Lambda invocation before Temporal service + * stubs, client, and worker are created. Required fields may be supplied by this callback. + */ + public static RequestHandler define( + @Nonnull WorkerDeploymentVersion version, + @Nonnull Consumer configure, + @Nonnull InvocationConfigurator invocationConfigure) { + LambdaWorkerOptions.validateVersion(version); + Objects.requireNonNull(configure, "configure"); + Objects.requireNonNull(invocationConfigure, "invocationConfigure"); + try { + LambdaWorkerOptions.Builder builder = + LambdaWorkerOptions.newBuilderFromEnvironment(System.getenv()); + configure.accept(builder); + return newHandler(version, builder.build(), invocationConfigure); + } catch (IOException e) { + throw new RuntimeException("Unable to load Temporal client configuration", e); + } + } + + /** Returns an AWS Lambda Java handler using already-configured Lambda worker options. */ + public static RequestHandler newHandler( + @Nonnull WorkerDeploymentVersion version, @Nonnull LambdaWorkerOptions options) { + return newHandler( + version, options, new DefaultLambdaWorkerRuntime(), sleep(), systemMonotonicClock()); + } + + /** + * Returns an AWS Lambda Java handler using base options and per-invocation configuration. + * + *

The supplied options are copied for each invocation before {@code invocationConfigure} runs. + * Required fields may be supplied by {@code invocationConfigure}. + */ + public static RequestHandler newHandler( + @Nonnull WorkerDeploymentVersion version, + @Nonnull LambdaWorkerOptions options, + @Nonnull InvocationConfigurator invocationConfigure) { + return newHandler( + version, + options, + invocationConfigure, + new DefaultLambdaWorkerRuntime(), + sleep(), + systemMonotonicClock()); + } + + static RequestHandler newHandler( + WorkerDeploymentVersion version, + LambdaWorkerOptions options, + LambdaWorkerRuntime runtime, + Sleeper sleeper) { + // This overload exists to let tests inject a fake runtime and sleeper. + return newHandler(version, options, runtime, sleeper, systemMonotonicClock()); + } + + static RequestHandler newHandler( + WorkerDeploymentVersion version, + LambdaWorkerOptions options, + InvocationConfigurator invocationConfigure, + LambdaWorkerRuntime runtime, + Sleeper sleeper) { + // This overload exists to let tests inject a fake runtime and sleeper. + return newHandler( + version, options, invocationConfigure, runtime, sleeper, systemMonotonicClock()); + } + + static RequestHandler newHandler( + WorkerDeploymentVersion version, + LambdaWorkerOptions options, + LambdaWorkerRuntime runtime, + Sleeper sleeper, + MonotonicClock clock) { + // This overload exists to let tests inject a deterministic clock. + return new Handler( + Objects.requireNonNull(options, "options").prepare(version), + Objects.requireNonNull(runtime, "runtime"), + Objects.requireNonNull(sleeper, "sleeper"), + Objects.requireNonNull(clock, "clock"), + false); + } + + static RequestHandler newHandler( + WorkerDeploymentVersion version, + LambdaWorkerOptions options, + InvocationConfigurator invocationConfigure, + LambdaWorkerRuntime runtime, + Sleeper sleeper, + MonotonicClock clock) { + LambdaWorkerOptions.validateVersion(version); + Objects.requireNonNull(options, "options"); + Objects.requireNonNull(invocationConfigure, "invocationConfigure"); + return new Handler( + context -> { + LambdaWorkerOptions.Builder builder = options.toBuilder(); + try { + invocationConfigure.configure(builder, context); + return builder.build().prepare(version).materialize(identityFor(context)); + } catch (RuntimeException e) { + throw new InvocationConfigurationException(e, builder); + } + }, + Objects.requireNonNull(runtime, "runtime"), + Objects.requireNonNull(sleeper, "sleeper"), + Objects.requireNonNull(clock, "clock"), + true); + } + + private static Sleeper sleep() { + return duration -> Thread.sleep(duration.toMillis()); + } + + interface Sleeper { + void sleep(Duration duration) throws InterruptedException; + } + + interface MonotonicClock { + long nanoTime(); + } + + private static MonotonicClock systemMonotonicClock() { + return System::nanoTime; + } + + private static String identityFor(Context context) { + return emptyToUnknown(context.getAwsRequestId()) + + "@" + + emptyToUnknown(context.getInvokedFunctionArn()); + } + + private static String emptyToUnknown(String value) { + return value == null || value.isEmpty() ? "unknown" : value; + } + + private interface OptionsMaterializer { + LambdaWorkerOptions.Materialized materialize(Context context); + } + + private static final class Handler implements RequestHandler { + private final OptionsMaterializer optionsMaterializer; + private final LambdaWorkerRuntime runtime; + private final Sleeper sleeper; + private final MonotonicClock clock; + private final boolean runShutdownHooksBeforeRuntimeCreation; + + private Handler( + LambdaWorkerOptions.Materialized preparedOptions, + LambdaWorkerRuntime runtime, + Sleeper sleeper, + MonotonicClock clock, + boolean runShutdownHooksBeforeRuntimeCreation) { + this( + context -> preparedOptions.materialize(identityFor(context)), + runtime, + sleeper, + clock, + runShutdownHooksBeforeRuntimeCreation); + Objects.requireNonNull(preparedOptions, "preparedOptions"); + } + + private Handler( + OptionsMaterializer optionsMaterializer, + LambdaWorkerRuntime runtime, + Sleeper sleeper, + MonotonicClock clock) { + this(optionsMaterializer, runtime, sleeper, clock, false); + } + + private Handler( + OptionsMaterializer optionsMaterializer, + LambdaWorkerRuntime runtime, + Sleeper sleeper, + MonotonicClock clock, + boolean runShutdownHooksBeforeRuntimeCreation) { + this.optionsMaterializer = Objects.requireNonNull(optionsMaterializer, "optionsMaterializer"); + this.runtime = runtime; + this.sleeper = sleeper; + this.clock = clock; + this.runShutdownHooksBeforeRuntimeCreation = runShutdownHooksBeforeRuntimeCreation; + } + + @Override + public Void handleRequest(Object input, Context context) { + Objects.requireNonNull(context, "context"); + + LambdaWorkerOptions.Materialized options = null; + LambdaWorkerRuntime.Invocation invocation = null; + try { + options = optionsMaterializer.materialize(context); + validateRemainingTime(context, options.shutdownDeadlineBuffer); + + invocation = + runtime.create( + options.serviceStubsOptions, + options.clientOptions, + options.workerFactoryOptions, + options.taskQueue, + options.workerOptions); + + for (LambdaWorkerOptions.Registration registration : options.registrations) { + registration.apply(invocation.getWorkerRegistrar()); + } + + invocation.start(); + log.info( + "Temporal Lambda worker started awsRequestId={} invokedFunctionArn={} taskQueue={} identity={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue, + options.workerOptions.getIdentity()); + + sleepUntilShutdownWindow(context, options); + return null; + } catch (InvocationConfigurationException e) { + runShutdownHooks( + context, + e.taskQueue, + e.shutdownHooks, + cleanupDeadlineNanos(context, e.gracefulShutdownTimeout, e.shutdownDeadlineBuffer)); + throw e.failure; + } catch (RuntimeException e) { + if (invocation == null && options != null && runShutdownHooksBeforeRuntimeCreation) { + runShutdownHooks( + context, + options.taskQueue, + options.shutdownHooks, + cleanupDeadlineNanos(context, options)); + } + throw e; + } finally { + if (invocation != null) { + shutdownInvocation(context, invocation, options); + } + } + } + + private void sleepUntilShutdownWindow( + Context context, LambdaWorkerOptions.Materialized options) { + Duration runDuration = durationUntilShutdownWindow(context, options); + if (runDuration.isZero() || runDuration.isNegative()) { + return; + } + + try { + sleeper.sleep(runDuration); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while running Temporal Lambda worker", e); + } + } + + private void shutdownInvocation( + Context context, + LambdaWorkerRuntime.Invocation invocation, + LambdaWorkerOptions.Materialized options) { + Long cleanupDeadlineNanos = null; + if (invocation != null) { + try { + invocation.shutdown(); + invocation.awaitTermination(options.gracefulShutdownTimeout); + } catch (RuntimeException e) { + log.error( + "Temporal Lambda worker shutdown failed awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue, + e); + } + + cleanupDeadlineNanos = cleanupDeadlineNanos(context, options); + boolean terminated = isTerminated(context, invocation, options); + if (!terminated) { + terminated = forceShutdownInvocation(context, invocation, options, cleanupDeadlineNanos); + } + if (terminated) { + log.info( + "Temporal Lambda worker stopped awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue); + } + } + + if (cleanupDeadlineNanos == null) { + cleanupDeadlineNanos = cleanupDeadlineNanos(context, options); + } + runShutdownHooks(context, options, cleanupDeadlineNanos.longValue()); + + try { + invocation.closeStubs(remainingCleanupTime(cleanupDeadlineNanos.longValue())); + } catch (RuntimeException e) { + log.error( + "Temporal Lambda worker service stubs close failed awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue, + e); + } + } + + private boolean forceShutdownInvocation( + Context context, + LambdaWorkerRuntime.Invocation invocation, + LambdaWorkerOptions.Materialized options, + long cleanupDeadlineNanos) { + log.warn( + "Temporal Lambda worker did not stop before graceful shutdown timeout; forcing stop awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue); + try { + invocation.shutdownNow(); + } catch (RuntimeException e) { + log.error( + "Temporal Lambda worker forced shutdown failed awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue, + e); + } + + try { + invocation.awaitTermination(remainingCleanupTime(cleanupDeadlineNanos)); + } catch (RuntimeException e) { + log.error( + "Temporal Lambda worker forced shutdown wait failed awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue, + e); + } + + boolean terminated = isTerminated(context, invocation, options); + if (!terminated) { + log.warn( + "Temporal Lambda worker did not terminate after forced shutdown awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue); + } + return terminated; + } + + private boolean isTerminated( + Context context, + LambdaWorkerRuntime.Invocation invocation, + LambdaWorkerOptions.Materialized options) { + try { + return invocation.isTerminated(); + } catch (RuntimeException e) { + log.error( + "Temporal Lambda worker termination check failed awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + options.taskQueue, + e); + return false; + } + } + + private void runShutdownHooks( + Context context, LambdaWorkerOptions.Materialized options, long cleanupDeadlineNanos) { + runShutdownHooks(context, options.taskQueue, options.shutdownHooks, cleanupDeadlineNanos); + } + + private void runShutdownHooks( + Context context, + String taskQueue, + List shutdownHooks, + long cleanupDeadlineNanos) { + for (Runnable hook : shutdownHooks) { + try { + if (hook instanceof TimedShutdownHook) { + ((TimedShutdownHook) hook).run(remainingCleanupTime(cleanupDeadlineNanos)); + } else { + hook.run(); + } + } catch (RuntimeException e) { + log.error( + "Temporal Lambda worker shutdown hook failed awsRequestId={} invokedFunctionArn={} taskQueue={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + taskQueue, + e); + } + } + } + + private Duration durationUntilShutdownWindow( + Context context, LambdaWorkerOptions.Materialized options) { + return Duration.ofMillis(context.getRemainingTimeInMillis()) + .minus(options.shutdownDeadlineBuffer); + } + + private void validateRemainingTime(Context context, Duration shutdownDeadlineBuffer) { + Duration available = + Duration.ofMillis(context.getRemainingTimeInMillis()).minus(shutdownDeadlineBuffer); + if (available.compareTo(MINIMUM_AVAILABLE_RUNTIME) <= 0) { + throw new IllegalStateException( + "Insufficient Lambda invocation time remaining after shutdown buffer: " + + available.toMillis() + + "ms"); + } + if (available.compareTo(LOW_AVAILABLE_RUNTIME_WARNING) < 0) { + log.warn( + "Temporal Lambda worker has low remaining time awsRequestId={} invokedFunctionArn={} availableRuntimeMs={} shutdownDeadlineBufferMs={}", + context.getAwsRequestId(), + context.getInvokedFunctionArn(), + available.toMillis(), + shutdownDeadlineBuffer.toMillis()); + } + } + + private long cleanupDeadlineNanos(Context context, LambdaWorkerOptions.Materialized options) { + return cleanupDeadlineNanos( + context, options.gracefulShutdownTimeout, options.shutdownDeadlineBuffer); + } + + private long cleanupDeadlineNanos( + Context context, Duration gracefulShutdownTimeout, Duration shutdownDeadlineBuffer) { + return clock.nanoTime() + + cleanupWindow(context, gracefulShutdownTimeout, shutdownDeadlineBuffer).toNanos(); + } + + private Duration cleanupWindow(Context context, LambdaWorkerOptions.Materialized options) { + return cleanupWindow( + context, options.gracefulShutdownTimeout, options.shutdownDeadlineBuffer); + } + + private Duration cleanupWindow( + Context context, Duration gracefulShutdownTimeout, Duration shutdownDeadlineBuffer) { + Duration configuredWindow = + nonNegative(shutdownDeadlineBuffer.minus(gracefulShutdownTimeout)); + Duration remaining = remainingInvocationTime(context); + return configuredWindow.compareTo(remaining) <= 0 ? configuredWindow : remaining; + } + + private Duration remainingCleanupTime(long cleanupDeadlineNanos) { + return nonNegative(Duration.ofNanos(cleanupDeadlineNanos - clock.nanoTime())); + } + + private Duration remainingInvocationTime(Context context) { + return nonNegative(Duration.ofMillis(context.getRemainingTimeInMillis())); + } + + private static Duration nonNegative(Duration duration) { + return duration.isNegative() ? Duration.ZERO : duration; + } + } + + private static final class InvocationConfigurationException extends RuntimeException { + private final RuntimeException failure; + private final String taskQueue; + private final Duration gracefulShutdownTimeout; + private final Duration shutdownDeadlineBuffer; + private final List shutdownHooks; + + private InvocationConfigurationException( + RuntimeException failure, LambdaWorkerOptions.Builder builder) { + super(failure); + this.failure = failure; + this.taskQueue = builder.getTaskQueue(); + this.gracefulShutdownTimeout = builder.getGracefulShutdownTimeout(); + this.shutdownDeadlineBuffer = builder.getShutdownDeadlineBuffer(); + this.shutdownHooks = builder.getShutdownHooks(); + } + } +} diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerOptions.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerOptions.java new file mode 100644 index 0000000000..7a74309368 --- /dev/null +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerOptions.java @@ -0,0 +1,646 @@ +package io.temporal.aws.lambda; + +import io.temporal.activity.DynamicActivity; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.VersioningBehavior; +import io.temporal.common.WorkerDeploymentVersion; +import io.temporal.common.converter.EncodedValues; +import io.temporal.envconfig.ClientConfigProfile; +import io.temporal.envconfig.LoadClientConfigProfileOptions; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerDeploymentOptions; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.DynamicWorkflow; +import io.temporal.workflow.Functions; +import java.io.File; +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** + * Configuration for Temporal workers running inside AWS Lambda invocations. + * + *

Instances are immutable snapshots. Lambda handlers copy snapshots for each invocation so + * invocation identity and optional per-invocation configuration can be applied without mutating the + * base options. + */ +public final class LambdaWorkerOptions { + public static final String TEMPORAL_TASK_QUEUE = "TEMPORAL_TASK_QUEUE"; + public static final String TEMPORAL_CONFIG_FILE = "TEMPORAL_CONFIG_FILE"; + public static final String LAMBDA_TASK_ROOT = "LAMBDA_TASK_ROOT"; + + static final Duration DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT = Duration.ofSeconds(5); + static final Duration DEFAULT_SHUTDOWN_HOOKS_AND_STUBS_TIMEOUT = Duration.ofSeconds(2); + static final Duration DEFAULT_SHUTDOWN_DEADLINE_BUFFER = + DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT.plus(DEFAULT_SHUTDOWN_HOOKS_AND_STUBS_TIMEOUT); + + private static final int DEFAULT_MAX_CONCURRENT_ACTIVITY_EXECUTION_SIZE = 2; + private static final int DEFAULT_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTION_SIZE = 10; + private static final int DEFAULT_MAX_CONCURRENT_LOCAL_ACTIVITY_EXECUTION_SIZE = 2; + private static final int DEFAULT_MAX_CONCURRENT_NEXUS_EXECUTION_SIZE = 5; + private static final int DEFAULT_MAX_CONCURRENT_WORKFLOW_TASK_POLLERS = 2; + private static final int DEFAULT_MAX_CONCURRENT_ACTIVITY_TASK_POLLERS = 1; + private static final int DEFAULT_MAX_CONCURRENT_NEXUS_TASK_POLLERS = 1; + private static final int DEFAULT_WORKFLOW_CACHE_SIZE = 30; + private static final int DEFAULT_MAX_WORKFLOW_THREAD_COUNT = 30; + + private final WorkflowServiceStubsOptions workflowServiceStubsOptions; + private final WorkflowClientOptions workflowClientOptions; + private final WorkerFactoryOptions workerFactoryOptions; + private final WorkerOptions workerOptions; + private final List registrations; + private final List shutdownHooks; + private final String taskQueue; + private final Duration gracefulShutdownTimeout; + private final Duration shutdownDeadlineBuffer; + private final boolean shutdownDeadlineBufferExplicit; + + private LambdaWorkerOptions( + WorkflowServiceStubsOptions workflowServiceStubsOptions, + WorkflowClientOptions workflowClientOptions, + WorkerFactoryOptions workerFactoryOptions, + WorkerOptions workerOptions, + List registrations, + List shutdownHooks, + String taskQueue, + Duration gracefulShutdownTimeout, + Duration shutdownDeadlineBuffer, + boolean shutdownDeadlineBufferExplicit) { + this.workflowServiceStubsOptions = workflowServiceStubsOptions; + this.workflowClientOptions = workflowClientOptions; + this.workerFactoryOptions = workerFactoryOptions; + this.workerOptions = workerOptions; + this.registrations = Collections.unmodifiableList(new ArrayList<>(registrations)); + this.shutdownHooks = Collections.unmodifiableList(new ArrayList<>(shutdownHooks)); + this.taskQueue = taskQueue; + this.gracefulShutdownTimeout = gracefulShutdownTimeout; + this.shutdownDeadlineBuffer = shutdownDeadlineBuffer; + this.shutdownDeadlineBufferExplicit = shutdownDeadlineBufferExplicit; + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(@Nonnull LambdaWorkerOptions options) { + return new Builder(options); + } + + /** Loads Temporal client configuration from the process environment into a new builder. */ + public static Builder newBuilderFromEnvironment() throws IOException { + return newBuilderFromEnvironment(System.getenv()); + } + + /** + * Loads Temporal client configuration from the provided environment values into a new builder. + */ + public static Builder newBuilderFromEnvironment(Map env) throws IOException { + return newBuilderFromEnvironment(env, new File(".")); + } + + static Builder newBuilderFromEnvironment(Map env, File cwd) throws IOException { + ClientConfigProfile profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFilePath(resolveConfigFilePath(env, cwd)) + .setEnvOverrides(env) + .build()); + return new Builder(profile, env); + } + + static String resolveConfigFilePath(Map env) { + return resolveConfigFilePath(env, new File(".")); + } + + static String resolveConfigFilePath(Map env, File cwd) { + String configured = nonEmptyEnv(env, TEMPORAL_CONFIG_FILE); + if (configured != null) { + return configured; + } + + String taskRoot = nonEmptyEnv(env, LAMBDA_TASK_ROOT); + if (taskRoot != null) { + File lambdaConfig = new File(taskRoot, "temporal.toml"); + if (isReadableFile(lambdaConfig)) { + return lambdaConfig.getAbsolutePath(); + } + } + + File cwdConfig = new File(Objects.requireNonNull(cwd, "cwd"), "temporal.toml"); + return isReadableFile(cwdConfig) ? cwdConfig.getAbsolutePath() : null; + } + + public Builder toBuilder() { + return new Builder(this); + } + + public WorkflowServiceStubsOptions getWorkflowServiceStubsOptions() { + return workflowServiceStubsOptions; + } + + public WorkflowClientOptions getWorkflowClientOptions() { + return workflowClientOptions; + } + + public WorkerFactoryOptions getWorkerFactoryOptions() { + return workerFactoryOptions; + } + + public WorkerOptions getWorkerOptions() { + return workerOptions; + } + + public String getTaskQueue() { + return taskQueue; + } + + public Duration getGracefulShutdownTimeout() { + return gracefulShutdownTimeout; + } + + public Duration getShutdownDeadlineBuffer() { + return shutdownDeadlineBuffer; + } + + Materialized materialize( + @Nonnull WorkerDeploymentVersion version, @Nonnull String invocationIdentity) { + return prepare(version).materialize(invocationIdentity); + } + + Materialized prepare(@Nonnull WorkerDeploymentVersion version) { + validateVersion(version); + if (isNullOrEmpty(taskQueue)) { + throw new IllegalStateException( + "Task queue must be set with LambdaWorkerOptions.Builder#setTaskQueue or TEMPORAL_TASK_QUEUE"); + } + validateShutdownConfiguration(); + + WorkflowClientOptions rawClientOptions = workflowClientOptions; + WorkerOptions rawWorkerOptions = workerOptions; + WorkerFactoryOptions rawFactoryOptions = workerFactoryOptions; + + WorkflowClientOptions.Builder clientOptionsBuilder = + WorkflowClientOptions.newBuilder(rawClientOptions); + WorkerOptions.Builder workerOptionsBuilder = WorkerOptions.newBuilder(rawWorkerOptions); + WorkerFactoryOptions.Builder factoryOptionsBuilder = + WorkerFactoryOptions.newBuilder(rawFactoryOptions); + + if (rawClientOptions.getIdentity() == null && rawWorkerOptions.getIdentity() != null) { + clientOptionsBuilder.setIdentity(rawWorkerOptions.getIdentity()); + } else if (rawWorkerOptions.getIdentity() == null && rawClientOptions.getIdentity() != null) { + workerOptionsBuilder.setIdentity(rawClientOptions.getIdentity()); + } + + applyLambdaWorkerDefaults(rawWorkerOptions, workerOptionsBuilder, version); + applyLambdaFactoryDefaults(rawFactoryOptions, factoryOptionsBuilder); + + return new Materialized( + WorkflowServiceStubsOptions.newBuilder(workflowServiceStubsOptions) + .validateAndBuildWithDefaults(), + clientOptionsBuilder.build(), + factoryOptionsBuilder.validateAndBuildWithDefaults(), + taskQueue, + workerOptionsBuilder.validateAndBuildWithDefaults(), + gracefulShutdownTimeout, + shutdownDeadlineBuffer, + new ArrayList<>(registrations), + new ArrayList<>(shutdownHooks)); + } + + private void validateShutdownConfiguration() { + if (shutdownDeadlineBuffer.compareTo(gracefulShutdownTimeout) < 0) { + throw new IllegalStateException( + "shutdownDeadlineBuffer must be greater than or equal to gracefulShutdownTimeout"); + } + } + + private static void applyLambdaWorkerDefaults( + WorkerOptions rawOptions, WorkerOptions.Builder builder, WorkerDeploymentVersion version) { + if (rawOptions.getWorkerTuner() == null) { + if (rawOptions.getMaxConcurrentActivityExecutionSize() == 0) { + builder.setMaxConcurrentActivityExecutionSize( + DEFAULT_MAX_CONCURRENT_ACTIVITY_EXECUTION_SIZE); + } + if (rawOptions.getMaxConcurrentWorkflowTaskExecutionSize() == 0) { + builder.setMaxConcurrentWorkflowTaskExecutionSize( + DEFAULT_MAX_CONCURRENT_WORKFLOW_TASK_EXECUTION_SIZE); + } + if (rawOptions.getMaxConcurrentLocalActivityExecutionSize() == 0) { + builder.setMaxConcurrentLocalActivityExecutionSize( + DEFAULT_MAX_CONCURRENT_LOCAL_ACTIVITY_EXECUTION_SIZE); + } + if (rawOptions.getMaxConcurrentNexusExecutionSize() == 0) { + builder.setMaxConcurrentNexusExecutionSize(DEFAULT_MAX_CONCURRENT_NEXUS_EXECUTION_SIZE); + } + } + + if (rawOptions.getWorkflowTaskPollersBehavior() == null + && rawOptions.getMaxConcurrentWorkflowTaskPollers() == 0) { + builder.setMaxConcurrentWorkflowTaskPollers(DEFAULT_MAX_CONCURRENT_WORKFLOW_TASK_POLLERS); + } + if (rawOptions.getActivityTaskPollersBehavior() == null + && rawOptions.getMaxConcurrentActivityTaskPollers() == 0) { + builder.setMaxConcurrentActivityTaskPollers(DEFAULT_MAX_CONCURRENT_ACTIVITY_TASK_POLLERS); + } + if (rawOptions.getNexusTaskPollersBehavior() == null + && rawOptions.getMaxConcurrentNexusTaskPollers() == 0) { + builder.setMaxConcurrentNexusTaskPollers(DEFAULT_MAX_CONCURRENT_NEXUS_TASK_POLLERS); + } + + builder.setDisableEagerExecution(true); + builder.setDeploymentOptions( + forcedDeploymentOptions(rawOptions.getDeploymentOptions(), version)); + } + + private static void applyLambdaFactoryDefaults( + WorkerFactoryOptions rawOptions, WorkerFactoryOptions.Builder builder) { + if (rawOptions.getWorkflowCacheSize() == 0) { + builder.setWorkflowCacheSize(DEFAULT_WORKFLOW_CACHE_SIZE); + } + if (rawOptions.getMaxWorkflowThreadCount() == 0) { + builder.setMaxWorkflowThreadCount(DEFAULT_MAX_WORKFLOW_THREAD_COUNT); + } + } + + private static WorkerDeploymentOptions forcedDeploymentOptions( + WorkerDeploymentOptions existing, WorkerDeploymentVersion version) { + VersioningBehavior behavior = VersioningBehavior.PINNED; + if (existing != null + && existing.getDefaultVersioningBehavior() != VersioningBehavior.UNSPECIFIED) { + behavior = existing.getDefaultVersioningBehavior(); + } + + return WorkerDeploymentOptions.newBuilder() + .setUseVersioning(true) + .setVersion(version) + .setDefaultVersioningBehavior(behavior) + .build(); + } + + static void validateVersion(@Nonnull WorkerDeploymentVersion version) { + Objects.requireNonNull(version, "version"); + if (isNullOrEmpty(version.getDeploymentName())) { + throw new IllegalArgumentException("Worker deployment name must be non-empty"); + } + if (isNullOrEmpty(version.getBuildId())) { + throw new IllegalArgumentException("Worker deployment build ID must be non-empty"); + } + } + + private static Duration requireNonNegative(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isNegative()) { + throw new IllegalArgumentException(name + " must not be negative"); + } + return value; + } + + private static boolean isReadableFile(File file) { + return file.isFile() && file.canRead(); + } + + private static String nonEmptyEnv(Map env, String name) { + if (env == null) { + return null; + } + String value = env.get(name); + return isNullOrEmpty(value) ? null : value; + } + + private static boolean isNullOrEmpty(String value) { + return value == null || value.trim().isEmpty(); + } + + private static Class[] copyClasses(Class... classes) { + Objects.requireNonNull(classes, "classes"); + for (Class workflowImplementationClass : classes) { + Objects.requireNonNull(workflowImplementationClass, "workflowImplementationClass"); + } + return Arrays.copyOf(classes, classes.length); + } + + private static Object[] copyObjects(Object[] objects, String name) { + Objects.requireNonNull(objects, name); + return Arrays.copyOf(objects, objects.length); + } + + public static final class Builder { + private final WorkflowServiceStubsOptions.Builder workflowServiceStubsOptionsBuilder; + private final WorkflowClientOptions.Builder workflowClientOptionsBuilder; + private final WorkerFactoryOptions.Builder workerFactoryOptionsBuilder; + private final WorkerOptions.Builder workerOptionsBuilder; + private final List registrations = new ArrayList<>(); + private final List shutdownHooks = new ArrayList<>(); + + private String taskQueue; + private Duration gracefulShutdownTimeout = DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT; + private Duration shutdownDeadlineBuffer = DEFAULT_SHUTDOWN_DEADLINE_BUFFER; + private boolean shutdownDeadlineBufferExplicit; + + private Builder() { + this.workflowServiceStubsOptionsBuilder = WorkflowServiceStubsOptions.newBuilder(); + this.workflowClientOptionsBuilder = WorkflowClientOptions.newBuilder(); + this.workerFactoryOptionsBuilder = WorkerFactoryOptions.newBuilder(); + this.workerOptionsBuilder = WorkerOptions.newBuilder(); + } + + private Builder(ClientConfigProfile profile, Map env) { + this.workflowServiceStubsOptionsBuilder = + WorkflowServiceStubsOptions.newBuilder(profile.toWorkflowServiceStubsOptions()); + this.workflowClientOptionsBuilder = + WorkflowClientOptions.newBuilder(profile.toWorkflowClientOptions()); + this.workerFactoryOptionsBuilder = WorkerFactoryOptions.newBuilder(); + this.workerOptionsBuilder = WorkerOptions.newBuilder(); + this.taskQueue = nonEmptyEnv(env, TEMPORAL_TASK_QUEUE); + } + + private Builder(LambdaWorkerOptions options) { + Objects.requireNonNull(options, "options"); + this.workflowServiceStubsOptionsBuilder = + WorkflowServiceStubsOptions.newBuilder(options.workflowServiceStubsOptions); + this.workflowClientOptionsBuilder = + WorkflowClientOptions.newBuilder(options.workflowClientOptions); + this.workerFactoryOptionsBuilder = + WorkerFactoryOptions.newBuilder(options.workerFactoryOptions); + this.workerOptionsBuilder = WorkerOptions.newBuilder(options.workerOptions); + this.registrations.addAll(options.registrations); + this.shutdownHooks.addAll(options.shutdownHooks); + this.taskQueue = options.taskQueue; + this.gracefulShutdownTimeout = options.gracefulShutdownTimeout; + this.shutdownDeadlineBuffer = options.shutdownDeadlineBuffer; + this.shutdownDeadlineBufferExplicit = options.shutdownDeadlineBufferExplicit; + } + + /** Returns the builder used to prepare {@link WorkflowServiceStubsOptions}. */ + public WorkflowServiceStubsOptions.Builder getWorkflowServiceStubsOptionsBuilder() { + return workflowServiceStubsOptionsBuilder; + } + + /** Returns the builder used to prepare {@link WorkflowClientOptions}. */ + public WorkflowClientOptions.Builder getWorkflowClientOptionsBuilder() { + return workflowClientOptionsBuilder; + } + + /** Returns the builder used to prepare {@link WorkerFactoryOptions}. */ + public WorkerFactoryOptions.Builder getWorkerFactoryOptionsBuilder() { + return workerFactoryOptionsBuilder; + } + + /** Returns the builder used to prepare {@link WorkerOptions}. */ + public WorkerOptions.Builder getWorkerOptionsBuilder() { + return workerOptionsBuilder; + } + + public String getTaskQueue() { + return taskQueue; + } + + /** Sets the Temporal task queue polled by the per-invocation worker. */ + public Builder setTaskQueue(String taskQueue) { + this.taskQueue = taskQueue; + return this; + } + + public Duration getGracefulShutdownTimeout() { + return gracefulShutdownTimeout; + } + + /** + * Sets how long worker shutdown waits for pollers and executions to stop. + * + *

If {@link #setShutdownDeadlineBuffer(Duration)} has not been called, the shutdown deadline + * buffer is recomputed as this timeout plus a 2 second hook and service stubs margin. + */ + public Builder setGracefulShutdownTimeout(@Nonnull Duration gracefulShutdownTimeout) { + this.gracefulShutdownTimeout = + requireNonNegative(gracefulShutdownTimeout, "gracefulShutdownTimeout"); + if (!shutdownDeadlineBufferExplicit) { + shutdownDeadlineBuffer = + this.gracefulShutdownTimeout.plus(DEFAULT_SHUTDOWN_HOOKS_AND_STUBS_TIMEOUT); + } + return this; + } + + public Duration getShutdownDeadlineBuffer() { + return shutdownDeadlineBuffer; + } + + List getShutdownHooks() { + return new ArrayList<>(shutdownHooks); + } + + /** + * Sets the full shutdown window reserved at the end of the Lambda invocation. + * + *

The worker stops when remaining invocation time reaches this buffer. The default is 7 + * seconds, made up of the 5 second graceful shutdown timeout and a 2 second hook and service + * stubs margin. This buffer must be greater than or equal to {@link + * #getGracefulShutdownTimeout()}. + */ + public Builder setShutdownDeadlineBuffer(@Nonnull Duration shutdownDeadlineBuffer) { + this.shutdownDeadlineBuffer = + requireNonNegative(shutdownDeadlineBuffer, "shutdownDeadlineBuffer"); + shutdownDeadlineBufferExplicit = true; + return this; + } + + public Builder registerWorkflowImplementationTypes( + @Nonnull Class... workflowImplementationClasses) { + final Class[] classes = copyClasses(workflowImplementationClasses); + registrations.add(registrar -> registrar.registerWorkflowImplementationTypes(classes)); + return this; + } + + public Builder registerWorkflowImplementationTypes( + @Nonnull WorkflowImplementationOptions options, + @Nonnull Class... workflowImplementationClasses) { + Objects.requireNonNull(options, "options"); + final Class[] classes = copyClasses(workflowImplementationClasses); + registrations.add( + registrar -> registrar.registerWorkflowImplementationTypes(options, classes)); + return this; + } + + /** + * Registers a dynamic workflow implementation type. + * + *

Only one dynamic workflow implementation type can be registered per worker. + */ + public Builder registerDynamicWorkflowImplementationType( + @Nonnull Class workflowImplementationClass) { + final Class implementationClass = + Objects.requireNonNull(workflowImplementationClass, "workflowImplementationClass"); + registrations.add( + registrar -> registrar.registerWorkflowImplementationTypes(implementationClass)); + return this; + } + + /** + * Registers a dynamic workflow implementation type with custom workflow implementation options. + * + *

Only one dynamic workflow implementation type can be registered per worker. + */ + public Builder registerDynamicWorkflowImplementationType( + @Nonnull WorkflowImplementationOptions options, + @Nonnull Class workflowImplementationClass) { + Objects.requireNonNull(options, "options"); + final Class implementationClass = + Objects.requireNonNull(workflowImplementationClass, "workflowImplementationClass"); + registrations.add( + registrar -> registrar.registerWorkflowImplementationTypes(options, implementationClass)); + return this; + } + + public Builder registerWorkflowImplementationFactory( + @Nonnull Class workflowInterface, @Nonnull Functions.Func factory) { + Objects.requireNonNull(workflowInterface, "workflowInterface"); + Objects.requireNonNull(factory, "factory"); + registrations.add( + registrar -> registrar.registerWorkflowImplementationFactory(workflowInterface, factory)); + return this; + } + + public Builder registerWorkflowImplementationFactory( + @Nonnull Class workflowInterface, + @Nonnull Functions.Func factory, + @Nonnull WorkflowImplementationOptions options) { + Objects.requireNonNull(workflowInterface, "workflowInterface"); + Objects.requireNonNull(factory, "factory"); + Objects.requireNonNull(options, "options"); + registrations.add( + registrar -> + registrar.registerWorkflowImplementationFactory(workflowInterface, factory, options)); + return this; + } + + public Builder registerWorkflowImplementationFactory( + @Nonnull Class workflowInterface, + @Nonnull Functions.Func1 factory, + @Nonnull WorkflowImplementationOptions options) { + Objects.requireNonNull(workflowInterface, "workflowInterface"); + Objects.requireNonNull(factory, "factory"); + Objects.requireNonNull(options, "options"); + registrations.add( + registrar -> + registrar.registerWorkflowImplementationFactory(workflowInterface, factory, options)); + return this; + } + + public Builder registerActivitiesImplementations(@Nonnull Object... activityImplementations) { + final Object[] implementations = + copyObjects(activityImplementations, "activityImplementations"); + registrations.add(registrar -> registrar.registerActivitiesImplementations(implementations)); + return this; + } + + /** + * Registers a dynamic activity implementation. + * + *

Only one dynamic activity implementation can be registered per worker. + */ + public Builder registerDynamicActivityImplementation( + @Nonnull DynamicActivity activityImplementation) { + final DynamicActivity implementation = + Objects.requireNonNull(activityImplementation, "activityImplementation"); + registrations.add(registrar -> registrar.registerActivitiesImplementations(implementation)); + return this; + } + + public Builder registerNexusServiceImplementation( + @Nonnull Object... nexusServiceImplementations) { + final Object[] implementations = + copyObjects(nexusServiceImplementations, "nexusServiceImplementations"); + registrations.add(registrar -> registrar.registerNexusServiceImplementation(implementations)); + return this; + } + + /** + * Adds a shutdown hook that runs after the worker has stopped and before service stubs close. + */ + public Builder addShutdownHook(@Nonnull Runnable hook) { + shutdownHooks.add(Objects.requireNonNull(hook, "hook")); + return this; + } + + public LambdaWorkerOptions build() { + return new LambdaWorkerOptions( + workflowServiceStubsOptionsBuilder.build(), + workflowClientOptionsBuilder.build(), + workerFactoryOptionsBuilder.build(), + workerOptionsBuilder.build(), + registrations, + shutdownHooks, + taskQueue, + gracefulShutdownTimeout, + shutdownDeadlineBuffer, + shutdownDeadlineBufferExplicit); + } + } + + interface Registration { + void apply(WorkerRegistrar registrar); + } + + static final class Materialized { + final WorkflowServiceStubsOptions serviceStubsOptions; + final WorkflowClientOptions clientOptions; + final WorkerFactoryOptions workerFactoryOptions; + final String taskQueue; + final WorkerOptions workerOptions; + final Duration gracefulShutdownTimeout; + final Duration shutdownDeadlineBuffer; + final List registrations; + final List shutdownHooks; + + private Materialized( + WorkflowServiceStubsOptions serviceStubsOptions, + WorkflowClientOptions clientOptions, + WorkerFactoryOptions workerFactoryOptions, + String taskQueue, + WorkerOptions workerOptions, + Duration gracefulShutdownTimeout, + Duration shutdownDeadlineBuffer, + List registrations, + List shutdownHooks) { + this.serviceStubsOptions = serviceStubsOptions; + this.clientOptions = clientOptions; + this.workerFactoryOptions = workerFactoryOptions; + this.taskQueue = taskQueue; + this.workerOptions = workerOptions; + this.gracefulShutdownTimeout = gracefulShutdownTimeout; + this.shutdownDeadlineBuffer = shutdownDeadlineBuffer; + this.registrations = Collections.unmodifiableList(registrations); + this.shutdownHooks = Collections.unmodifiableList(shutdownHooks); + } + + Materialized materialize(@Nonnull String invocationIdentity) { + WorkflowClientOptions.Builder clientOptionsBuilder = + WorkflowClientOptions.newBuilder(clientOptions); + WorkerOptions.Builder workerOptionsBuilder = WorkerOptions.newBuilder(workerOptions); + + if (clientOptions.getIdentity() == null && workerOptions.getIdentity() == null) { + clientOptionsBuilder.setIdentity(invocationIdentity); + workerOptionsBuilder.setIdentity(invocationIdentity); + } + + return new Materialized( + serviceStubsOptions, + clientOptionsBuilder.validateAndBuildWithDefaults(), + workerFactoryOptions, + taskQueue, + workerOptionsBuilder.validateAndBuildWithDefaults(), + gracefulShutdownTimeout, + shutdownDeadlineBuffer, + new ArrayList<>(registrations), + new ArrayList<>(shutdownHooks)); + } + } +} diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerRuntime.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerRuntime.java new file mode 100644 index 0000000000..e296d4bc00 --- /dev/null +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/LambdaWorkerRuntime.java @@ -0,0 +1,32 @@ +package io.temporal.aws.lambda; + +import io.temporal.client.WorkflowClientOptions; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkerOptions; +import java.time.Duration; + +interface LambdaWorkerRuntime { + Invocation create( + WorkflowServiceStubsOptions serviceStubsOptions, + WorkflowClientOptions clientOptions, + WorkerFactoryOptions workerFactoryOptions, + String taskQueue, + WorkerOptions workerOptions); + + interface Invocation { + WorkerRegistrar getWorkerRegistrar(); + + void start(); + + void shutdown(); + + void shutdownNow(); + + void awaitTermination(Duration timeout); + + boolean isTerminated(); + + void closeStubs(Duration timeout); + } +} diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java new file mode 100644 index 0000000000..235352b7ac --- /dev/null +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java @@ -0,0 +1,280 @@ +package io.temporal.aws.lambda; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.contrib.awsxray.AwsXrayIdGenerator; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.temporal.opentelemetry.OpenTelemetryPlugin; +import io.temporal.opentelemetry.OpenTelemetryWorker; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.serviceclient.WorkflowServiceStubsPlugin; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** OpenTelemetry configuration helper for {@link LambdaWorker}. */ +public final class OtelLambdaWorkerConfigurationHelper { + public static final String OTEL_EXPORTER_OTLP_ENDPOINT = + OpenTelemetryWorker.OTEL_EXPORTER_OTLP_ENDPOINT; + public static final String OTEL_SERVICE_NAME = OpenTelemetryWorker.OTEL_SERVICE_NAME; + public static final String AWS_LAMBDA_FUNCTION_NAME = "AWS_LAMBDA_FUNCTION_NAME"; + public static final String DEFAULT_OTLP_ENDPOINT = OpenTelemetryWorker.DEFAULT_OTLP_ENDPOINT; + public static final String DEFAULT_SERVICE_NAME = "temporal-lambda-worker"; + + private static final Duration DEFAULT_METRICS_REPORT_INTERVAL = Duration.ofSeconds(1); + + private OtelLambdaWorkerConfigurationHelper() {} + + public static Builder newBuilder() { + return new Builder(System.getenv()); + } + + static Builder newBuilder(Map env) { + return new Builder(env); + } + + public static String getDefaultEndpoint() { + return OpenTelemetryWorker.getDefaultEndpoint(); + } + + public static String getDefaultServiceName() { + return resolveServiceName(System.getenv()); + } + + public static void configure(@Nonnull LambdaWorkerOptions.Builder options) { + configure(options, builder -> {}); + } + + /** + * Configures metrics, tracing interceptors, and per-invocation flushing for a Lambda worker. + * + *

By default this method creates an OpenTelemetry SDK with OTLP trace and metric exporters and + * AWS X-Ray-compatible trace ID generation. If {@link Builder#setOpenTelemetry(OpenTelemetry)} is + * used, the provided instance is used instead and exporters are not created. + */ + public static void configure( + @Nonnull LambdaWorkerOptions.Builder options, @Nonnull Consumer configure) { + Objects.requireNonNull(options, "options"); + Builder builder = newBuilder(); + Objects.requireNonNull(configure, "configure").accept(builder); + builder.apply(options); + } + + /** + * Configures Temporal metrics with the default service name and reporting interval. + * + *

This helper installs the metrics scope and registers a hook that reports buffered Tally + * metrics before provider flushing. It does not configure tracing interceptors or an + * OpenTelemetry provider flush hook. + */ + public static void configureMetrics( + @Nonnull LambdaWorkerOptions.Builder options, @Nonnull OpenTelemetry openTelemetry) { + configureMetrics( + options, openTelemetry, getDefaultServiceName(), DEFAULT_METRICS_REPORT_INTERVAL); + } + + /** + * Configures Temporal metrics with an application-owned OpenTelemetry provider. + * + *

This helper installs the metrics scope and registers a hook that reports buffered Tally + * metrics before provider flushing. It does not configure tracing interceptors or an + * OpenTelemetry provider flush hook. + */ + public static void configureMetrics( + @Nonnull LambdaWorkerOptions.Builder options, + @Nonnull OpenTelemetry openTelemetry, + @Nonnull String serviceName, + @Nonnull Duration reportInterval) { + Objects.requireNonNull(options, "options"); + OpenTelemetryWorker.configureMetrics( + options.getWorkflowServiceStubsOptionsBuilder(), + options::addShutdownHook, + openTelemetry, + serviceName, + reportInterval); + } + + /** + * Configures Temporal tracing interceptors with an application-owned OpenTelemetry provider. + * + *

This helper only installs tracing interceptors. It does not configure metrics or register a + * flush hook. + */ + public static void configureTracing( + @Nonnull LambdaWorkerOptions.Builder options, @Nonnull OpenTelemetry openTelemetry) { + Objects.requireNonNull(options, "options"); + OpenTelemetryWorker.configureTracing( + options.getWorkflowClientOptionsBuilder(), + options.getWorkerFactoryOptionsBuilder(), + openTelemetry); + } + + /** + * Registers a per-invocation OpenTelemetry force-flush hook. + * + *

This helper only registers the flush hook. It does not configure metrics or tracing. + */ + public static void configureFlushHook( + @Nonnull LambdaWorkerOptions.Builder options, + @Nonnull OpenTelemetry openTelemetry, + @Nonnull Duration flushTimeout) { + Objects.requireNonNull(options, "options"); + OpenTelemetryWorker.configureFlushHook(options::addShutdownHook, openTelemetry, flushTimeout); + } + + static String resolveServiceName(Map env) { + String serviceName = nonEmptyEnv(env, OTEL_SERVICE_NAME); + if (serviceName != null) { + return serviceName; + } + serviceName = nonEmptyEnv(env, AWS_LAMBDA_FUNCTION_NAME); + return serviceName == null ? DEFAULT_SERVICE_NAME : serviceName; + } + + public static final class Builder { + private final Map env; + private final OpenTelemetryPlugin.Builder delegate; + private OpenTelemetry openTelemetry; + private String serviceName; + private Duration metricsReportInterval = DEFAULT_METRICS_REPORT_INTERVAL; + private Duration flushTimeout = Duration.ofSeconds(10); + private TelemetryFactory telemetryFactory; + + private Builder(Map env) { + this.env = Objects.requireNonNull(env, "env"); + this.delegate = + OpenTelemetryPlugin.newBuilder(env) + .setIdGenerator(AwsXrayIdGenerator.getInstance()) + .setFlushOnWorkerFactoryShutdown(false); + } + + /** + * Uses an application-owned OpenTelemetry instance instead of creating an SDK and exporters. + */ + public Builder setOpenTelemetry(@Nonnull OpenTelemetry openTelemetry) { + this.openTelemetry = Objects.requireNonNull(openTelemetry, "openTelemetry"); + delegate.setOpenTelemetry(openTelemetry); + return this; + } + + /** Sets the OTLP metric and trace exporter endpoint used by the default SDK setup. */ + public Builder setEndpoint(@Nonnull String endpoint) { + delegate.setEndpoint(endpoint); + return this; + } + + /** Sets the service name used by the default SDK resource and Temporal metrics reporter. */ + public Builder setServiceName(@Nonnull String serviceName) { + this.serviceName = Objects.requireNonNull(serviceName, "serviceName"); + delegate.setServiceName(serviceName); + return this; + } + + /** Sets the interval used by the Tally metrics scope and periodic metric reader. */ + public Builder setMetricsReportInterval(@Nonnull Duration metricsReportInterval) { + delegate.setMetricsReportInterval(metricsReportInterval); + this.metricsReportInterval = metricsReportInterval; + return this; + } + + /** Sets how long the per-invocation OpenTelemetry flush hook waits for provider flushing. */ + public Builder setFlushTimeout(@Nonnull Duration flushTimeout) { + delegate.setFlushTimeout(flushTimeout); + this.flushTimeout = flushTimeout; + return this; + } + + /** Overrides the per-invocation flush hook. */ + public Builder setFlushHook(@Nonnull Runnable flushHook) { + delegate.setFlushHook(flushHook); + return this; + } + + public String getEndpoint() { + return delegate.getEndpoint(); + } + + public String getServiceName() { + return serviceName == null ? resolveServiceName(env) : serviceName; + } + + Builder setTelemetryFactory(TelemetryFactory telemetryFactory) { + this.telemetryFactory = Objects.requireNonNull(telemetryFactory, "telemetryFactory"); + return this; + } + + OpenTelemetry createOpenTelemetry() { + applyLambdaServiceNameDefault(); + if (telemetryFactory != null) { + return openTelemetry == null ? getOrCreateTestOpenTelemetry() : openTelemetry; + } + return delegate.createOpenTelemetry(); + } + + void apply(LambdaWorkerOptions.Builder options) { + Objects.requireNonNull(options, "options"); + applyLambdaDefaults(); + OpenTelemetryPlugin plugin = delegate.build(); + appendServiceStubsPlugin(options.getWorkflowServiceStubsOptionsBuilder(), plugin); + options.addShutdownHook(plugin.newFlushHook()); + } + + private void applyLambdaDefaults() { + applyLambdaServiceNameDefault(); + if (telemetryFactory != null && openTelemetry == null) { + delegate.setOpenTelemetry(getOrCreateTestOpenTelemetry()); + } + } + + private void applyLambdaServiceNameDefault() { + delegate.setServiceName(getServiceName()); + } + + private OpenTelemetry getOrCreateTestOpenTelemetry() { + if (openTelemetry == null) { + openTelemetry = createTestOpenTelemetry(); + } + return openTelemetry; + } + + private OpenTelemetry createTestOpenTelemetry() { + return telemetryFactory.create( + getEndpoint(), + getServiceName(), + metricsReportInterval, + flushTimeout, + AwsXrayIdGenerator.getInstance()); + } + } + + interface TelemetryFactory { + OpenTelemetry create( + String endpoint, + String serviceName, + Duration metricsReportInterval, + Duration flushTimeout, + IdGenerator idGenerator); + } + + private static String nonEmptyEnv(Map env, String name) { + if (env == null) { + return null; + } + String value = env.get(name); + return value == null || value.trim().isEmpty() ? null : value; + } + + private static void appendServiceStubsPlugin( + WorkflowServiceStubsOptions.Builder options, WorkflowServiceStubsPlugin plugin) { + WorkflowServiceStubsPlugin[] existing = options.build().getPlugins(); + int existingLength = existing == null ? 0 : existing.length; + WorkflowServiceStubsPlugin[] plugins = + existingLength == 0 + ? new WorkflowServiceStubsPlugin[1] + : Arrays.copyOf(existing, existingLength + 1); + plugins[existingLength] = Objects.requireNonNull(plugin, "plugin"); + options.setPlugins(plugins); + } +} diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/WorkerRegistrar.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/WorkerRegistrar.java new file mode 100644 index 0000000000..53a47ce6a4 --- /dev/null +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/WorkerRegistrar.java @@ -0,0 +1,27 @@ +package io.temporal.aws.lambda; + +import io.temporal.common.converter.EncodedValues; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.Functions; + +interface WorkerRegistrar { + void registerWorkflowImplementationTypes(Class... workflowImplementationClasses); + + void registerWorkflowImplementationTypes( + WorkflowImplementationOptions options, Class... workflowImplementationClasses); + + void registerWorkflowImplementationFactory( + Class workflowInterface, Functions.Func factory); + + void registerWorkflowImplementationFactory( + Class workflowInterface, + Functions.Func1 factory, + WorkflowImplementationOptions options); + + void registerWorkflowImplementationFactory( + Class workflowInterface, Functions.Func factory, WorkflowImplementationOptions options); + + void registerActivitiesImplementations(Object... activityImplementations); + + void registerNexusServiceImplementation(Object... nexusServiceImplementations); +} diff --git a/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerLifecycleTest.java b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerLifecycleTest.java new file mode 100644 index 0000000000..b4c7640884 --- /dev/null +++ b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerLifecycleTest.java @@ -0,0 +1,848 @@ +package io.temporal.aws.lambda; + +import static org.junit.Assert.*; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import io.temporal.activity.DynamicActivity; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.SimplePlugin; +import io.temporal.common.WorkerDeploymentVersion; +import io.temporal.common.converter.EncodedValues; +import io.temporal.opentelemetry.TimedShutdownHook; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.DynamicWorkflow; +import io.temporal.workflow.Functions; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class LambdaWorkerLifecycleTest { + private static final WorkerDeploymentVersion VERSION = + new WorkerDeploymentVersion("deployment", "build"); + + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void invocationLifecycleRunsInOrder() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> + options + .setTaskQueue("task-queue") + .registerWorkflowImplementationTypes(TestWorkflowImpl.class) + .registerActivitiesImplementations(new Object()) + .registerNexusServiceImplementation(new Object()) + .addShutdownHook(() -> runtime.events.add("hook-1")) + .addShutdownHook(() -> runtime.events.add("hook-2")), + runtime, + duration -> runtime.events.add("sleep:" + duration.toMillis())); + + handler.handleRequest(null, context(20_000)); + + assertEquals( + events( + "create", + "registerWorkflowTypes:1", + "registerActivities:1", + "registerNexus:1", + "start", + "sleep:13000", + "shutdown", + "await:5000", + "hook-1", + "hook-2", + "close:2000"), + runtime.events); + } + + @Test + public void dynamicRegistrationsReplayBeforeWorkerStart() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> + options + .setTaskQueue("task-queue") + .registerDynamicWorkflowImplementationType(TestDynamicWorkflow.class) + .registerDynamicWorkflowImplementationType( + WorkflowImplementationOptions.getDefaultInstance(), + TestDynamicWorkflowWithOptions.class) + .registerDynamicActivityImplementation(new TestDynamicActivity()), + runtime); + + handler.handleRequest(null, context(20_000)); + + assertEquals( + events( + "create", + "registerWorkflowTypes:1", + "registerWorkflowTypesWithOptions:1", + "registerActivities:1", + "start", + "shutdown", + "await:5000", + "close:2000"), + runtime.events); + } + + @Test + public void configureRunsOnceAndRuntimeIsCreatedOncePerInvocation() { + AtomicInteger configureCount = new AtomicInteger(); + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> { + configureCount.incrementAndGet(); + options.setTaskQueue("task-queue"); + }, + runtime); + + assertEquals(1, configureCount.get()); + + handler.handleRequest(null, context(20_000, "request-1", "function-arn-1")); + assertEquals("request-1@function-arn-1", runtime.clientOptions.getIdentity()); + assertEquals("request-1@function-arn-1", runtime.workerOptions.getIdentity()); + + handler.handleRequest(null, context(20_000, "request-2", "function-arn-2")); + assertEquals("request-2@function-arn-2", runtime.clientOptions.getIdentity()); + assertEquals("request-2@function-arn-2", runtime.workerOptions.getIdentity()); + + assertEquals(1, configureCount.get()); + assertEquals(2, runtime.createCount); + } + + @Test + public void perInvocationConfigureRunsOncePerInvocation() { + AtomicInteger coldStartConfigureCount = new AtomicInteger(); + AtomicInteger invocationConfigureCount = new AtomicInteger(); + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> { + coldStartConfigureCount.incrementAndGet(); + options.setTaskQueue("task-queue"); + }, + (options, context) -> { + invocationConfigureCount.incrementAndGet(); + options.getWorkflowClientOptionsBuilder().setNamespace(context.getAwsRequestId()); + }, + runtime); + + assertEquals(1, coldStartConfigureCount.get()); + + handler.handleRequest(null, context(20_000, "request-1", "function-arn-1")); + assertEquals("request-1", runtime.clientOptions.getNamespace()); + + handler.handleRequest(null, context(20_000, "request-2", "function-arn-2")); + assertEquals("request-2", runtime.clientOptions.getNamespace()); + + assertEquals(1, coldStartConfigureCount.get()); + assertEquals(2, invocationConfigureCount.get()); + assertEquals(2, runtime.createCount); + } + + @Test + public void perInvocationConfigureCanSupplyTaskQueue() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> {}, + (options, context) -> options.setTaskQueue(context.getAwsRequestId()), + runtime); + + handler.handleRequest(null, context(20_000, "request-task-queue", "function-arn")); + + assertEquals("request-task-queue", runtime.taskQueue); + assertEquals(1, runtime.createCount); + } + + @Test + public void missingTaskQueueWithPerInvocationConfigureFailsDuringInvocation() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler(options -> {}, (options, context) -> {}, runtime); + + assertThrows(IllegalStateException.class, () -> handler.handleRequest(null, context(20_000))); + + assertEquals(0, runtime.createCount); + } + + @Test + public void perInvocationShutdownHooksDoNotAccumulateAcrossInvocations() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> options.setTaskQueue("task-queue"), + (options, context) -> + options.addShutdownHook( + () -> runtime.events.add("hook:" + context.getAwsRequestId())), + runtime); + + handler.handleRequest(null, context(20_000, "request-1", "function-arn")); + handler.handleRequest(null, context(20_000, "request-2", "function-arn")); + + assertEquals(1, Collections.frequency(runtime.events, "hook:request-1")); + assertEquals(1, Collections.frequency(runtime.events, "hook:request-2")); + } + + @Test + public void perInvocationShutdownHooksRunWhenConfigureThrows() { + FakeRuntime runtime = new FakeRuntime(); + FakeMonotonicClock clock = new FakeMonotonicClock(); + AtomicReference hookTimeout = new AtomicReference<>(); + RequestHandler handler = + handler( + options -> {}, + (options, context) -> { + options.setTaskQueue("task-queue"); + options.addShutdownHook( + new TimedShutdownHook() { + @Override + public void run() { + run(Duration.ZERO); + } + + @Override + public void run(Duration timeout) { + hookTimeout.set(timeout); + runtime.events.add("cleanup"); + } + }); + throw new RuntimeException("configure failed"); + }, + runtime, + duration -> {}, + clock); + + RuntimeException e = + assertThrows(RuntimeException.class, () -> handler.handleRequest(null, context(20_000))); + + assertEquals("configure failed", e.getMessage()); + assertEquals(0, runtime.createCount); + assertEquals(Duration.ofSeconds(2), hookTimeout.get()); + assertEquals(events("cleanup"), runtime.events); + } + + @Test + public void perInvocationShutdownHooksRunWhenFinalValidationFails() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> {}, + (options, context) -> options.addShutdownHook(() -> runtime.events.add("cleanup")), + runtime); + + assertThrows(IllegalStateException.class, () -> handler.handleRequest(null, context(20_000))); + + assertEquals(0, runtime.createCount); + assertEquals(events("cleanup"), runtime.events); + } + + @Test + public void newHandlerSupportsPerInvocationConfigure() throws IOException { + FakeRuntime runtime = new FakeRuntime(); + LambdaWorkerOptions options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()).build(); + RequestHandler handler = + LambdaWorker.newHandler( + VERSION, + options, + (builder, context) -> { + builder.setTaskQueue("task-queue"); + builder.getWorkerOptionsBuilder().setIdentity("worker-" + context.getAwsRequestId()); + }, + runtime, + duration -> {}, + new FakeMonotonicClock()); + + handler.handleRequest(null, context(20_000, "request-1", "function-arn")); + + assertEquals("task-queue", runtime.taskQueue); + assertEquals("worker-request-1", runtime.workerOptions.getIdentity()); + assertEquals("worker-request-1", runtime.clientOptions.getIdentity()); + } + + @Test + public void missingTaskQueueFailsDuringHandlerConstruction() { + FakeRuntime runtime = new FakeRuntime(); + + assertThrows(IllegalStateException.class, () -> handler(options -> {}, runtime)); + + assertEquals(0, runtime.createCount); + } + + @Test + public void envconfigValuesAreVisibleToConfigureBeforeInvocation() throws IOException { + File config = temporaryFolder.newFile("temporal.toml"); + Files.write( + config.toPath(), + "[profile.default]\nnamespace = \"configured\"\n".getBytes(StandardCharsets.UTF_8)); + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.TEMPORAL_CONFIG_FILE, config.getAbsolutePath()); + AtomicReference namespaceInConfigure = new AtomicReference<>(); + FakeRuntime runtime = new FakeRuntime(); + + RequestHandler handler = + handler( + env, + options -> { + namespaceInConfigure.set( + options.getWorkflowClientOptionsBuilder().build().getNamespace()); + options.setTaskQueue("task-queue"); + }, + runtime, + duration -> {}); + + assertEquals("configured", namespaceInConfigure.get()); + + handler.handleRequest(null, context(20_000)); + + assertEquals("configured", runtime.clientOptions.getNamespace()); + } + + @Test + public void identityUsesAwsRequestIdAndFunctionArn() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler(options -> options.setTaskQueue("task-queue"), runtime); + + handler.handleRequest(null, context(20_000)); + + assertEquals("request-id@function-arn", runtime.clientOptions.getIdentity()); + assertEquals("request-id@function-arn", runtime.workerOptions.getIdentity()); + } + + @Test + public void userProvidedClientIdentityIsPreserved() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> { + options.setTaskQueue("task-queue"); + options.getWorkflowClientOptionsBuilder().setIdentity("custom-client"); + }, + runtime); + + handler.handleRequest(null, context(20_000)); + + assertEquals("custom-client", runtime.clientOptions.getIdentity()); + assertEquals("custom-client", runtime.workerOptions.getIdentity()); + } + + @Test + public void userProvidedWorkerIdentityIsPreserved() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> { + options.setTaskQueue("task-queue"); + options.getWorkerOptionsBuilder().setIdentity("custom-worker"); + }, + runtime); + + handler.handleRequest(null, context(20_000)); + + assertEquals("custom-worker", runtime.workerOptions.getIdentity()); + assertEquals("custom-worker", runtime.clientOptions.getIdentity()); + } + + @Test + public void insufficientRemainingTimeThrowsBeforeRuntimeCreation() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler(options -> options.setTaskQueue("task-queue"), runtime); + + assertThrows(IllegalStateException.class, () -> handler.handleRequest(null, context(8_000))); + + assertEquals(0, runtime.createCount); + } + + @Test + public void lowRemainingTimeStillStartsAndSleepsUntilShutdownBuffer() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> options.setTaskQueue("task-queue"), + runtime, + duration -> runtime.events.add("sleep:" + duration.toMillis())); + + handler.handleRequest(null, context(10_500)); + + assertTrue(runtime.events.contains("start")); + assertTrue(runtime.events.contains("sleep:3500")); + } + + @Test + public void shutdownHookErrorsDoNotSkipLaterHooks() { + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> + options + .setTaskQueue("task-queue") + .addShutdownHook( + () -> { + runtime.events.add("hook-1"); + throw new RuntimeException("hook failed"); + }) + .addShutdownHook(() -> runtime.events.add("hook-2")), + runtime); + + handler.handleRequest(null, context(20_000)); + + assertTrue(runtime.events.contains("hook-1")); + assertTrue(runtime.events.contains("hook-2")); + assertEquals("close:2000", runtime.events.get(runtime.events.size() - 1)); + } + + @Test + public void shutdownHooksAndStubsShareCleanupDeadline() { + FakeRuntime runtime = new FakeRuntime(); + FakeMonotonicClock clock = new FakeMonotonicClock(); + AtomicReference hookTimeout = new AtomicReference<>(); + RequestHandler handler = + handler( + options -> + options + .setTaskQueue("task-queue") + .addShutdownHook( + new TimedShutdownHook() { + @Override + public void run() { + run(Duration.ZERO); + } + + @Override + public void run(Duration timeout) { + hookTimeout.set(timeout); + clock.advance(Duration.ofMillis(1500)); + } + }), + runtime, + duration -> {}, + clock); + + handler.handleRequest(null, context(20_000)); + + assertEquals(Duration.ofSeconds(2), hookTimeout.get()); + assertEquals("close:500", runtime.events.get(runtime.events.size() - 1)); + } + + @Test + public void workerShutdownEscalatesAfterGracefulTimeoutAndSharesCleanupDeadline() { + FakeMonotonicClock clock = new FakeMonotonicClock(); + FakeRuntime runtime = new FakeRuntime(false, true, clock, Duration.ofMillis(1500)); + RequestHandler handler = + handler(options -> options.setTaskQueue("task-queue"), runtime, duration -> {}, clock); + + handler.handleRequest(null, context(20_000)); + + assertEquals( + events( + "create", "start", "shutdown", "await:5000", "shutdownNow", "await:2000", "close:500"), + runtime.events); + } + + @Test + public void workerShutdownEscalatesWhenGracefulAwaitThrows() { + FakeMonotonicClock clock = new FakeMonotonicClock(); + FakeRuntime runtime = new FakeRuntime(false, true, clock, Duration.ZERO, true); + RequestHandler handler = + handler(options -> options.setTaskQueue("task-queue"), runtime, duration -> {}, clock); + + handler.handleRequest(null, context(20_000)); + + assertEquals( + events( + "create", "start", "shutdown", "await:5000", "shutdownNow", "await:2000", "close:2000"), + runtime.events); + } + + @Test + public void defaultRuntimeShutsDownFactoryWhenWorkerCreationFails() { + AtomicInteger factoryShutdowns = new AtomicInteger(); + DefaultLambdaWorkerRuntime runtime = new DefaultLambdaWorkerRuntime(); + + WorkerFactoryOptions factoryOptions = + WorkerFactoryOptions.newBuilder() + .setPlugins( + new SimplePlugin("failing-worker-initializer") { + @Override + public void configureWorker(String taskQueue, WorkerOptions.Builder builder) { + throw new RuntimeException("worker configuration failed"); + } + + @Override + public void shutdownWorkerFactory( + WorkerFactory factory, Consumer next) { + factoryShutdowns.incrementAndGet(); + next.accept(factory); + } + }) + .build(); + + RuntimeException e = + assertThrows( + RuntimeException.class, + () -> + runtime.create( + WorkflowServiceStubsOptions.newBuilder() + .setRpcTimeout(Duration.ofMillis(10)) + .setSystemInfoTimeout(Duration.ofMillis(10)) + .build(), + WorkflowClientOptions.newBuilder().build(), + factoryOptions, + "task-queue", + WorkerOptions.newBuilder().build())); + + assertEquals("worker configuration failed", e.getMessage()); + assertEquals(1, factoryShutdowns.get()); + } + + private RequestHandler handler( + java.util.function.Consumer configure, FakeRuntime runtime) { + return handler(configure, runtime, duration -> {}); + } + + private RequestHandler handler( + java.util.function.Consumer configure, + LambdaWorker.InvocationConfigurator invocationConfigure, + FakeRuntime runtime) { + return handler(configure, invocationConfigure, runtime, duration -> {}); + } + + private RequestHandler handler( + java.util.function.Consumer configure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper) { + return handler(baseEnv(), configure, runtime, sleeper, new FakeMonotonicClock()); + } + + private RequestHandler handler( + java.util.function.Consumer configure, + LambdaWorker.InvocationConfigurator invocationConfigure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper) { + return handler( + baseEnv(), configure, invocationConfigure, runtime, sleeper, new FakeMonotonicClock()); + } + + private RequestHandler handler( + java.util.function.Consumer configure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper, + LambdaWorker.MonotonicClock clock) { + return handler(baseEnv(), configure, runtime, sleeper, clock); + } + + private RequestHandler handler( + java.util.function.Consumer configure, + LambdaWorker.InvocationConfigurator invocationConfigure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper, + LambdaWorker.MonotonicClock clock) { + return handler(baseEnv(), configure, invocationConfigure, runtime, sleeper, clock); + } + + private RequestHandler handler( + Map env, + java.util.function.Consumer configure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper) { + return handler(env, configure, runtime, sleeper, new FakeMonotonicClock()); + } + + private RequestHandler handler( + Map env, + java.util.function.Consumer configure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper, + LambdaWorker.MonotonicClock clock) { + try { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(env); + configure.accept(options); + return LambdaWorker.newHandler(VERSION, options.build(), runtime, sleeper, clock); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private RequestHandler handler( + Map env, + java.util.function.Consumer configure, + LambdaWorker.InvocationConfigurator invocationConfigure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper, + LambdaWorker.MonotonicClock clock) { + try { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(env); + configure.accept(options); + return LambdaWorker.newHandler( + VERSION, options.build(), invocationConfigure, runtime, sleeper, clock); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private Context context(int remainingTimeMillis) { + return new TestLambdaContext(remainingTimeMillis); + } + + private Context context(int remainingTimeMillis, String awsRequestId, String invokedFunctionArn) { + return new TestLambdaContext(remainingTimeMillis, awsRequestId, invokedFunctionArn); + } + + private Map baseEnv() { + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.TEMPORAL_CONFIG_FILE, "/nonexistent/temporal.toml"); + return env; + } + + private List events(String... events) { + List result = new ArrayList<>(); + for (String event : events) { + result.add(event); + } + return result; + } + + private static final class FakeMonotonicClock implements LambdaWorker.MonotonicClock { + private long nowNanos; + + @Override + public long nanoTime() { + return nowNanos; + } + + private void advance(Duration duration) { + nowNanos += duration.toNanos(); + } + } + + private static final class FakeRuntime implements LambdaWorkerRuntime { + private final List events = new ArrayList<>(); + private final boolean gracefulTerminates; + private final boolean forcedTerminates; + private final FakeMonotonicClock clock; + private final Duration forcedAwaitDuration; + private final boolean gracefulAwaitThrows; + private int createCount; + private WorkflowClientOptions clientOptions; + private WorkerOptions workerOptions; + private String taskQueue; + + private FakeRuntime() { + this(true, true, null, Duration.ZERO, false); + } + + private FakeRuntime( + boolean gracefulTerminates, + boolean forcedTerminates, + FakeMonotonicClock clock, + Duration forcedAwaitDuration) { + this(gracefulTerminates, forcedTerminates, clock, forcedAwaitDuration, false); + } + + private FakeRuntime( + boolean gracefulTerminates, + boolean forcedTerminates, + FakeMonotonicClock clock, + Duration forcedAwaitDuration, + boolean gracefulAwaitThrows) { + this.gracefulTerminates = gracefulTerminates; + this.forcedTerminates = forcedTerminates; + this.clock = clock; + this.forcedAwaitDuration = forcedAwaitDuration; + this.gracefulAwaitThrows = gracefulAwaitThrows; + } + + @Override + public Invocation create( + WorkflowServiceStubsOptions serviceStubsOptions, + WorkflowClientOptions clientOptions, + WorkerFactoryOptions workerFactoryOptions, + String taskQueue, + WorkerOptions workerOptions) { + createCount++; + events.add("create"); + this.clientOptions = clientOptions; + this.workerOptions = workerOptions; + this.taskQueue = taskQueue; + return new FakeInvocation( + events, + gracefulTerminates, + forcedTerminates, + clock, + forcedAwaitDuration, + gracefulAwaitThrows); + } + } + + private static final class FakeInvocation implements LambdaWorkerRuntime.Invocation { + private final List events; + private final WorkerRegistrar registrar; + private final boolean gracefulTerminates; + private final boolean forcedTerminates; + private final FakeMonotonicClock clock; + private final Duration forcedAwaitDuration; + private final boolean gracefulAwaitThrows; + private boolean terminated; + private int awaitCount; + + private FakeInvocation( + List events, + boolean gracefulTerminates, + boolean forcedTerminates, + FakeMonotonicClock clock, + Duration forcedAwaitDuration, + boolean gracefulAwaitThrows) { + this.events = events; + this.registrar = new FakeWorkerRegistrar(events); + this.gracefulTerminates = gracefulTerminates; + this.forcedTerminates = forcedTerminates; + this.clock = clock; + this.forcedAwaitDuration = forcedAwaitDuration; + this.gracefulAwaitThrows = gracefulAwaitThrows; + } + + @Override + public WorkerRegistrar getWorkerRegistrar() { + return registrar; + } + + @Override + public void start() { + events.add("start"); + } + + @Override + public void shutdown() { + events.add("shutdown"); + terminated = false; + } + + @Override + public void shutdownNow() { + events.add("shutdownNow"); + } + + @Override + public void awaitTermination(Duration timeout) { + events.add("await:" + timeout.toMillis()); + awaitCount++; + if (awaitCount == 1 && gracefulAwaitThrows) { + throw new RuntimeException("await failed"); + } + if (awaitCount == 1 && gracefulTerminates) { + terminated = true; + } else if (awaitCount > 1) { + if (clock != null) { + clock.advance(forcedAwaitDuration); + } + terminated = forcedTerminates; + } + } + + @Override + public boolean isTerminated() { + return terminated; + } + + @Override + public void closeStubs(Duration timeout) { + events.add("close:" + timeout.toMillis()); + } + } + + private static final class FakeWorkerRegistrar implements WorkerRegistrar { + private final List events; + + private FakeWorkerRegistrar(List events) { + this.events = events; + } + + @Override + public void registerWorkflowImplementationTypes(Class... workflowImplementationClasses) { + events.add("registerWorkflowTypes:" + workflowImplementationClasses.length); + } + + @Override + public void registerWorkflowImplementationTypes( + WorkflowImplementationOptions options, Class... workflowImplementationClasses) { + events.add("registerWorkflowTypesWithOptions:" + workflowImplementationClasses.length); + } + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, Functions.Func factory) { + events.add("registerWorkflowFactory"); + } + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, + Functions.Func1 factory, + WorkflowImplementationOptions options) { + events.add("registerWorkflowFactoryWithArgs"); + } + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, + Functions.Func factory, + WorkflowImplementationOptions options) { + events.add("registerWorkflowFactoryWithOptions"); + } + + @Override + public void registerActivitiesImplementations(Object... activityImplementations) { + events.add("registerActivities:" + activityImplementations.length); + } + + @Override + public void registerNexusServiceImplementation(Object... nexusServiceImplementations) { + events.add("registerNexus:" + nexusServiceImplementations.length); + } + } + + private static final class TestWorkflowImpl {} + + private static final class TestDynamicWorkflow implements DynamicWorkflow { + @Override + public Object execute(EncodedValues args) { + return null; + } + } + + private static final class TestDynamicWorkflowWithOptions implements DynamicWorkflow { + @Override + public Object execute(EncodedValues args) { + return null; + } + } + + private static final class TestDynamicActivity implements DynamicActivity { + @Override + public Object execute(EncodedValues args) { + return null; + } + } +} diff --git a/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerOptionsTest.java b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerOptionsTest.java new file mode 100644 index 0000000000..39c9cd6547 --- /dev/null +++ b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/LambdaWorkerOptionsTest.java @@ -0,0 +1,339 @@ +package io.temporal.aws.lambda; + +import static org.junit.Assert.*; + +import io.temporal.activity.DynamicActivity; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.VersioningBehavior; +import io.temporal.common.WorkerDeploymentVersion; +import io.temporal.common.converter.EncodedValues; +import io.temporal.worker.WorkerDeploymentOptions; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.WorkflowImplementationOptions; +import io.temporal.workflow.DynamicWorkflow; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class LambdaWorkerOptionsTest { + private static final WorkerDeploymentVersion VERSION = + new WorkerDeploymentVersion("deployment", "build"); + + @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder(); + + @Test + public void lambdaDefaultsAreAppliedToUnsetOptions() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + options.setTaskQueue("task-queue"); + + LambdaWorkerOptions.Materialized materialized = + options.build().materialize(VERSION, "request@arn"); + + WorkerOptions workerOptions = materialized.workerOptions; + assertEquals(2, workerOptions.getMaxConcurrentActivityExecutionSize()); + assertEquals(10, workerOptions.getMaxConcurrentWorkflowTaskExecutionSize()); + assertEquals(2, workerOptions.getMaxConcurrentLocalActivityExecutionSize()); + assertEquals(5, workerOptions.getMaxConcurrentNexusExecutionSize()); + assertEquals(2, workerOptions.getMaxConcurrentWorkflowTaskPollers()); + assertEquals(1, workerOptions.getMaxConcurrentActivityTaskPollers()); + assertEquals(1, workerOptions.getMaxConcurrentNexusTaskPollers()); + assertTrue(workerOptions.isEagerExecutionDisabled()); + + WorkerFactoryOptions factoryOptions = materialized.workerFactoryOptions; + assertEquals(30, factoryOptions.getWorkflowCacheSize()); + assertEquals(30, factoryOptions.getMaxWorkflowThreadCount()); + + WorkerDeploymentOptions deploymentOptions = workerOptions.getDeploymentOptions(); + assertTrue(deploymentOptions.isUsingVersioning()); + assertEquals(VERSION, deploymentOptions.getVersion()); + assertEquals(VersioningBehavior.PINNED, deploymentOptions.getDefaultVersioningBehavior()); + assertEquals(Duration.ofSeconds(5), materialized.gracefulShutdownTimeout); + assertEquals(Duration.ofSeconds(7), materialized.shutdownDeadlineBuffer); + } + + @Test + public void userOverridesWinOverLambdaDefaults() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + options.setTaskQueue("task-queue"); + options.getWorkerOptionsBuilder().setMaxConcurrentActivityExecutionSize(11); + options.getWorkerOptionsBuilder().setMaxConcurrentWorkflowTaskExecutionSize(12); + options.getWorkerOptionsBuilder().setMaxConcurrentLocalActivityExecutionSize(13); + options.getWorkerOptionsBuilder().setMaxConcurrentNexusExecutionSize(14); + options.getWorkerOptionsBuilder().setMaxConcurrentWorkflowTaskPollers(3); + options.getWorkerOptionsBuilder().setMaxConcurrentActivityTaskPollers(4); + options.getWorkerOptionsBuilder().setMaxConcurrentNexusTaskPollers(6); + options + .getWorkerOptionsBuilder() + .setDeploymentOptions( + WorkerDeploymentOptions.newBuilder() + .setUseVersioning(true) + .setVersion(new WorkerDeploymentVersion("ignored", "ignored")) + .setDefaultVersioningBehavior(VersioningBehavior.AUTO_UPGRADE) + .build()); + options.getWorkerFactoryOptionsBuilder().setWorkflowCacheSize(17); + options.getWorkerFactoryOptionsBuilder().setMaxWorkflowThreadCount(18); + + LambdaWorkerOptions.Materialized materialized = + options.build().materialize(VERSION, "request@arn"); + + WorkerOptions workerOptions = materialized.workerOptions; + assertEquals(11, workerOptions.getMaxConcurrentActivityExecutionSize()); + assertEquals(12, workerOptions.getMaxConcurrentWorkflowTaskExecutionSize()); + assertEquals(13, workerOptions.getMaxConcurrentLocalActivityExecutionSize()); + assertEquals(14, workerOptions.getMaxConcurrentNexusExecutionSize()); + assertEquals(3, workerOptions.getMaxConcurrentWorkflowTaskPollers()); + assertEquals(4, workerOptions.getMaxConcurrentActivityTaskPollers()); + assertEquals(6, workerOptions.getMaxConcurrentNexusTaskPollers()); + assertTrue(workerOptions.isEagerExecutionDisabled()); + assertEquals(17, materialized.workerFactoryOptions.getWorkflowCacheSize()); + assertEquals(18, materialized.workerFactoryOptions.getMaxWorkflowThreadCount()); + assertTrue(workerOptions.getDeploymentOptions().isUsingVersioning()); + assertEquals(VERSION, workerOptions.getDeploymentOptions().getVersion()); + assertEquals( + VersioningBehavior.AUTO_UPGRADE, + workerOptions.getDeploymentOptions().getDefaultVersioningBehavior()); + } + + @Test + public void temporalTaskQueueEnvPopulatesTaskQueue() throws IOException { + Map env = baseEnv(); + env.put(LambdaWorkerOptions.TEMPORAL_TASK_QUEUE, "env-task-queue"); + + LambdaWorkerOptions.Materialized materialized = + LambdaWorkerOptions.newBuilderFromEnvironment(env) + .build() + .materialize(VERSION, "request@arn"); + + assertEquals("env-task-queue", materialized.taskQueue); + } + + @Test + public void missingTaskQueueFailsBeforeRuntimeCreation() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + + IllegalStateException e = + assertThrows( + IllegalStateException.class, () -> options.build().materialize(VERSION, "request@arn")); + + assertTrue(e.getMessage().contains("Task queue must be set")); + } + + @Test + public void gracefulShutdownTimeoutRecomputesDefaultShutdownDeadlineBuffer() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + options.setTaskQueue("task-queue"); + options.setGracefulShutdownTimeout(Duration.ofSeconds(3)); + + LambdaWorkerOptions.Materialized materialized = + options.build().materialize(VERSION, "request@arn"); + + assertEquals(Duration.ofSeconds(3), materialized.gracefulShutdownTimeout); + assertEquals(Duration.ofSeconds(5), materialized.shutdownDeadlineBuffer); + } + + @Test + public void explicitShutdownDeadlineBufferIsNotRecomputed() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + options.setTaskQueue("task-queue"); + options.setShutdownDeadlineBuffer(Duration.ofSeconds(9)); + options.setGracefulShutdownTimeout(Duration.ofSeconds(3)); + + LambdaWorkerOptions.Materialized materialized = + options.build().materialize(VERSION, "request@arn"); + + assertEquals(Duration.ofSeconds(3), materialized.gracefulShutdownTimeout); + assertEquals(Duration.ofSeconds(9), materialized.shutdownDeadlineBuffer); + } + + @Test + public void buildCreatesImmutableSnapshot() throws IOException { + LambdaWorkerOptions.Builder builder = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + builder.setTaskQueue("first-task-queue"); + + LambdaWorkerOptions options = builder.build(); + builder.setTaskQueue("second-task-queue"); + + assertEquals("first-task-queue", options.getTaskQueue()); + assertEquals("first-task-queue", options.materialize(VERSION, "request@arn").taskQueue); + } + + @Test + public void toBuilderPreservesExplicitShutdownDeadlineBuffer() throws IOException { + LambdaWorkerOptions options = + LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()) + .setTaskQueue("task-queue") + .setShutdownDeadlineBuffer(Duration.ofSeconds(9)) + .setGracefulShutdownTimeout(Duration.ofSeconds(3)) + .build(); + + LambdaWorkerOptions.Materialized materialized = + options.toBuilder() + .setGracefulShutdownTimeout(Duration.ofSeconds(4)) + .build() + .materialize(VERSION, "request@arn"); + + assertEquals(Duration.ofSeconds(4), materialized.gracefulShutdownTimeout); + assertEquals(Duration.ofSeconds(9), materialized.shutdownDeadlineBuffer); + } + + @Test + public void shutdownDeadlineBufferMustCoverGracefulShutdownTimeout() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + options.setTaskQueue("task-queue"); + options.setShutdownDeadlineBuffer(Duration.ofSeconds(2)); + + IllegalStateException e = + assertThrows( + IllegalStateException.class, () -> options.build().materialize(VERSION, "request@arn")); + + assertTrue(e.getMessage().contains("shutdownDeadlineBuffer")); + } + + @Test + public void temporalConfigFileTakesPrecedenceOverLambdaTaskRoot() throws IOException { + File explicitConfig = temporaryFolder.newFile("explicit.toml"); + Files.write( + explicitConfig.toPath(), + "[profile.default]\nnamespace = \"explicit\"\n".getBytes(StandardCharsets.UTF_8)); + File taskRoot = temporaryFolder.newFolder("task-root"); + Files.write( + new File(taskRoot, "temporal.toml").toPath(), + "[profile.default]\nnamespace = \"lambda-root\"\n".getBytes(StandardCharsets.UTF_8)); + + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.TEMPORAL_CONFIG_FILE, explicitConfig.getAbsolutePath()); + env.put(LambdaWorkerOptions.LAMBDA_TASK_ROOT, taskRoot.getAbsolutePath()); + + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(env); + options.setTaskQueue("task-queue"); + WorkflowClientOptions clientOptions = + options.build().materialize(VERSION, "request@arn").clientOptions; + + assertEquals("explicit", clientOptions.getNamespace()); + } + + @Test + public void lambdaTaskRootTemporalTomlWinsOverCwdTemporalToml() throws IOException { + File taskRoot = temporaryFolder.newFolder("task-root"); + writeConfig(taskRoot, "lambda-root"); + File cwd = temporaryFolder.newFolder("cwd"); + writeConfig(cwd, "cwd"); + + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.LAMBDA_TASK_ROOT, taskRoot.getAbsolutePath()); + + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(env, cwd); + options.setTaskQueue("task-queue"); + + assertEquals( + "lambda-root", + options.build().materialize(VERSION, "request@arn").clientOptions.getNamespace()); + } + + @Test + public void cwdTemporalTomlIsUsedWhenLambdaTaskRootIsUnset() throws IOException { + File cwd = temporaryFolder.newFolder("cwd"); + writeConfig(cwd, "cwd"); + + LambdaWorkerOptions.Builder options = + LambdaWorkerOptions.newBuilderFromEnvironment(new HashMap<>(), cwd); + options.setTaskQueue("task-queue"); + + assertEquals( + "cwd", options.build().materialize(VERSION, "request@arn").clientOptions.getNamespace()); + } + + @Test + public void cwdTemporalTomlIsUsedWhenLambdaTaskRootHasNoTemporalToml() throws IOException { + File taskRoot = temporaryFolder.newFolder("task-root"); + File cwd = temporaryFolder.newFolder("cwd"); + writeConfig(cwd, "cwd"); + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.LAMBDA_TASK_ROOT, taskRoot.getAbsolutePath()); + + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(env, cwd); + options.setTaskQueue("task-queue"); + + assertEquals( + "cwd", options.build().materialize(VERSION, "request@arn").clientOptions.getNamespace()); + } + + @Test + public void envconfigDefaultsAreUsedWhenNoConfigFileCandidateExists() throws IOException { + File cwd = temporaryFolder.newFolder("cwd"); + + LambdaWorkerOptions.Builder options = + LambdaWorkerOptions.newBuilderFromEnvironment(new HashMap<>(), cwd); + options.setTaskQueue("task-queue"); + + assertEquals( + "default", + options.build().materialize(VERSION, "request@arn").clientOptions.getNamespace()); + } + + @Test + public void deploymentVersionMustHaveDeploymentNameAndBuildId() { + assertThrows( + IllegalArgumentException.class, + () -> LambdaWorkerOptions.validateVersion(new WorkerDeploymentVersion("", "build"))); + assertThrows( + IllegalArgumentException.class, + () -> LambdaWorkerOptions.validateVersion(new WorkerDeploymentVersion("deployment", ""))); + } + + @Test + public void dynamicRegistrationMethodsRejectNullInputs() throws IOException { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + + assertThrows( + NullPointerException.class, + () -> + options.registerDynamicWorkflowImplementationType( + (Class) null)); + assertThrows( + NullPointerException.class, + () -> options.registerDynamicWorkflowImplementationType(null, TestDynamicWorkflow.class)); + assertThrows( + NullPointerException.class, + () -> + options.registerDynamicWorkflowImplementationType( + WorkflowImplementationOptions.getDefaultInstance(), null)); + assertThrows( + NullPointerException.class, () -> options.registerDynamicActivityImplementation(null)); + } + + private Map baseEnv() { + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.TEMPORAL_CONFIG_FILE, "/nonexistent/temporal.toml"); + return env; + } + + private void writeConfig(File directory, String namespace) throws IOException { + Files.write( + new File(directory, "temporal.toml").toPath(), + ("[profile.default]\nnamespace = \"" + namespace + "\"\n") + .getBytes(StandardCharsets.UTF_8)); + } + + private static final class TestDynamicWorkflow implements DynamicWorkflow { + @Override + public Object execute(EncodedValues args) { + return null; + } + } + + private static final class TestDynamicActivity implements DynamicActivity { + @Override + public Object execute(EncodedValues args) { + return null; + } + } +} diff --git a/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelperTest.java b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelperTest.java new file mode 100644 index 0000000000..3adac8a55a --- /dev/null +++ b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelperTest.java @@ -0,0 +1,458 @@ +package io.temporal.aws.lambda; + +import static org.junit.Assert.*; + +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.RequestHandler; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.temporal.common.WorkerDeploymentVersion; +import io.temporal.opentelemetry.OpenTelemetryPlugin; +import io.temporal.opentelemetry.TimedShutdownHook; +import io.temporal.serviceclient.WorkflowServiceStubsPlugin; +import java.io.Closeable; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Test; + +public class OtelLambdaWorkerConfigurationHelperTest { + private static final WorkerDeploymentVersion VERSION = + new WorkerDeploymentVersion("deployment", "build"); + + @Test + public void defaultsResolveEndpointAndServiceName() { + assertEquals( + "http://localhost:4317", + OtelLambdaWorkerConfigurationHelper.newBuilder(new HashMap<>()).getEndpoint()); + assertEquals( + "temporal-lambda-worker", + OtelLambdaWorkerConfigurationHelper.newBuilder(new HashMap<>()).getServiceName()); + + Map env = new HashMap<>(); + env.put( + OtelLambdaWorkerConfigurationHelper.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + env.put(OtelLambdaWorkerConfigurationHelper.AWS_LAMBDA_FUNCTION_NAME, "function-name"); + assertEquals( + "http://collector:4317", OtelLambdaWorkerConfigurationHelper.newBuilder(env).getEndpoint()); + assertEquals( + "function-name", OtelLambdaWorkerConfigurationHelper.newBuilder(env).getServiceName()); + + env.put(OtelLambdaWorkerConfigurationHelper.OTEL_SERVICE_NAME, "explicit-service"); + assertEquals( + "explicit-service", OtelLambdaWorkerConfigurationHelper.newBuilder(env).getServiceName()); + } + + @Test + public void defaultFactoryCreatesXRayTraceIds() { + OpenTelemetry openTelemetry = + OtelLambdaWorkerConfigurationHelper.newBuilder(new HashMap<>()) + .setFlushTimeout(Duration.ofMillis(10)) + .createOpenTelemetry(); + long beforeSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()); + + assertTrue(openTelemetry instanceof OpenTelemetrySdk); + + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetry; + Span span = sdk.getSdkTracerProvider().get("test").spanBuilder("test").startSpan(); + try { + String traceId = span.getSpanContext().getTraceId(); + long afterSeconds = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()); + long xrayTimestampSeconds = Long.parseLong(traceId.substring(0, 8), 16); + + assertTrue(xrayTimestampSeconds >= beforeSeconds); + assertTrue(xrayTimestampSeconds <= afterSeconds); + } finally { + sdk.shutdown().join(1, TimeUnit.SECONDS); + span.end(); + } + } + + @Test + public void exporterFactoryReceivesResolvedEndpointServiceNameAndIdGenerator() throws Exception { + Map env = new HashMap<>(); + env.put( + OtelLambdaWorkerConfigurationHelper.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + env.put(OtelLambdaWorkerConfigurationHelper.AWS_LAMBDA_FUNCTION_NAME, "function-name"); + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + + OtelLambdaWorkerConfigurationHelper.newBuilder(env).setTelemetryFactory(factory).apply(options); + + assertEquals(1, factory.creates.get()); + assertEquals("http://collector:4317", factory.endpoint); + assertEquals("function-name", factory.serviceName); + assertNotNull(factory.idGenerator); + } + + @Test + public void customOpenTelemetryBypassesExporterCreation() throws Exception { + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + + OtelLambdaWorkerConfigurationHelper.newBuilder(new HashMap<>()) + .setTelemetryFactory(factory) + .setOpenTelemetry(OpenTelemetry.noop()) + .apply(options); + + assertEquals(0, factory.creates.get()); + assertTrue(installedPlugin(options) instanceof OpenTelemetryPlugin); + } + + @Test + public void pluginInstallsMetricsScopeAndTracingInterceptors() throws Exception { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + + OtelLambdaWorkerConfigurationHelper.configure( + options, builder -> builder.setOpenTelemetry(OpenTelemetry.noop()).setFlushHook(() -> {})); + OpenTelemetryPlugin plugin = installedPlugin(options); + + plugin.configureServiceStubs(options.getWorkflowServiceStubsOptionsBuilder()); + plugin.configureWorkflowClient(options.getWorkflowClientOptionsBuilder()); + plugin.configureWorkerFactory(options.getWorkerFactoryOptionsBuilder()); + + assertNotNull(options.getWorkflowServiceStubsOptionsBuilder().build().getMetricsScope()); + assertEquals(1, options.getWorkflowClientOptionsBuilder().build().getInterceptors().length); + assertEquals( + 1, options.getWorkerFactoryOptionsBuilder().build().getWorkerInterceptors().length); + } + + @Test + public void configureRegistersPluginAndPerInvocationFlushHook() throws Exception { + LambdaWorkerOptions.Builder options = LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + + OtelLambdaWorkerConfigurationHelper.configure( + options, builder -> builder.setOpenTelemetry(OpenTelemetry.noop())); + options.setTaskQueue("task-queue"); + + List hooks = options.build().prepare(VERSION).materialize("identity").shutdownHooks; + assertTrue(installedPlugin(options) instanceof OpenTelemetryPlugin); + assertEquals(1, hooks.size()); + assertTrue(hooks.get(0) instanceof TimedShutdownHook); + } + + @Test + public void flushHookReceivesLambdaCleanupTimeoutAndDoesNotCloseProviders() { + TimeoutRecordingOpenTelemetry openTelemetry = new TimeoutRecordingOpenTelemetry(); + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> { + options.setTaskQueue("task-queue"); + OtelLambdaWorkerConfigurationHelper.configureFlushHook( + options, openTelemetry, Duration.ofSeconds(10)); + }, + runtime, + duration -> {}); + + handler.handleRequest(null, context()); + + assertTrue(openTelemetry.tracerProvider.joinTimeoutMillis.get() > 0); + assertTrue(openTelemetry.tracerProvider.joinTimeoutMillis.get() < 10_000); + assertTrue(openTelemetry.meterProvider.joinTimeoutMillis.get() > 0); + assertTrue(openTelemetry.meterProvider.joinTimeoutMillis.get() < 10_000); + assertEquals(0, openTelemetry.tracerProvider.closes.get()); + assertEquals(0, openTelemetry.meterProvider.closes.get()); + } + + @Test + public void flushHookRunsOncePerInvocationAndDoesNotCloseProviders() { + CountingOpenTelemetry openTelemetry = new CountingOpenTelemetry(); + FakeRuntime runtime = new FakeRuntime(); + RequestHandler handler = + handler( + options -> { + options.setTaskQueue("task-queue"); + OtelLambdaWorkerConfigurationHelper.configure( + options, builder -> builder.setOpenTelemetry(openTelemetry)); + }, + runtime, + duration -> {}); + + handler.handleRequest(null, context()); + handler.handleRequest(null, context()); + + assertEquals(2, openTelemetry.tracerProvider.flushes.get()); + assertEquals(2, openTelemetry.meterProvider.flushes.get()); + assertEquals(0, openTelemetry.tracerProvider.closes.get()); + assertEquals(0, openTelemetry.meterProvider.closes.get()); + } + + private Context context() { + return new TestLambdaContext(20_000); + } + + private Map baseEnv() { + Map env = new HashMap<>(); + env.put(LambdaWorkerOptions.TEMPORAL_CONFIG_FILE, "/nonexistent/temporal.toml"); + return env; + } + + private RequestHandler handler( + java.util.function.Consumer configure, + FakeRuntime runtime, + LambdaWorker.Sleeper sleeper) { + try { + LambdaWorkerOptions.Builder options = + LambdaWorkerOptions.newBuilderFromEnvironment(baseEnv()); + configure.accept(options); + return LambdaWorker.newHandler(VERSION, options.build(), runtime, sleeper); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + } + + private OpenTelemetryPlugin installedPlugin(LambdaWorkerOptions.Builder options) { + WorkflowServiceStubsPlugin[] plugins = + options.getWorkflowServiceStubsOptionsBuilder().build().getPlugins(); + assertNotNull(plugins); + assertEquals(1, plugins.length); + assertTrue(plugins[0] instanceof OpenTelemetryPlugin); + return (OpenTelemetryPlugin) plugins[0]; + } + + private static final class RecordingTelemetryFactory + implements OtelLambdaWorkerConfigurationHelper.TelemetryFactory { + private final AtomicInteger creates = new AtomicInteger(); + private String endpoint; + private String serviceName; + private IdGenerator idGenerator; + + @Override + public OpenTelemetry create( + String endpoint, + String serviceName, + Duration metricsReportInterval, + Duration flushTimeout, + IdGenerator idGenerator) { + creates.incrementAndGet(); + this.endpoint = endpoint; + this.serviceName = serviceName; + this.idGenerator = idGenerator; + return OpenTelemetry.noop(); + } + } + + private static final class CountingOpenTelemetry implements OpenTelemetry { + private final CountingTracerProvider tracerProvider = new CountingTracerProvider(); + private final CountingMeterProvider meterProvider = new CountingMeterProvider(); + + @Override + public TracerProvider getTracerProvider() { + return tracerProvider; + } + + @Override + public MeterProvider getMeterProvider() { + return meterProvider; + } + + @Override + public ContextPropagators getPropagators() { + return ContextPropagators.noop(); + } + } + + private static final class TimeoutRecordingOpenTelemetry implements OpenTelemetry { + private final TimeoutRecordingTracerProvider tracerProvider = + new TimeoutRecordingTracerProvider(); + private final TimeoutRecordingMeterProvider meterProvider = new TimeoutRecordingMeterProvider(); + + @Override + public TracerProvider getTracerProvider() { + return tracerProvider; + } + + @Override + public MeterProvider getMeterProvider() { + return meterProvider; + } + + @Override + public ContextPropagators getPropagators() { + return ContextPropagators.noop(); + } + } + + public static final class TimeoutRecordingTracerProvider implements TracerProvider, Closeable { + private final AtomicLong joinTimeoutMillis = new AtomicLong(-1); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public Tracer get(String instrumentationName) { + return TracerProvider.noop().get(instrumentationName); + } + + @Override + public Tracer get(String instrumentationName, String instrumentationVersion) { + return TracerProvider.noop().get(instrumentationName, instrumentationVersion); + } + + public TimeoutRecordingResult forceFlush() { + return new TimeoutRecordingResult(joinTimeoutMillis); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + public static final class TimeoutRecordingMeterProvider implements MeterProvider, Closeable { + private final AtomicLong joinTimeoutMillis = new AtomicLong(-1); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public MeterBuilder meterBuilder(String instrumentationName) { + return MeterProvider.noop().meterBuilder(instrumentationName); + } + + public TimeoutRecordingResult forceFlush() { + return new TimeoutRecordingResult(joinTimeoutMillis); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + public static final class TimeoutRecordingResult { + private final AtomicLong joinTimeoutMillis; + + private TimeoutRecordingResult(AtomicLong joinTimeoutMillis) { + this.joinTimeoutMillis = joinTimeoutMillis; + } + + public TimeoutRecordingResult join(long timeout, TimeUnit unit) { + joinTimeoutMillis.set(unit.toMillis(timeout)); + return this; + } + } + + public static final class CountingTracerProvider implements TracerProvider, Closeable { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public Tracer get(String instrumentationName) { + return TracerProvider.noop().get(instrumentationName); + } + + @Override + public Tracer get(String instrumentationName, String instrumentationVersion) { + return TracerProvider.noop().get(instrumentationName, instrumentationVersion); + } + + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + public static final class CountingMeterProvider implements MeterProvider, Closeable { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public MeterBuilder meterBuilder(String instrumentationName) { + return MeterProvider.noop().meterBuilder(instrumentationName); + } + + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + private static final class FakeRuntime implements LambdaWorkerRuntime { + @Override + public Invocation create( + io.temporal.serviceclient.WorkflowServiceStubsOptions serviceStubsOptions, + io.temporal.client.WorkflowClientOptions clientOptions, + io.temporal.worker.WorkerFactoryOptions workerFactoryOptions, + String taskQueue, + io.temporal.worker.WorkerOptions workerOptions) { + return new Invocation() { + @Override + public WorkerRegistrar getWorkerRegistrar() { + return new NoopRegistrar(); + } + + @Override + public void start() {} + + @Override + public void shutdown() {} + + @Override + public void shutdownNow() {} + + @Override + public void awaitTermination(java.time.Duration timeout) {} + + @Override + public boolean isTerminated() { + return true; + } + + @Override + public void closeStubs(java.time.Duration timeout) {} + }; + } + } + + private static final class NoopRegistrar implements WorkerRegistrar { + @Override + public void registerWorkflowImplementationTypes(Class... workflowImplementationClasses) {} + + @Override + public void registerWorkflowImplementationTypes( + io.temporal.worker.WorkflowImplementationOptions options, + Class... workflowImplementationClasses) {} + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, io.temporal.workflow.Functions.Func factory) {} + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, + io.temporal.workflow.Functions.Func1 factory, + io.temporal.worker.WorkflowImplementationOptions options) {} + + @Override + public void registerWorkflowImplementationFactory( + Class workflowInterface, + io.temporal.workflow.Functions.Func factory, + io.temporal.worker.WorkflowImplementationOptions options) {} + + @Override + public void registerActivitiesImplementations(Object... activityImplementations) {} + + @Override + public void registerNexusServiceImplementation(Object... nexusServiceImplementations) {} + } +} diff --git a/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/TestLambdaContext.java b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/TestLambdaContext.java new file mode 100644 index 0000000000..1d58d6f8b1 --- /dev/null +++ b/contrib/temporal-aws-lambda/src/test/java/io/temporal/aws/lambda/TestLambdaContext.java @@ -0,0 +1,96 @@ +package io.temporal.aws.lambda; + +import com.amazonaws.services.lambda.runtime.ClientContext; +import com.amazonaws.services.lambda.runtime.CognitoIdentity; +import com.amazonaws.services.lambda.runtime.Context; +import com.amazonaws.services.lambda.runtime.LambdaLogger; +import java.util.function.IntSupplier; + +final class TestLambdaContext implements Context { + private static final LambdaLogger NOOP_LOGGER = + new LambdaLogger() { + @Override + public void log(String message) {} + + @Override + public void log(byte[] message) {} + }; + + private final IntSupplier remainingTimeMillis; + private final String awsRequestId; + private final String invokedFunctionArn; + + TestLambdaContext(int remainingTimeMillis) { + this(remainingTimeMillis, "request-id", "function-arn"); + } + + TestLambdaContext(int remainingTimeMillis, String awsRequestId, String invokedFunctionArn) { + this(() -> remainingTimeMillis, awsRequestId, invokedFunctionArn); + } + + TestLambdaContext(IntSupplier remainingTimeMillis) { + this(remainingTimeMillis, "request-id", "function-arn"); + } + + TestLambdaContext( + IntSupplier remainingTimeMillis, String awsRequestId, String invokedFunctionArn) { + this.remainingTimeMillis = remainingTimeMillis; + this.awsRequestId = awsRequestId; + this.invokedFunctionArn = invokedFunctionArn; + } + + @Override + public String getAwsRequestId() { + return awsRequestId; + } + + @Override + public String getLogGroupName() { + return "log-group"; + } + + @Override + public String getLogStreamName() { + return "log-stream"; + } + + @Override + public String getFunctionName() { + return "function"; + } + + @Override + public String getFunctionVersion() { + return "1"; + } + + @Override + public String getInvokedFunctionArn() { + return invokedFunctionArn; + } + + @Override + public CognitoIdentity getIdentity() { + return null; + } + + @Override + public ClientContext getClientContext() { + return null; + } + + @Override + public int getRemainingTimeInMillis() { + return remainingTimeMillis.getAsInt(); + } + + @Override + public int getMemoryLimitInMB() { + return 128; + } + + @Override + public LambdaLogger getLogger() { + return NOOP_LOGGER; + } +} diff --git a/contrib/temporal-opentelemetry/README.md b/contrib/temporal-opentelemetry/README.md new file mode 100644 index 0000000000..ab315279eb --- /dev/null +++ b/contrib/temporal-opentelemetry/README.md @@ -0,0 +1,65 @@ +# Temporal OpenTelemetry module + +This module provides OpenTelemetry integration helpers for Temporal Java SDK workers. + +The Java SDK metrics API is based on Tally `Scope`. `temporal-opentelemetry` bridges that Tally surface into OpenTelemetry metrics, configures tracing through the SDK OpenTracing interceptor path, and provides shutdown hooks that flush Tally and OpenTelemetry providers without closing application-owned providers. + +## Usage + +Add `temporal-opentelemetry` next to your Temporal SDK dependency, then install the plugin on service stubs options before creating clients and workers: + +```java +OpenTelemetryPlugin plugin = OpenTelemetryPlugin.newBuilder().build(); + +WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setPlugins(plugin) + .build()); +WorkflowClient client = WorkflowClient.newInstance(service); +WorkerFactory factory = WorkerFactory.newInstance(client); +``` + +Plugins configured on service stubs propagate to workflow clients and worker factories. By default, the plugin creates an OpenTelemetry SDK with OTLP metric and trace exporters, W3C trace context propagation, an OpenTelemetry-backed Tally metrics scope, OpenTracing-shim client and worker interceptors, and a worker-factory shutdown flush. Buffered Tally metrics are reported before OpenTelemetry providers are force-flushed. Providers are not closed. + +The plugin defaults the OTLP endpoint from `OTEL_EXPORTER_OTLP_ENDPOINT`, then `http://localhost:4317`. It defaults the service name from `OTEL_SERVICE_NAME`, then `temporal-worker`. + +To use an application-owned provider, call `builder.setOpenTelemetry(...)`; in that path, no exporters are created and the plugin only installs the metrics scope, interceptors, and flush hook: + +```java +OpenTelemetryPlugin plugin = + OpenTelemetryPlugin.newBuilder() + .setOpenTelemetry(openTelemetry) + .build(); +``` + +Serverless adapters should disable worker-factory shutdown flushing and run `plugin.newFlushHook()` from their per-invocation cleanup path. If a hook implements `TimedShutdownHook`, pass the remaining cleanup timeout to `run(Duration)`. + +For manual composition or existing integrations, `OpenTelemetryWorker.configure(...)` remains available: + +```java +List shutdownHooks = new ArrayList<>(); + +WorkflowServiceStubsOptions.Builder serviceOptions = WorkflowServiceStubsOptions.newBuilder(); +WorkflowClientOptions.Builder clientOptions = WorkflowClientOptions.newBuilder(); +WorkerFactoryOptions.Builder factoryOptions = WorkerFactoryOptions.newBuilder(); + +OpenTelemetryWorker.configure( + serviceOptions, + clientOptions, + factoryOptions, + shutdownHooks::add); +``` + +Use `OpenTelemetryWorker.configureMetrics(...)`, `OpenTelemetryWorker.configureTracing(...)`, and `OpenTelemetryWorker.configureFlushHook(...)` when you want to compose metrics, tracing, or provider flushing separately. + +To use an application-owned provider with the manual helper: + +```java +OpenTelemetryWorker.configure( + serviceOptions, + clientOptions, + factoryOptions, + shutdownHooks::add, + builder -> builder.setOpenTelemetry(openTelemetry)); +``` diff --git a/contrib/temporal-opentelemetry/build.gradle b/contrib/temporal-opentelemetry/build.gradle new file mode 100644 index 0000000000..d49bd04e05 --- /dev/null +++ b/contrib/temporal-opentelemetry/build.gradle @@ -0,0 +1,32 @@ +description = '''Temporal Java SDK OpenTelemetry Support Module''' + +ext { + otelVersion = '1.25.0' + otShimVersion = "${otelVersion}-alpha" +} + +dependencies { + api platform("io.opentelemetry:opentelemetry-bom:$otelVersion") + + // This module shouldn't carry temporal-sdk with it, especially for situations when users may + // be using a shaded artifact. + compileOnly project(':temporal-serviceclient') + compileOnly project(':temporal-sdk') + compileOnly "javax.annotation:javax.annotation-api:$annotationApiVersion" + + api "com.uber.m3:tally-core:$tallyVersion" + api "io.opentelemetry:opentelemetry-api" + api "io.opentelemetry:opentelemetry-sdk-trace" + + implementation project(':temporal-opentracing') + implementation "io.opentelemetry:opentelemetry-exporter-otlp" + implementation "io.opentelemetry:opentelemetry-opentracing-shim:$otShimVersion" + implementation "io.opentelemetry:opentelemetry-sdk" + implementation "org.slf4j:slf4j-api:$slf4jVersion" + + testImplementation project(':temporal-sdk') + testImplementation project(':temporal-serviceclient') + testImplementation "junit:junit:${junitVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryFlushHook.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryFlushHook.java new file mode 100644 index 0000000000..92a100cf63 --- /dev/null +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryFlushHook.java @@ -0,0 +1,120 @@ +package io.temporal.opentelemetry; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Force-flushes OpenTelemetry providers without closing them. */ +public final class OpenTelemetryFlushHook implements TimedShutdownHook { + private static final Logger log = LoggerFactory.getLogger(OpenTelemetryFlushHook.class); + + private final OpenTelemetry openTelemetry; + private final Duration timeout; + private final MonotonicClock clock; + + public OpenTelemetryFlushHook(@Nonnull OpenTelemetry openTelemetry, @Nonnull Duration timeout) { + this(openTelemetry, timeout, System::nanoTime); + } + + OpenTelemetryFlushHook(OpenTelemetry openTelemetry, Duration timeout, MonotonicClock clock) { + this.openTelemetry = Objects.requireNonNull(openTelemetry, "openTelemetry"); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @Override + public void run() { + run(timeout); + } + + @Override + public void run(@Nonnull Duration timeout) { + long deadlineNanos = clock.nanoTime() + min(timeout, this.timeout).toNanos(); + forceFlush(tracerProvider(), deadlineNanos); + forceFlush(meterProvider(), deadlineNanos); + } + + interface MonotonicClock { + long nanoTime(); + } + + private Object tracerProvider() { + if (openTelemetry instanceof OpenTelemetrySdk) { + return ((OpenTelemetrySdk) openTelemetry).getSdkTracerProvider(); + } + return openTelemetry.getTracerProvider(); + } + + private Object meterProvider() { + if (openTelemetry instanceof OpenTelemetrySdk) { + return ((OpenTelemetrySdk) openTelemetry).getSdkMeterProvider(); + } + return openTelemetry.getMeterProvider(); + } + + private void forceFlush(Object provider, long deadlineNanos) { + if (provider == null) { + return; + } + + try { + Method forceFlush = provider.getClass().getMethod("forceFlush"); + Object result = forceFlush.invoke(provider); + join(result, remainingFlushTime(deadlineNanos)); + } catch (NoSuchMethodException e) { + // The OpenTelemetry API no-op providers do not expose forceFlush. + } catch (IllegalAccessException | InvocationTargetException | RuntimeException e) { + log.warn("OpenTelemetry forceFlush failed provider={}", provider.getClass().getName(), e); + } + } + + private void join(Object result, Duration timeout) { + if (result == null) { + return; + } + + try { + Method join = result.getClass().getMethod("join", long.class, TimeUnit.class); + join.invoke(result, timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (NoSuchMethodException e) { + tryJoinMillis(result, timeout); + } catch (IllegalAccessException | InvocationTargetException | RuntimeException e) { + log.warn("OpenTelemetry forceFlush join failed result={}", result.getClass().getName(), e); + } + } + + private void tryJoinMillis(Object result, Duration timeout) { + try { + Method join = result.getClass().getMethod("join", long.class); + join.invoke(result, timeout.toMillis()); + } catch (NoSuchMethodException e) { + // Some forceFlush result implementations do not expose a blocking join method. + } catch (IllegalAccessException | InvocationTargetException | RuntimeException e) { + log.warn("OpenTelemetry forceFlush join failed result={}", result.getClass().getName(), e); + } + } + + private static Duration requireNonNegative(Duration timeout) { + Objects.requireNonNull(timeout, "timeout"); + return timeout.isNegative() ? Duration.ZERO : timeout; + } + + private Duration remainingFlushTime(long deadlineNanos) { + return requireNonNegative(Duration.ofNanos(deadlineNanos - clock.nanoTime())); + } + + private static Duration min(Duration first, Duration second) { + Duration nonNegativeFirst = requireNonNegative(first); + Duration nonNegativeSecond = requireNonNegative(second); + return nonNegativeFirst.compareTo(nonNegativeSecond) <= 0 + ? nonNegativeFirst + : nonNegativeSecond; + } +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java new file mode 100644 index 0000000000..2604e205f1 --- /dev/null +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java @@ -0,0 +1,252 @@ +package io.temporal.opentelemetry; + +import com.uber.m3.tally.Scope; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.Experimental; +import io.temporal.common.SimplePlugin; +import io.temporal.opentracing.OpenTracingOptions; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** + * OpenTelemetry plugin for Temporal workers. + * + *

The plugin installs an OpenTelemetry-backed Tally metrics scope on service stubs and + * OpenTelemetry-backed tracing interceptors on workflow clients and worker factories. + */ +@Experimental +public final class OpenTelemetryPlugin extends SimplePlugin { + public static final String NAME = "io.temporal.opentelemetry"; + + private final Map env; + private final OpenTelemetryWorker.TelemetryFactory telemetryFactory; + private final String endpoint; + private final String serviceName; + private final Duration metricsReportInterval; + private final Duration flushTimeout; + private final Runnable flushHook; + private final IdGenerator idGenerator; + private final boolean flushOnWorkerFactoryShutdown; + private final List metricsScopes = new ArrayList<>(); + + private OpenTelemetry openTelemetry; + + private OpenTelemetryPlugin(Builder builder) { + super(NAME); + this.env = builder.env; + this.telemetryFactory = builder.telemetryFactory; + this.openTelemetry = builder.openTelemetry; + this.endpoint = builder.endpoint; + this.serviceName = builder.serviceName; + this.metricsReportInterval = builder.metricsReportInterval; + this.flushTimeout = builder.flushTimeout; + this.flushHook = builder.flushHook; + this.idGenerator = builder.idGenerator; + this.flushOnWorkerFactoryShutdown = builder.flushOnWorkerFactoryShutdown; + } + + public static Builder newBuilder() { + return new Builder(System.getenv()); + } + + public static Builder newBuilder(@Nonnull Map env) { + return new Builder(env); + } + + public String getEndpoint() { + return endpoint == null ? OpenTelemetryWorker.resolveEndpoint(env) : endpoint; + } + + public String getServiceName() { + return serviceName == null ? OpenTelemetryWorker.resolveServiceName(env) : serviceName; + } + + public synchronized OpenTelemetry getOpenTelemetry() { + if (openTelemetry == null) { + openTelemetry = + telemetryFactory.create( + getEndpoint(), getServiceName(), metricsReportInterval, flushTimeout, idGenerator); + } + return openTelemetry; + } + + @Override + public void configureServiceStubs(@Nonnull WorkflowServiceStubsOptions.Builder builder) { + Scope scope = + OpenTelemetryWorker.createMetricsScope( + getOpenTelemetry(), getServiceName(), metricsReportInterval); + synchronized (metricsScopes) { + metricsScopes.add(scope); + } + builder.setMetricsScope(scope); + } + + @Override + public void configureWorkflowClient(@Nonnull WorkflowClientOptions.Builder builder) { + OpenTracingOptions tracingOptions = + OpenTelemetryWorker.createOpenTracingOptions(getOpenTelemetry()); + OpenTelemetryWorker.appendClientInterceptor( + builder, new io.temporal.opentracing.OpenTracingClientInterceptor(tracingOptions)); + } + + @Override + public void configureWorkerFactory(@Nonnull WorkerFactoryOptions.Builder builder) { + OpenTracingOptions tracingOptions = + OpenTelemetryWorker.createOpenTracingOptions(getOpenTelemetry()); + OpenTelemetryWorker.appendWorkerInterceptor( + builder, new io.temporal.opentracing.OpenTracingWorkerInterceptor(tracingOptions)); + } + + @Override + public void shutdownWorkerFactory( + @Nonnull WorkerFactory factory, @Nonnull Consumer next) { + next.accept(factory); + if (flushOnWorkerFactoryShutdown) { + newFlushHook().run(); + } + } + + /** + * Creates a flush hook that reports buffered Tally metrics before force-flushing OpenTelemetry + * providers. + */ + public TimedShutdownHook newFlushHook() { + return new TimedShutdownHook() { + @Override + public void run() { + flush(flushTimeout); + } + + @Override + public void run(@Nonnull Duration timeout) { + flush(timeout); + } + }; + } + + private void flush(Duration timeout) { + for (Scope scope : drainMetricsScopes()) { + new TallyScopeFlushHook(scope).run(); + } + if (flushHook == null) { + new OpenTelemetryFlushHook(getOpenTelemetry(), flushTimeout).run(timeout); + } else { + flushHook.run(); + } + } + + private List drainMetricsScopes() { + synchronized (metricsScopes) { + List scopes = new ArrayList<>(metricsScopes); + metricsScopes.clear(); + return scopes; + } + } + + /** Builder for {@link OpenTelemetryPlugin}. */ + public static final class Builder { + private final Map env; + private OpenTelemetry openTelemetry; + private String endpoint; + private String serviceName; + private Duration metricsReportInterval = OpenTelemetryWorker.DEFAULT_METRICS_REPORT_INTERVAL; + private Duration flushTimeout = Duration.ofSeconds(10); + private Runnable flushHook; + private IdGenerator idGenerator; + private OpenTelemetryWorker.TelemetryFactory telemetryFactory = + new OpenTelemetryWorker.DefaultTelemetryFactory(); + private boolean flushOnWorkerFactoryShutdown = true; + + private Builder(Map env) { + this.env = Objects.requireNonNull(env, "env"); + } + + /** + * Uses an application-owned OpenTelemetry instance instead of creating an SDK and exporters. + */ + public Builder setOpenTelemetry(@Nonnull OpenTelemetry openTelemetry) { + this.openTelemetry = Objects.requireNonNull(openTelemetry, "openTelemetry"); + return this; + } + + /** Sets the OTLP metric and trace exporter endpoint used by the default SDK setup. */ + public Builder setEndpoint(@Nonnull String endpoint) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + return this; + } + + /** Sets the service name used by the default SDK resource and Temporal metrics reporter. */ + public Builder setServiceName(@Nonnull String serviceName) { + this.serviceName = Objects.requireNonNull(serviceName, "serviceName"); + return this; + } + + /** Sets the interval used by the Tally metrics scope and periodic metric reader. */ + public Builder setMetricsReportInterval(@Nonnull Duration metricsReportInterval) { + this.metricsReportInterval = + OpenTelemetryWorker.requirePositive(metricsReportInterval, "metricsReportInterval"); + return this; + } + + /** Sets how long the OpenTelemetry flush hook waits for provider flushing. */ + public Builder setFlushTimeout(@Nonnull Duration flushTimeout) { + this.flushTimeout = OpenTelemetryWorker.requireNonNegative(flushTimeout, "flushTimeout"); + return this; + } + + /** Overrides the OpenTelemetry provider flush hook. */ + public Builder setFlushHook(@Nonnull Runnable flushHook) { + this.flushHook = Objects.requireNonNull(flushHook, "flushHook"); + return this; + } + + /** + * Controls whether the plugin flushes when a {@link WorkerFactory} is shut down. + * + *

Serverless integrations should disable this and use {@link + * OpenTelemetryPlugin#newFlushHook()} from their per-invocation cleanup path. + */ + public Builder setFlushOnWorkerFactoryShutdown(boolean flushOnWorkerFactoryShutdown) { + this.flushOnWorkerFactoryShutdown = flushOnWorkerFactoryShutdown; + return this; + } + + /** Sets the trace ID generator used by the default SDK setup. */ + public Builder setIdGenerator(@Nonnull IdGenerator idGenerator) { + this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); + return this; + } + + public String getEndpoint() { + return endpoint == null ? OpenTelemetryWorker.resolveEndpoint(env) : endpoint; + } + + public String getServiceName() { + return serviceName == null ? OpenTelemetryWorker.resolveServiceName(env) : serviceName; + } + + Builder setTelemetryFactory(OpenTelemetryWorker.TelemetryFactory telemetryFactory) { + this.telemetryFactory = Objects.requireNonNull(telemetryFactory, "telemetryFactory"); + return this; + } + + public OpenTelemetry createOpenTelemetry() { + return telemetryFactory.create( + getEndpoint(), getServiceName(), metricsReportInterval, flushTimeout, idGenerator); + } + + public OpenTelemetryPlugin build() { + return new OpenTelemetryPlugin(this); + } + } +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryStatsReporter.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryStatsReporter.java new file mode 100644 index 0000000000..7b9836eb74 --- /dev/null +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryStatsReporter.java @@ -0,0 +1,160 @@ +package io.temporal.opentelemetry; + +import com.uber.m3.tally.Capabilities; +import com.uber.m3.tally.CapableOf; +import com.uber.m3.tally.StatsReporter; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.DoubleHistogram; +import io.opentelemetry.api.metrics.LongCounter; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.ObservableDoubleGauge; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nonnull; + +/** Tally reporter that emits Temporal metrics through OpenTelemetry. */ +public final class OpenTelemetryStatsReporter implements StatsReporter { + private final Meter meter; + private final String serviceName; + private final ConcurrentMap counters = new ConcurrentHashMap<>(); + private final ConcurrentMap timers = new ConcurrentHashMap<>(); + private final ConcurrentMap gauges = new ConcurrentHashMap<>(); + + public OpenTelemetryStatsReporter( + @Nonnull OpenTelemetry openTelemetry, @Nonnull String serviceName) { + this.meter = Objects.requireNonNull(openTelemetry, "openTelemetry").getMeter("io.temporal"); + this.serviceName = Objects.requireNonNull(serviceName, "serviceName"); + } + + @Override + public Capabilities capabilities() { + return CapableOf.REPORTING; + } + + @Override + public void flush() { + // OpenTelemetry SDK flushing is handled by OpenTelemetryWorker's shutdown hook. + } + + @Override + public void close() { + flush(); + } + + @Override + public void reportCounter(String name, Map tags, long value) { + LongCounter counter = counters.computeIfAbsent(name, key -> meter.counterBuilder(key).build()); + counter.add(value, attributes(tags)); + } + + @Override + public void reportGauge(String name, Map tags, double value) { + MetricKey key = new MetricKey(name, tags); + GaugeHolder holder = + gauges.computeIfAbsent( + key, + metricKey -> { + AtomicReference current = new AtomicReference<>(0.0); + ObservableDoubleGauge gauge = + meter + .gaugeBuilder(metricKey.name) + .buildWithCallback( + measurement -> + measurement.record(current.get(), attributes(metricKey.tags))); + return new GaugeHolder(current, gauge); + }); + holder.current.set(value); + } + + @Override + public void reportTimer( + String name, Map tags, com.uber.m3.util.Duration interval) { + DoubleHistogram timer = + timers.computeIfAbsent(name, key -> meter.histogramBuilder(key).setUnit("ms").build()); + timer.record(interval.getNanos() / 1_000_000.0, attributes(tags)); + } + + @Override + @SuppressWarnings("deprecation") + public void reportHistogramValueSamples( + String name, + Map tags, + com.uber.m3.tally.Buckets buckets, + double bucketLowerBound, + double bucketUpperBound, + long samples) { + // Tally reports pre-aggregated bucket samples, while the OpenTelemetry API records raw values. + } + + @Override + @SuppressWarnings("deprecation") + public void reportHistogramDurationSamples( + String name, + Map tags, + com.uber.m3.tally.Buckets buckets, + com.uber.m3.util.Duration bucketLowerBound, + com.uber.m3.util.Duration bucketUpperBound, + long samples) { + // Tally reports pre-aggregated bucket samples, while the OpenTelemetry API records raw values. + } + + private Attributes attributes(Map tags) { + AttributesBuilder builder = Attributes.builder(); + builder.put("service.name", serviceName); + if (tags != null) { + for (Map.Entry entry : tags.entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { + builder.put(entry.getKey(), entry.getValue()); + } + } + } + return builder.build(); + } + + private static final class GaugeHolder { + private final AtomicReference current; + + @SuppressWarnings("unused") + private final ObservableDoubleGauge gauge; + + private GaugeHolder(AtomicReference current, ObservableDoubleGauge gauge) { + this.current = current; + this.gauge = gauge; + } + } + + private static final class MetricKey { + private final String name; + private final Map tags; + + private MetricKey(String name, Map tags) { + this.name = Objects.requireNonNull(name, "name"); + this.tags = + tags == null ? Collections.emptyMap() : Collections.unmodifiableMap(new HashMap<>(tags)); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + MetricKey metricKey = (MetricKey) o; + return name.equals(metricKey.name) && tags.equals(metricKey.tags); + } + + @Override + public int hashCode() { + return Objects.hash(name, tags); + } + } +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java new file mode 100644 index 0000000000..90e7697aac --- /dev/null +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java @@ -0,0 +1,400 @@ +package io.temporal.opentelemetry; + +import com.uber.m3.tally.RootScopeBuilder; +import com.uber.m3.tally.Scope; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.exporter.otlp.metrics.OtlpGrpcMetricExporter; +import io.opentelemetry.exporter.otlp.trace.OtlpGrpcSpanExporter; +import io.opentelemetry.opentracingshim.OpenTracingShim; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.export.MetricExporter; +import io.opentelemetry.sdk.metrics.export.PeriodicMetricReader; +import io.opentelemetry.sdk.resources.Resource; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SdkTracerProviderBuilder; +import io.opentelemetry.sdk.trace.export.BatchSpanProcessor; +import io.opentelemetry.sdk.trace.export.SpanExporter; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.interceptors.WorkerInterceptor; +import io.temporal.common.interceptors.WorkflowClientInterceptor; +import io.temporal.opentracing.OpenTracingClientInterceptor; +import io.temporal.opentracing.OpenTracingOptions; +import io.temporal.opentracing.OpenTracingWorkerInterceptor; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactoryOptions; +import java.time.Duration; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** OpenTelemetry helper for Temporal workers. */ +public final class OpenTelemetryWorker { + public static final String OTEL_EXPORTER_OTLP_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"; + public static final String OTEL_SERVICE_NAME = "OTEL_SERVICE_NAME"; + public static final String DEFAULT_OTLP_ENDPOINT = "http://localhost:4317"; + public static final String DEFAULT_SERVICE_NAME = "temporal-worker"; + + static final Duration DEFAULT_METRICS_REPORT_INTERVAL = Duration.ofSeconds(1); + + private static final AttributeKey SERVICE_NAME_ATTRIBUTE = + AttributeKey.stringKey("service.name"); + + private OpenTelemetryWorker() {} + + public static Builder newBuilder() { + return new Builder(System.getenv()); + } + + public static Builder newBuilder(@Nonnull Map env) { + return new Builder(env); + } + + public static String getDefaultEndpoint() { + return resolveEndpoint(System.getenv()); + } + + public static String getDefaultServiceName() { + return resolveServiceName(System.getenv()); + } + + public static void configure( + @Nonnull WorkflowServiceStubsOptions.Builder serviceStubsOptions, + @Nonnull WorkflowClientOptions.Builder clientOptions, + @Nonnull WorkerFactoryOptions.Builder workerFactoryOptions, + @Nonnull Consumer addShutdownHook) { + configure( + serviceStubsOptions, clientOptions, workerFactoryOptions, addShutdownHook, builder -> {}); + } + + /** + * Configures metrics, tracing interceptors, and flushing for a Temporal worker. + * + *

By default this method creates an OpenTelemetry SDK with OTLP trace and metric exporters. If + * {@link Builder#setOpenTelemetry(OpenTelemetry)} is used, the provided instance is used instead + * and exporters are not created. + */ + public static void configure( + @Nonnull WorkflowServiceStubsOptions.Builder serviceStubsOptions, + @Nonnull WorkflowClientOptions.Builder clientOptions, + @Nonnull WorkerFactoryOptions.Builder workerFactoryOptions, + @Nonnull Consumer addShutdownHook, + @Nonnull Consumer configure) { + Builder builder = newBuilder(); + Objects.requireNonNull(configure, "configure").accept(builder); + builder.apply(serviceStubsOptions, clientOptions, workerFactoryOptions, addShutdownHook); + } + + /** + * Configures Temporal metrics with the default service name and reporting interval. + * + *

This helper installs the metrics scope and registers a hook that reports buffered Tally + * metrics before provider flushing. It does not configure tracing interceptors or an + * OpenTelemetry provider flush hook. + */ + public static void configureMetrics( + @Nonnull WorkflowServiceStubsOptions.Builder serviceStubsOptions, + @Nonnull Consumer addShutdownHook, + @Nonnull OpenTelemetry openTelemetry) { + configureMetrics( + serviceStubsOptions, + addShutdownHook, + openTelemetry, + getDefaultServiceName(), + DEFAULT_METRICS_REPORT_INTERVAL); + } + + /** + * Configures Temporal metrics with an application-owned OpenTelemetry provider. + * + *

This helper installs the metrics scope and registers a hook that reports buffered Tally + * metrics before provider flushing. It does not configure tracing interceptors or an + * OpenTelemetry provider flush hook. + */ + public static void configureMetrics( + @Nonnull WorkflowServiceStubsOptions.Builder serviceStubsOptions, + @Nonnull Consumer addShutdownHook, + @Nonnull OpenTelemetry openTelemetry, + @Nonnull String serviceName, + @Nonnull Duration reportInterval) { + Objects.requireNonNull(serviceStubsOptions, "serviceStubsOptions"); + Objects.requireNonNull(addShutdownHook, "addShutdownHook"); + Scope scope = createMetricsScope(openTelemetry, serviceName, reportInterval); + serviceStubsOptions.setMetricsScope(scope); + addShutdownHook.accept(new TallyScopeFlushHook(scope)); + } + + /** + * Configures Temporal tracing interceptors with an application-owned OpenTelemetry provider. + * + *

This helper only installs tracing interceptors. It does not configure metrics or register a + * flush hook. + */ + public static void configureTracing( + @Nonnull WorkflowClientOptions.Builder clientOptions, + @Nonnull WorkerFactoryOptions.Builder workerFactoryOptions, + @Nonnull OpenTelemetry openTelemetry) { + Objects.requireNonNull(clientOptions, "clientOptions"); + Objects.requireNonNull(workerFactoryOptions, "workerFactoryOptions"); + OpenTracingOptions tracingOptions = + createOpenTracingOptions(Objects.requireNonNull(openTelemetry, "openTelemetry")); + appendClientInterceptor(clientOptions, new OpenTracingClientInterceptor(tracingOptions)); + appendWorkerInterceptor(workerFactoryOptions, new OpenTracingWorkerInterceptor(tracingOptions)); + } + + /** + * Registers an OpenTelemetry force-flush hook. + * + *

This helper only registers the flush hook. It does not configure metrics or tracing. + */ + public static void configureFlushHook( + @Nonnull Consumer addShutdownHook, + @Nonnull OpenTelemetry openTelemetry, + @Nonnull Duration flushTimeout) { + Objects.requireNonNull(addShutdownHook, "addShutdownHook"); + addShutdownHook.accept( + new OpenTelemetryFlushHook( + Objects.requireNonNull(openTelemetry, "openTelemetry"), + requireNonNegative(flushTimeout, "flushTimeout"))); + } + + static String resolveEndpoint(Map env) { + String endpoint = nonEmptyEnv(env, OTEL_EXPORTER_OTLP_ENDPOINT); + return endpoint == null ? DEFAULT_OTLP_ENDPOINT : endpoint; + } + + static String resolveServiceName(Map env) { + String serviceName = nonEmptyEnv(env, OTEL_SERVICE_NAME); + return serviceName == null ? DEFAULT_SERVICE_NAME : serviceName; + } + + public static final class Builder { + private final Map env; + private OpenTelemetry openTelemetry; + private String endpoint; + private String serviceName; + private Duration metricsReportInterval = DEFAULT_METRICS_REPORT_INTERVAL; + private Duration flushTimeout = Duration.ofSeconds(10); + private Runnable flushHook; + private IdGenerator idGenerator; + private TelemetryFactory telemetryFactory = new DefaultTelemetryFactory(); + + private Builder(Map env) { + this.env = Objects.requireNonNull(env, "env"); + } + + /** + * Uses an application-owned OpenTelemetry instance instead of creating an SDK and exporters. + */ + public Builder setOpenTelemetry(@Nonnull OpenTelemetry openTelemetry) { + this.openTelemetry = Objects.requireNonNull(openTelemetry, "openTelemetry"); + return this; + } + + /** Sets the OTLP metric and trace exporter endpoint used by the default SDK setup. */ + public Builder setEndpoint(@Nonnull String endpoint) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + return this; + } + + /** Sets the service name used by the default SDK resource and Temporal metrics reporter. */ + public Builder setServiceName(@Nonnull String serviceName) { + this.serviceName = Objects.requireNonNull(serviceName, "serviceName"); + return this; + } + + /** Sets the interval used by the Tally metrics scope and periodic metric reader. */ + public Builder setMetricsReportInterval(@Nonnull Duration metricsReportInterval) { + this.metricsReportInterval = requirePositive(metricsReportInterval, "metricsReportInterval"); + return this; + } + + /** Sets how long the OpenTelemetry flush hook waits for provider flushing. */ + public Builder setFlushTimeout(@Nonnull Duration flushTimeout) { + this.flushTimeout = requireNonNegative(flushTimeout, "flushTimeout"); + return this; + } + + /** Overrides the OpenTelemetry provider flush hook. */ + public Builder setFlushHook(@Nonnull Runnable flushHook) { + this.flushHook = Objects.requireNonNull(flushHook, "flushHook"); + return this; + } + + /** Sets the trace ID generator used by the default SDK setup. */ + public Builder setIdGenerator(@Nonnull IdGenerator idGenerator) { + this.idGenerator = Objects.requireNonNull(idGenerator, "idGenerator"); + return this; + } + + public String getEndpoint() { + return endpoint == null ? resolveEndpoint(env) : endpoint; + } + + public String getServiceName() { + return serviceName == null ? resolveServiceName(env) : serviceName; + } + + Builder setTelemetryFactory(TelemetryFactory telemetryFactory) { + this.telemetryFactory = Objects.requireNonNull(telemetryFactory, "telemetryFactory"); + return this; + } + + public OpenTelemetry createOpenTelemetry() { + return telemetryFactory.create( + getEndpoint(), getServiceName(), metricsReportInterval, flushTimeout, idGenerator); + } + + public void apply( + @Nonnull WorkflowServiceStubsOptions.Builder serviceStubsOptions, + @Nonnull WorkflowClientOptions.Builder clientOptions, + @Nonnull WorkerFactoryOptions.Builder workerFactoryOptions, + @Nonnull Consumer addShutdownHook) { + OpenTelemetry resolvedOpenTelemetry = + openTelemetry == null ? createOpenTelemetry() : openTelemetry; + configureMetrics( + serviceStubsOptions, + addShutdownHook, + resolvedOpenTelemetry, + getServiceName(), + metricsReportInterval); + configureTracing(clientOptions, workerFactoryOptions, resolvedOpenTelemetry); + if (flushHook == null) { + configureFlushHook(addShutdownHook, resolvedOpenTelemetry, flushTimeout); + } else { + Objects.requireNonNull(addShutdownHook, "addShutdownHook").accept(flushHook); + } + } + } + + interface TelemetryFactory { + OpenTelemetry create( + String endpoint, + String serviceName, + Duration metricsReportInterval, + Duration flushTimeout, + IdGenerator idGenerator); + } + + static final class DefaultTelemetryFactory implements TelemetryFactory { + @Override + public OpenTelemetry create( + String endpoint, + String serviceName, + Duration metricsReportInterval, + Duration flushTimeout, + IdGenerator idGenerator) { + Resource resource = + Resource.getDefault() + .merge(Resource.create(Attributes.of(SERVICE_NAME_ATTRIBUTE, serviceName))); + MetricExporter metricExporter = + OtlpGrpcMetricExporter.builder().setEndpoint(endpoint).build(); + SpanExporter spanExporter = OtlpGrpcSpanExporter.builder().setEndpoint(endpoint).build(); + + SdkMeterProvider meterProvider = + SdkMeterProvider.builder() + .setResource(resource) + .registerMetricReader( + PeriodicMetricReader.builder(metricExporter) + .setInterval(metricsReportInterval) + .build()) + .build(); + SdkTracerProviderBuilder tracerProviderBuilder = + SdkTracerProvider.builder().setResource(resource); + if (idGenerator != null) { + tracerProviderBuilder.setIdGenerator(idGenerator); + } + SdkTracerProvider tracerProvider = + tracerProviderBuilder + .addSpanProcessor( + BatchSpanProcessor.builder(spanExporter).setExporterTimeout(flushTimeout).build()) + .build(); + + return OpenTelemetrySdk.builder() + .setMeterProvider(meterProvider) + .setTracerProvider(tracerProvider) + .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance())) + .build(); + } + } + + private static String nonEmptyEnv(Map env, String name) { + if (env == null) { + return null; + } + String value = env.get(name); + return value == null || value.trim().isEmpty() ? null : value; + } + + static Scope createMetricsScope( + @Nonnull OpenTelemetry openTelemetry, + @Nonnull String serviceName, + @Nonnull Duration reportInterval) { + OpenTelemetryStatsReporter reporter = + new OpenTelemetryStatsReporter( + Objects.requireNonNull(openTelemetry, "openTelemetry"), + Objects.requireNonNull(serviceName, "serviceName")); + return new RootScopeBuilder() + .reporter(reporter) + .reportEvery( + com.uber.m3.util.Duration.ofMillis( + requirePositive(reportInterval, "reportInterval").toMillis())); + } + + static OpenTracingOptions createOpenTracingOptions(@Nonnull OpenTelemetry openTelemetry) { + return OpenTracingOptions.newBuilder() + .setTracer( + OpenTracingShim.createTracerShim( + Objects.requireNonNull(openTelemetry, "openTelemetry"))) + .build(); + } + + static void appendClientInterceptor( + WorkflowClientOptions.Builder options, WorkflowClientInterceptor interceptor) { + WorkflowClientOptions raw = options.build(); + WorkflowClientInterceptor[] existing = raw.getInterceptors(); + int existingLength = existing == null ? 0 : existing.length; + WorkflowClientInterceptor[] interceptors = + existingLength == 0 + ? new WorkflowClientInterceptor[1] + : Arrays.copyOf(existing, existingLength + 1); + interceptors[existingLength] = interceptor; + options.setInterceptors(interceptors); + } + + static void appendWorkerInterceptor( + WorkerFactoryOptions.Builder options, WorkerInterceptor interceptor) { + WorkerFactoryOptions raw = options.build(); + WorkerInterceptor[] existing = raw.getWorkerInterceptors(); + int existingLength = existing == null ? 0 : existing.length; + WorkerInterceptor[] interceptors = + existingLength == 0 + ? new WorkerInterceptor[1] + : Arrays.copyOf(existing, existingLength + 1); + interceptors[existingLength] = interceptor; + options.setWorkerInterceptors(interceptors); + } + + static Duration requirePositive(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + static Duration requireNonNegative(Duration value, String name) { + Objects.requireNonNull(value, name); + if (value.isNegative()) { + throw new IllegalArgumentException(name + " must not be negative"); + } + return value; + } +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TallyScopeFlushHook.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TallyScopeFlushHook.java new file mode 100644 index 0000000000..9f75394db0 --- /dev/null +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TallyScopeFlushHook.java @@ -0,0 +1,34 @@ +package io.temporal.opentelemetry; + +import com.uber.m3.tally.Scope; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Objects; +import javax.annotation.Nonnull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Reports buffered Tally metrics without closing the scope. */ +public final class TallyScopeFlushHook implements Runnable { + private static final Logger log = LoggerFactory.getLogger(TallyScopeFlushHook.class); + + private final Scope scope; + + public TallyScopeFlushHook(@Nonnull Scope scope) { + this.scope = Objects.requireNonNull(scope, "scope"); + } + + @Override + public void run() { + try { + Method reportLoopIteration = scope.getClass().getDeclaredMethod("reportLoopIteration"); + reportLoopIteration.setAccessible(true); + reportLoopIteration.invoke(scope); + } catch (NoSuchMethodException + | IllegalAccessException + | InvocationTargetException + | RuntimeException e) { + log.warn("Tally scope flush failed scope={}", scope.getClass().getName(), e); + } + } +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TimedShutdownHook.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TimedShutdownHook.java new file mode 100644 index 0000000000..0bc5a800b7 --- /dev/null +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/TimedShutdownHook.java @@ -0,0 +1,9 @@ +package io.temporal.opentelemetry; + +import java.time.Duration; +import javax.annotation.Nonnull; + +/** Shutdown hook that can use the caller's remaining cleanup timeout. */ +public interface TimedShutdownHook extends Runnable { + void run(@Nonnull Duration timeout); +} diff --git a/contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryPluginTest.java b/contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryPluginTest.java new file mode 100644 index 0000000000..2f63b88ae8 --- /dev/null +++ b/contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryPluginTest.java @@ -0,0 +1,211 @@ +package io.temporal.opentelemetry; + +import static org.junit.Assert.*; + +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactoryOptions; +import java.io.Closeable; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class OpenTelemetryPluginTest { + @Test + public void defaultsResolveEndpointAndServiceName() { + assertEquals( + "http://localhost:4317", OpenTelemetryPlugin.newBuilder(new HashMap<>()).getEndpoint()); + assertEquals( + "temporal-worker", OpenTelemetryPlugin.newBuilder(new HashMap<>()).getServiceName()); + + Map env = new HashMap<>(); + env.put(OpenTelemetryWorker.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + env.put(OpenTelemetryWorker.OTEL_SERVICE_NAME, "service-name"); + + assertEquals("http://collector:4317", OpenTelemetryPlugin.newBuilder(env).getEndpoint()); + assertEquals("service-name", OpenTelemetryPlugin.newBuilder(env).getServiceName()); + } + + @Test + public void installsMetricsScopeAndTracingInterceptors() { + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + OpenTelemetryPlugin plugin = + OpenTelemetryPlugin.newBuilder(new HashMap<>()).setTelemetryFactory(factory).build(); + Config config = new Config(); + + plugin.configureServiceStubs(config.serviceStubsOptions); + plugin.configureWorkflowClient(config.clientOptions); + plugin.configureWorkerFactory(config.workerFactoryOptions); + + assertEquals(1, factory.creates.get()); + assertNotNull(config.serviceStubsOptions.build().getMetricsScope()); + assertEquals(1, config.clientOptions.build().getInterceptors().length); + assertEquals(1, config.workerFactoryOptions.build().getWorkerInterceptors().length); + } + + @Test + public void customOpenTelemetryBypassesExporterCreation() { + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + OpenTelemetryPlugin plugin = + OpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setTelemetryFactory(factory) + .setOpenTelemetry(OpenTelemetry.noop()) + .build(); + Config config = new Config(); + + plugin.configureServiceStubs(config.serviceStubsOptions); + plugin.configureWorkflowClient(config.clientOptions); + plugin.configureWorkerFactory(config.workerFactoryOptions); + + assertEquals(0, factory.creates.get()); + assertNotNull(config.serviceStubsOptions.build().getMetricsScope()); + assertEquals(1, config.clientOptions.build().getInterceptors().length); + assertEquals(1, config.workerFactoryOptions.build().getWorkerInterceptors().length); + } + + @Test + public void flushHookForceFlushesProvidersWithoutClosingThem() { + CountingOpenTelemetry openTelemetry = new CountingOpenTelemetry(); + OpenTelemetryPlugin plugin = + OpenTelemetryPlugin.newBuilder(new HashMap<>()).setOpenTelemetry(openTelemetry).build(); + Config config = new Config(); + plugin.configureServiceStubs(config.serviceStubsOptions); + + plugin.newFlushHook().run(Duration.ofMillis(100)); + + assertEquals(1, openTelemetry.tracerProvider.flushes.get()); + assertEquals(1, openTelemetry.meterProvider.flushes.get()); + assertEquals(0, openTelemetry.tracerProvider.closes.get()); + assertEquals(0, openTelemetry.meterProvider.closes.get()); + } + + @Test + public void workerFactoryShutdownFlushCanBeDisabledForServerlessRuntimes() { + CountingOpenTelemetry openTelemetry = new CountingOpenTelemetry(); + OpenTelemetryPlugin plugin = + OpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setOpenTelemetry(openTelemetry) + .setFlushOnWorkerFactoryShutdown(false) + .build(); + AtomicInteger shutdowns = new AtomicInteger(); + + plugin.shutdownWorkerFactory(null, factory -> shutdowns.incrementAndGet()); + + assertEquals(1, shutdowns.get()); + assertEquals(0, openTelemetry.tracerProvider.flushes.get()); + assertEquals(0, openTelemetry.meterProvider.flushes.get()); + } + + @Test + public void workerFactoryShutdownFlushesByDefault() { + CountingOpenTelemetry openTelemetry = new CountingOpenTelemetry(); + OpenTelemetryPlugin plugin = + OpenTelemetryPlugin.newBuilder(new HashMap<>()).setOpenTelemetry(openTelemetry).build(); + AtomicInteger shutdowns = new AtomicInteger(); + + plugin.shutdownWorkerFactory(null, factory -> shutdowns.incrementAndGet()); + + assertEquals(1, shutdowns.get()); + assertEquals(1, openTelemetry.tracerProvider.flushes.get()); + assertEquals(1, openTelemetry.meterProvider.flushes.get()); + } + + private static final class Config { + private final WorkflowServiceStubsOptions.Builder serviceStubsOptions = + WorkflowServiceStubsOptions.newBuilder(); + private final WorkflowClientOptions.Builder clientOptions = WorkflowClientOptions.newBuilder(); + private final WorkerFactoryOptions.Builder workerFactoryOptions = + WorkerFactoryOptions.newBuilder(); + } + + private static final class RecordingTelemetryFactory + implements OpenTelemetryWorker.TelemetryFactory { + private final AtomicInteger creates = new AtomicInteger(); + + @Override + public OpenTelemetry create( + String endpoint, + String serviceName, + Duration metricsReportInterval, + Duration flushTimeout, + IdGenerator idGenerator) { + creates.incrementAndGet(); + return OpenTelemetry.noop(); + } + } + + private static final class CountingOpenTelemetry implements OpenTelemetry { + private final CountingTracerProvider tracerProvider = new CountingTracerProvider(); + private final CountingMeterProvider meterProvider = new CountingMeterProvider(); + + @Override + public TracerProvider getTracerProvider() { + return tracerProvider; + } + + @Override + public MeterProvider getMeterProvider() { + return meterProvider; + } + + @Override + public ContextPropagators getPropagators() { + return ContextPropagators.noop(); + } + } + + public static final class CountingTracerProvider implements TracerProvider, Closeable { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public Tracer get(String instrumentationName) { + return TracerProvider.noop().get(instrumentationName); + } + + @Override + public Tracer get(String instrumentationName, String instrumentationVersion) { + return TracerProvider.noop().get(instrumentationName, instrumentationVersion); + } + + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + public static final class CountingMeterProvider implements MeterProvider, Closeable { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public MeterBuilder meterBuilder(String instrumentationName) { + return MeterProvider.noop().meterBuilder(instrumentationName); + } + + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } +} diff --git a/contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryWorkerTest.java b/contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryWorkerTest.java new file mode 100644 index 0000000000..f2e158989b --- /dev/null +++ b/contrib/temporal-opentelemetry/src/test/java/io/temporal/opentelemetry/OpenTelemetryWorkerTest.java @@ -0,0 +1,618 @@ +package io.temporal.opentelemetry; + +import static org.junit.Assert.*; + +import com.uber.m3.tally.Capabilities; +import com.uber.m3.tally.CapableOf; +import com.uber.m3.tally.RootScopeBuilder; +import com.uber.m3.tally.Scope; +import com.uber.m3.tally.StatsReporter; +import io.opentelemetry.api.OpenTelemetry; +import io.opentelemetry.api.metrics.MeterBuilder; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.api.trace.Span; +import io.opentelemetry.api.trace.SpanContext; +import io.opentelemetry.api.trace.TraceFlags; +import io.opentelemetry.api.trace.TraceState; +import io.opentelemetry.api.trace.Tracer; +import io.opentelemetry.api.trace.TracerProvider; +import io.opentelemetry.context.propagation.ContextPropagators; +import io.opentelemetry.sdk.OpenTelemetrySdk; +import io.opentelemetry.sdk.common.CompletableResultCode; +import io.opentelemetry.sdk.metrics.Aggregation; +import io.opentelemetry.sdk.metrics.InstrumentType; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.AggregationTemporality; +import io.opentelemetry.sdk.metrics.export.CollectionRegistration; +import io.opentelemetry.sdk.metrics.export.DefaultAggregationSelector; +import io.opentelemetry.sdk.metrics.export.MetricReader; +import io.opentelemetry.sdk.trace.IdGenerator; +import io.opentelemetry.sdk.trace.ReadWriteSpan; +import io.opentelemetry.sdk.trace.ReadableSpan; +import io.opentelemetry.sdk.trace.SdkTracerProvider; +import io.opentelemetry.sdk.trace.SpanProcessor; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactoryOptions; +import java.io.Closeable; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.Test; + +public class OpenTelemetryWorkerTest { + @Test + public void defaultsResolveEndpointAndServiceName() { + assertEquals( + "http://localhost:4317", OpenTelemetryWorker.newBuilder(new HashMap<>()).getEndpoint()); + assertEquals( + "temporal-worker", OpenTelemetryWorker.newBuilder(new HashMap<>()).getServiceName()); + + Map env = new HashMap<>(); + env.put(OpenTelemetryWorker.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + assertEquals("http://collector:4317", OpenTelemetryWorker.newBuilder(env).getEndpoint()); + assertEquals("temporal-worker", OpenTelemetryWorker.newBuilder(env).getServiceName()); + + env.put(OpenTelemetryWorker.OTEL_SERVICE_NAME, "explicit-service"); + assertEquals("explicit-service", OpenTelemetryWorker.newBuilder(env).getServiceName()); + } + + @Test + public void defaultFactoryCreatesExporterBackedSdkProviders() { + OpenTelemetry openTelemetry = + OpenTelemetryWorker.newBuilder(new HashMap<>()).createOpenTelemetry(); + + assertTrue(openTelemetry instanceof OpenTelemetrySdk); + + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetry; + assertNotNull(sdk.getSdkTracerProvider()); + assertNotNull(sdk.getSdkMeterProvider()); + sdk.shutdown().join(1, TimeUnit.SECONDS); + } + + @Test + public void defaultFactoryConfiguresTraceContextPropagator() { + OpenTelemetry openTelemetry = + OpenTelemetryWorker.newBuilder(new HashMap<>()).createOpenTelemetry(); + + assertTrue(openTelemetry instanceof OpenTelemetrySdk); + + OpenTelemetrySdk sdk = (OpenTelemetrySdk) openTelemetry; + String traceId = "00000000000000000000000000000001"; + String spanId = "0000000000000002"; + Map carrier = new HashMap<>(); + sdk.getPropagators() + .getTextMapPropagator() + .inject( + io.opentelemetry.context.Context.root() + .with( + Span.wrap( + SpanContext.create( + traceId, spanId, TraceFlags.getSampled(), TraceState.getDefault()))), + carrier, + (map, key, value) -> map.put(key, value)); + + assertEquals("00-" + traceId + "-" + spanId + "-01", carrier.get("traceparent")); + sdk.shutdown().join(1, TimeUnit.SECONDS); + } + + @Test + public void exporterFactoryReceivesResolvedEndpointAndServiceName() { + Map env = new HashMap<>(); + env.put(OpenTelemetryWorker.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + env.put(OpenTelemetryWorker.OTEL_SERVICE_NAME, "service-name"); + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + Config config = new Config(); + + OpenTelemetryWorker.newBuilder(env) + .setTelemetryFactory(factory) + .apply( + config.serviceStubsOptions, + config.clientOptions, + config.workerFactoryOptions, + config.shutdownHooks::add); + + assertEquals(1, factory.creates.get()); + assertEquals("http://collector:4317", factory.endpoint); + assertEquals("service-name", factory.serviceName); + } + + @Test + public void customEndpointAndServiceNameAreUsedByExporterFactory() { + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + Config config = new Config(); + + OpenTelemetryWorker.newBuilder(new HashMap<>()) + .setTelemetryFactory(factory) + .setEndpoint("http://custom-collector:4317") + .setServiceName("custom-service") + .setMetricsReportInterval(Duration.ofSeconds(3)) + .setFlushTimeout(Duration.ofSeconds(4)) + .apply( + config.serviceStubsOptions, + config.clientOptions, + config.workerFactoryOptions, + config.shutdownHooks::add); + + assertEquals(1, factory.creates.get()); + assertEquals("http://custom-collector:4317", factory.endpoint); + assertEquals("custom-service", factory.serviceName); + assertEquals(Duration.ofSeconds(3), factory.metricsReportInterval); + assertEquals(Duration.ofSeconds(4), factory.flushTimeout); + } + + @Test + public void metricsScopeAndTracingInterceptorsAreInstalled() { + Config config = new Config(); + + OpenTelemetryWorker.configure( + config.serviceStubsOptions, + config.clientOptions, + config.workerFactoryOptions, + config.shutdownHooks::add, + builder -> builder.setOpenTelemetry(OpenTelemetry.noop()).setFlushHook(() -> {})); + + assertNotNull(config.serviceStubsOptions.build().getMetricsScope()); + assertEquals(1, config.clientOptions.build().getInterceptors().length); + assertEquals(1, config.workerFactoryOptions.build().getWorkerInterceptors().length); + } + + @Test + public void configureRegistersTallyFlushBeforeOpenTelemetryFlush() { + Config config = new Config(); + + OpenTelemetryWorker.configure( + config.serviceStubsOptions, + config.clientOptions, + config.workerFactoryOptions, + config.shutdownHooks::add, + builder -> builder.setOpenTelemetry(OpenTelemetry.noop())); + + assertEquals(2, config.shutdownHooks.size()); + assertTrue(config.shutdownHooks.get(0) instanceof TallyScopeFlushHook); + assertTrue(config.shutdownHooks.get(1) instanceof OpenTelemetryFlushHook); + } + + @Test + public void metricsOnlyInstallsScopeWithoutTracingInterceptors() { + Config config = new Config(); + + OpenTelemetryWorker.configureMetrics( + config.serviceStubsOptions, config.shutdownHooks::add, OpenTelemetry.noop()); + + assertNotNull(config.serviceStubsOptions.build().getMetricsScope()); + assertEquals(0, clientInterceptorCount(config.clientOptions)); + assertEquals(0, workerInterceptorCount(config.workerFactoryOptions)); + } + + @Test + public void tallyScopeFlushHookReportsBufferedMetricsWithoutClosingScope() throws Exception { + RecordingStatsReporter reporter = new RecordingStatsReporter(); + Scope scope = + new RootScopeBuilder().reporter(reporter).reportEvery(com.uber.m3.util.Duration.ofHours(1)); + try { + scope.counter("buffered-counter").inc(1); + + new TallyScopeFlushHook(scope).run(); + + assertTrue(reporter.counterReports.get() >= 1); + assertTrue(reporter.flushes.get() >= 1); + assertEquals(0, reporter.closes.get()); + } finally { + scope.close(); + } + } + + @Test + public void tracingOnlyInstallsInterceptorsWithoutMetricsScope() { + Config config = new Config(); + + OpenTelemetryWorker.configureTracing( + config.clientOptions, config.workerFactoryOptions, OpenTelemetry.noop()); + + assertNull(config.serviceStubsOptions.build().getMetricsScope()); + assertEquals(1, clientInterceptorCount(config.clientOptions)); + assertEquals(1, workerInterceptorCount(config.workerFactoryOptions)); + } + + @Test + public void flushHookUsesSmallerConfiguredAndCallerTimeout() { + FakeMonotonicClock clock = new FakeMonotonicClock(); + TimeoutRecordingOpenTelemetry openTelemetry = new TimeoutRecordingOpenTelemetry(); + + new OpenTelemetryFlushHook(openTelemetry, Duration.ofMillis(250), clock) + .run(Duration.ofSeconds(2)); + + assertEquals(250, openTelemetry.tracerProvider.joinTimeoutMillis.get()); + assertEquals(250, openTelemetry.meterProvider.joinTimeoutMillis.get()); + + clock = new FakeMonotonicClock(); + openTelemetry = new TimeoutRecordingOpenTelemetry(); + + new OpenTelemetryFlushHook(openTelemetry, Duration.ofSeconds(2), clock) + .run(Duration.ofMillis(125)); + + assertEquals(125, openTelemetry.tracerProvider.joinTimeoutMillis.get()); + assertEquals(125, openTelemetry.meterProvider.joinTimeoutMillis.get()); + } + + @Test + public void flushHookSpendsTimeoutOnceAcrossProviders() { + FakeMonotonicClock clock = new FakeMonotonicClock(); + TimeoutRecordingOpenTelemetry openTelemetry = + new TimeoutRecordingOpenTelemetry(clock, Duration.ofMillis(150)); + + new OpenTelemetryFlushHook(openTelemetry, Duration.ofMillis(250), clock) + .run(Duration.ofSeconds(2)); + + assertEquals(250, openTelemetry.tracerProvider.joinTimeoutMillis.get()); + assertEquals(100, openTelemetry.meterProvider.joinTimeoutMillis.get()); + } + + @Test + public void flushHookUnwrapsSdkProviders() { + RecordingSpanProcessor spanProcessor = new RecordingSpanProcessor(); + RecordingMetricReader metricReader = new RecordingMetricReader(); + OpenTelemetrySdk sdk = + OpenTelemetrySdk.builder() + .setTracerProvider(SdkTracerProvider.builder().addSpanProcessor(spanProcessor).build()) + .setMeterProvider(SdkMeterProvider.builder().registerMetricReader(metricReader).build()) + .build(); + try { + new OpenTelemetryFlushHook(sdk, Duration.ofSeconds(1), new FakeMonotonicClock()) + .run(Duration.ofSeconds(1)); + + assertEquals(1, spanProcessor.flushes.get()); + assertEquals(1, metricReader.flushes.get()); + } finally { + sdk.shutdown().join(1, TimeUnit.SECONDS); + } + } + + @Test + public void customOpenTelemetryBypassesExporterCreation() { + RecordingTelemetryFactory factory = new RecordingTelemetryFactory(); + Config config = new Config(); + + OpenTelemetryWorker.newBuilder(new HashMap<>()) + .setTelemetryFactory(factory) + .setOpenTelemetry(OpenTelemetry.noop()) + .apply( + config.serviceStubsOptions, + config.clientOptions, + config.workerFactoryOptions, + config.shutdownHooks::add); + + assertEquals(0, factory.creates.get()); + assertNotNull(config.serviceStubsOptions.build().getMetricsScope()); + } + + private int clientInterceptorCount(WorkflowClientOptions.Builder options) { + io.temporal.common.interceptors.WorkflowClientInterceptor[] interceptors = + options.build().getInterceptors(); + return interceptors == null ? 0 : interceptors.length; + } + + private int workerInterceptorCount(WorkerFactoryOptions.Builder options) { + io.temporal.common.interceptors.WorkerInterceptor[] interceptors = + options.build().getWorkerInterceptors(); + return interceptors == null ? 0 : interceptors.length; + } + + private static final class Config { + private final WorkflowServiceStubsOptions.Builder serviceStubsOptions = + WorkflowServiceStubsOptions.newBuilder(); + private final WorkflowClientOptions.Builder clientOptions = WorkflowClientOptions.newBuilder(); + private final WorkerFactoryOptions.Builder workerFactoryOptions = + WorkerFactoryOptions.newBuilder(); + private final List shutdownHooks = new ArrayList<>(); + } + + private static final class RecordingTelemetryFactory + implements OpenTelemetryWorker.TelemetryFactory { + private final AtomicInteger creates = new AtomicInteger(); + private String endpoint; + private String serviceName; + private Duration metricsReportInterval; + private Duration flushTimeout; + + @Override + public OpenTelemetry create( + String endpoint, + String serviceName, + Duration metricsReportInterval, + Duration flushTimeout, + IdGenerator idGenerator) { + creates.incrementAndGet(); + this.endpoint = endpoint; + this.serviceName = serviceName; + this.metricsReportInterval = metricsReportInterval; + this.flushTimeout = flushTimeout; + return OpenTelemetry.noop(); + } + } + + @SuppressWarnings("deprecation") + private static final class RecordingStatsReporter implements StatsReporter { + private final AtomicInteger counterReports = new AtomicInteger(); + private final AtomicInteger gaugeReports = new AtomicInteger(); + private final AtomicInteger timerReports = new AtomicInteger(); + private final AtomicInteger histogramReports = new AtomicInteger(); + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public void reportCounter(String name, Map tags, long value) { + counterReports.incrementAndGet(); + } + + @Override + public void reportGauge(String name, Map tags, double value) { + gaugeReports.incrementAndGet(); + } + + @Override + public void reportTimer( + String name, Map tags, com.uber.m3.util.Duration interval) { + timerReports.incrementAndGet(); + } + + @Override + public void reportHistogramValueSamples( + String name, + Map tags, + com.uber.m3.tally.Buckets buckets, + double bucketLowerBound, + double bucketUpperBound, + long samples) { + histogramReports.incrementAndGet(); + } + + @Override + public void reportHistogramDurationSamples( + String name, + Map tags, + com.uber.m3.tally.Buckets buckets, + com.uber.m3.util.Duration bucketLowerBound, + com.uber.m3.util.Duration bucketUpperBound, + long samples) { + histogramReports.incrementAndGet(); + } + + @Override + public Capabilities capabilities() { + return CapableOf.REPORTING_TAGGING; + } + + @Override + public void flush() { + flushes.incrementAndGet(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + private static final class RecordingSpanProcessor implements SpanProcessor { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger shutdowns = new AtomicInteger(); + + @Override + public void onStart(io.opentelemetry.context.Context parentContext, ReadWriteSpan span) {} + + @Override + public boolean isStartRequired() { + return false; + } + + @Override + public void onEnd(ReadableSpan span) {} + + @Override + public boolean isEndRequired() { + return false; + } + + @Override + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + shutdowns.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + } + + private static final class RecordingMetricReader implements MetricReader { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger shutdowns = new AtomicInteger(); + + @Override + public void register(CollectionRegistration registration) {} + + @Override + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public CompletableResultCode shutdown() { + shutdowns.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public AggregationTemporality getAggregationTemporality(InstrumentType instrumentType) { + return AggregationTemporality.CUMULATIVE; + } + + @Override + public Aggregation getDefaultAggregation(InstrumentType instrumentType) { + return DefaultAggregationSelector.getDefault().getDefaultAggregation(instrumentType); + } + } + + private static final class TimeoutRecordingOpenTelemetry implements OpenTelemetry { + private final TimeoutRecordingTracerProvider tracerProvider; + private final TimeoutRecordingMeterProvider meterProvider; + + private TimeoutRecordingOpenTelemetry() { + this(null, Duration.ZERO); + } + + private TimeoutRecordingOpenTelemetry(FakeMonotonicClock clock, Duration joinDuration) { + this.tracerProvider = new TimeoutRecordingTracerProvider(clock, joinDuration); + this.meterProvider = new TimeoutRecordingMeterProvider(clock, joinDuration); + } + + @Override + public TracerProvider getTracerProvider() { + return tracerProvider; + } + + @Override + public MeterProvider getMeterProvider() { + return meterProvider; + } + + @Override + public ContextPropagators getPropagators() { + return ContextPropagators.noop(); + } + } + + public static final class TimeoutRecordingTracerProvider implements TracerProvider { + private final AtomicLong joinTimeoutMillis = new AtomicLong(-1); + private final FakeMonotonicClock clock; + private final Duration joinDuration; + + private TimeoutRecordingTracerProvider(FakeMonotonicClock clock, Duration joinDuration) { + this.clock = clock; + this.joinDuration = joinDuration; + } + + @Override + public Tracer get(String instrumentationName) { + return TracerProvider.noop().get(instrumentationName); + } + + @Override + public Tracer get(String instrumentationName, String instrumentationVersion) { + return TracerProvider.noop().get(instrumentationName, instrumentationVersion); + } + + public TimeoutRecordingResult forceFlush() { + return new TimeoutRecordingResult(joinTimeoutMillis, clock, joinDuration); + } + } + + public static final class TimeoutRecordingMeterProvider implements MeterProvider { + private final AtomicLong joinTimeoutMillis = new AtomicLong(-1); + private final FakeMonotonicClock clock; + private final Duration joinDuration; + + private TimeoutRecordingMeterProvider(FakeMonotonicClock clock, Duration joinDuration) { + this.clock = clock; + this.joinDuration = joinDuration; + } + + @Override + public MeterBuilder meterBuilder(String instrumentationName) { + return MeterProvider.noop().meterBuilder(instrumentationName); + } + + public TimeoutRecordingResult forceFlush() { + return new TimeoutRecordingResult(joinTimeoutMillis, clock, joinDuration); + } + } + + public static final class TimeoutRecordingResult { + private final AtomicLong joinTimeoutMillis; + private final FakeMonotonicClock clock; + private final Duration joinDuration; + + private TimeoutRecordingResult( + AtomicLong joinTimeoutMillis, FakeMonotonicClock clock, Duration joinDuration) { + this.joinTimeoutMillis = joinTimeoutMillis; + this.clock = clock; + this.joinDuration = joinDuration; + } + + public TimeoutRecordingResult join(long timeout, TimeUnit unit) { + joinTimeoutMillis.set(unit.toMillis(timeout)); + if (clock != null) { + clock.advance(joinDuration); + } + return this; + } + } + + private static final class FakeMonotonicClock implements OpenTelemetryFlushHook.MonotonicClock { + private long nowNanos; + + @Override + public long nanoTime() { + return nowNanos; + } + + private void advance(Duration duration) { + nowNanos += duration.toNanos(); + } + } + + public static final class CountingTracerProvider implements TracerProvider, Closeable { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public Tracer get(String instrumentationName) { + return TracerProvider.noop().get(instrumentationName); + } + + @Override + public Tracer get(String instrumentationName, String instrumentationVersion) { + return TracerProvider.noop().get(instrumentationName, instrumentationVersion); + } + + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } + + public static final class CountingMeterProvider implements MeterProvider, Closeable { + private final AtomicInteger flushes = new AtomicInteger(); + private final AtomicInteger closes = new AtomicInteger(); + + @Override + public MeterBuilder meterBuilder(String instrumentationName) { + return MeterProvider.noop().meterBuilder(instrumentationName); + } + + public CompletableResultCode forceFlush() { + flushes.incrementAndGet(); + return CompletableResultCode.ofSuccess(); + } + + @Override + public void close() { + closes.incrementAndGet(); + } + } +} diff --git a/settings.gradle b/settings.gradle index fe80370b0c..969ce7f93c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -6,9 +6,13 @@ include 'temporal-testing' include 'temporal-test-server' include 'temporal-opentracing' project(':temporal-opentracing').projectDir = file('contrib/temporal-opentracing') +include 'temporal-opentelemetry' +project(':temporal-opentelemetry').projectDir = file('contrib/temporal-opentelemetry') include 'temporal-kotlin' include 'temporal-spring-ai' project(':temporal-spring-ai').projectDir = file('contrib/temporal-spring-ai') +include 'temporal-aws-lambda' +project(':temporal-aws-lambda').projectDir = file('contrib/temporal-aws-lambda') include 'temporal-spring-boot-autoconfigure' include 'temporal-spring-boot-starter' include 'temporal-remote-data-encoder' diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index e73d0d300e..3d9771704a 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -7,7 +7,9 @@ description = '''Temporal Java BOM''' dependencies { constraints { api project(':temporal-kotlin') + api project(':temporal-opentelemetry') api project(':temporal-opentracing') + api project(':temporal-aws-lambda') api project(':temporal-remote-data-encoder') api project(':temporal-sdk') api project(':temporal-serviceclient') From b8ace30009bbc572d5a0bcdb167f5dc1a4b4008a Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Thu, 9 Jul 2026 10:32:53 -0700 Subject: [PATCH 032/107] Add preferred version provider to worker options (#2942) --- .../replay/ReplayWorkflowContextImpl.java | 18 +- .../statemachines/VersionStateMachine.java | 48 +++- .../statemachines/WorkflowStateMachines.java | 12 + .../internal/sync/SyncWorkflowContext.java | 35 +-- .../internal/sync/WorkflowInternal.java | 11 + .../internal/worker/SingleWorkerOptions.java | 20 +- .../worker/PreferredVersionProvider.java | 30 +++ .../worker/PreferredVersionProviderInput.java | 63 +++++ .../io/temporal/worker/VersionPreference.java | 62 +++++ .../main/java/io/temporal/worker/Worker.java | 3 +- .../io/temporal/worker/WorkerOptions.java | 39 ++- .../java/io/temporal/workflow/Workflow.java | 7 +- .../VersionStateMachineTest.java | 231 +++++++++++++++++ .../io/temporal/worker/WorkerOptionsTest.java | 7 +- .../PreferredVersionProviderRolloutTest.java | 239 ++++++++++++++++++ .../PreferredVersionProviderTest.java | 121 +++++++++ 16 files changed, 911 insertions(+), 35 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java create mode 100644 temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProviderInput.java create mode 100644 temporal-sdk/src/main/java/io/temporal/worker/VersionPreference.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderRolloutTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java index cf704544bd..2f600b20aa 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java @@ -15,7 +15,10 @@ import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.SdkFlag; import io.temporal.internal.statemachines.*; +import io.temporal.internal.sync.WorkflowInternal; import io.temporal.internal.worker.SingleWorkerOptions; +import io.temporal.worker.PreferredVersionProvider; +import io.temporal.worker.PreferredVersionProviderInput; import io.temporal.workflow.Functions; import io.temporal.workflow.Functions.Func; import io.temporal.workflow.Functions.Func1; @@ -337,7 +340,20 @@ public Integer getVersion( int minSupported, int maxSupported, Functions.Proc2 callback) { - return workflowStateMachines.getVersion(changeId, minSupported, maxSupported, callback); + PreferredVersionProvider preferredVersionProvider = workerOptions.getPreferredVersionProvider(); + return workflowStateMachines.getVersion( + changeId, + minSupported, + maxSupported, + preferredVersionProvider == null + ? null + : (min, max) -> + WorkflowInternal.readOnly( + () -> + preferredVersionProvider.getPreferredVersion( + new PreferredVersionProviderInput( + WorkflowInternal.getWorkflowInfo(), changeId, min, max))), + callback); } @Override diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java index c218a82dbe..addb326482 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/VersionStateMachine.java @@ -14,8 +14,10 @@ import io.temporal.api.history.v1.HistoryEvent; import io.temporal.internal.history.VersionMarkerUtils; import io.temporal.worker.NonDeterministicException; +import io.temporal.worker.VersionPreference; import io.temporal.workflow.Functions; import java.util.Objects; +import java.util.function.BiFunction; import javax.annotation.Nullable; final class VersionStateMachine { @@ -137,17 +139,22 @@ class InvocationStateMachine private final Functions.Func1 upsertSearchAttributeCallback; private final Functions.Proc2 resultCallback; + @Nullable + private final BiFunction preferredVersionProvider; + InvocationStateMachine( int minSupported, int maxSupported, boolean waitForMarkerRecordedReplaying, Functions.Func1 upsertSearchAttributeCallback, + @Nullable BiFunction preferredVersionProvider, Functions.Proc2 callback) { super(STATE_MACHINE_DEFINITION, VersionStateMachine.this.commandSink, stateMachineSink); this.minSupported = minSupported; this.maxSupported = maxSupported; this.waitForMarkerRecordedReplaying = waitForMarkerRecordedReplaying; this.upsertSearchAttributeCallback = upsertSearchAttributeCallback; + this.preferredVersionProvider = preferredVersionProvider; this.resultCallback = Objects.requireNonNull(callback); } @@ -210,13 +217,17 @@ private void validateVersionAndThrow(boolean preloaded) { throw new IllegalStateException((preloaded ? "preloaded " : "") + " version not set"); } if (versionToUse < minSupported || versionToUse > maxSupported) { - throw new UnsupportedVersion.UnsupportedVersionException( - String.format( - "Version %d of changeId %s is not supported. Supported v is between %d and %d.", - versionToUse, changeId, minSupported, maxSupported)); + throwUnsupportedVersion(versionToUse); } } + private void throwUnsupportedVersion(int unsupportedVersion) { + throw new UnsupportedVersion.UnsupportedVersionException( + String.format( + "Version %d of changeId %s is not supported. Supported v is between %d and %d.", + unsupportedVersion, changeId, minSupported, maxSupported)); + } + void notifyFromVersion(boolean preloaded) { Integer versionToUse = preloaded ? preloadedVersion : version; resultCallback.apply(versionToUse, null); @@ -227,8 +238,8 @@ void notifyFromException(RuntimeException ex) { } void notifyFromVersionExecuting() { - // the only case when we don't need to validate before notification because - // we just initialized the version with maxVersion + // No validation needed before notification here: resolveVersionExecuting() already guarantees + // the version it produces is within [minSupported, maxSupported]. notifyFromVersion(false); } @@ -238,7 +249,7 @@ State createMarkerExecuting() { addCommand(StateMachineCommandUtils.RECORD_MARKER_FAKE_COMMAND); return State.SKIPPED; } else { - version = maxSupported; + version = resolveVersionExecuting(); SearchAttributes sa = upsertSearchAttributeCallback.apply(version); writeVersionChangeSA = sa != null; RecordMarkerCommandAttributes markerAttributes = @@ -253,6 +264,27 @@ State createMarkerExecuting() { } } + private int resolveVersionExecuting() { + if (preferredVersionProvider == null) { + return maxSupported; + } + VersionPreference versionPreference = + preferredVersionProvider.apply(minSupported, maxSupported); + if (versionPreference == null) { + return maxSupported; + } + + int preferredVersion = versionPreference.getVersion(); + if (preferredVersion >= minSupported && preferredVersion <= maxSupported) { + return preferredVersion; + } + if (versionPreference.isClampToSupportedRange()) { + return Math.min(Math.max(preferredVersion, minSupported), maxSupported); + } + throwUnsupportedVersion(preferredVersion); + throw new IllegalStateException("unreachable"); + } + void notifySkippedExecuting() { cancelCommand(); try { @@ -411,6 +443,7 @@ public Integer getVersion( int maxSupported, boolean waitForMarkerRecordedReplaying, Functions.Func1 upsertSearchAttributeCallback, + @Nullable BiFunction preferredVersionProvider, Functions.Proc2 callback) { InvocationStateMachine ism = new InvocationStateMachine( @@ -418,6 +451,7 @@ public Integer getVersion( maxSupported, waitForMarkerRecordedReplaying, upsertSearchAttributeCallback, + preferredVersionProvider, callback); ism.explicitEvent(ExplicitEvent.CHECK_EXECUTION_STATE); ism.explicitEvent(ExplicitEvent.SCHEDULE); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java index 884e6947d7..2de6b6ea15 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java @@ -30,12 +30,14 @@ import io.temporal.serviceclient.Version; import io.temporal.worker.MetricsType; import io.temporal.worker.NonDeterministicException; +import io.temporal.worker.VersionPreference; import io.temporal.worker.WorkflowImplementationOptions; import io.temporal.workflow.ChildWorkflowCancellationType; import io.temporal.workflow.Functions; import io.temporal.workflow.NexusOperationCancellationType; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.function.BiFunction; import javax.annotation.Nonnull; import javax.annotation.Nullable; import org.slf4j.Logger; @@ -1244,6 +1246,15 @@ public Integer getVersion( int minSupported, int maxSupported, Functions.Proc2 callback) { + return getVersion(changeId, minSupported, maxSupported, null, callback); + } + + public Integer getVersion( + String changeId, + int minSupported, + int maxSupported, + @Nullable BiFunction preferredVersionProvider, + Functions.Proc2 callback) { VersionStateMachine stateMachine = versions.computeIfAbsent( changeId, @@ -1275,6 +1286,7 @@ public Integer getVersion( } return sa; }, + preferredVersionProvider, (v, e) -> { callback.apply(v, e); // without this getVersion call will trigger the end of WFT, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index 1517c9257e..a1d92fc971 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -1143,21 +1143,26 @@ private R mutableSideEffectImpl( @Override public int getVersion(String changeId, int minSupported, int maxSupported) { CompletablePromise result = Workflow.newPromise(); - Integer versionToUse = - replayContext.getVersion( - changeId, - minSupported, - maxSupported, - (v, e) -> - runner.executeInWorkflowThread( - "version-callback", - () -> { - if (v != null) { - result.complete(v); - } else { - result.completeExceptionally(e); - } - })); + Integer versionToUse; + try { + versionToUse = + replayContext.getVersion( + changeId, + minSupported, + maxSupported, + (v, e) -> + runner.executeInWorkflowThread( + "version-callback", + () -> { + if (v != null) { + result.complete(v); + } else { + result.completeExceptionally(e); + } + })); + } catch (UnsupportedVersion.UnsupportedVersionException ex) { + throw new UnsupportedVersion(ex); + } /* * If we are replaying a workflow and encounter a getVersion call it is possible that this call did not exist * on the original execution. If the call did not exist on the original execution then we cannot block on results diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java index 79495694b4..3ea979e8db 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java @@ -674,6 +674,17 @@ public static T deadlockDetectorOff(Functions.Func func) { } } + public static T readOnly(Functions.Func func) { + SyncWorkflowContext workflowContext = getRootWorkflowContext(); + boolean previousReadOnly = workflowContext.isReadOnly(); + workflowContext.setReadOnly(true); + try { + return func.apply(); + } finally { + workflowContext.setReadOnly(previousReadOnly); + } + } + public static WorkflowInfo getWorkflowInfo() { return new WorkflowInfoImpl(getRootWorkflowContext().getReplayContext()); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java index 3e84dc750f..8e0288566e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java @@ -7,6 +7,7 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.common.interceptors.WorkerInterceptor; +import io.temporal.worker.PreferredVersionProvider; import io.temporal.worker.WorkerDeploymentOptions; import java.time.Duration; import java.util.List; @@ -43,6 +44,7 @@ public static final class Builder { private String workerInstanceKey; private boolean allowActivityHeartbeatDuringShutdown; private String workerControlTaskQueue; + private PreferredVersionProvider preferredVersionProvider; private Builder() {} @@ -70,6 +72,7 @@ private Builder(SingleWorkerOptions options) { this.workerInstanceKey = options.getWorkerInstanceKey(); this.allowActivityHeartbeatDuringShutdown = options.getAllowActivityHeartbeatDuringShutdown(); this.workerControlTaskQueue = options.getWorkerControlTaskQueue(); + this.preferredVersionProvider = options.getPreferredVersionProvider(); } public Builder setIdentity(String identity) { @@ -177,6 +180,11 @@ public Builder setWorkerControlTaskQueue(String workerControlTaskQueue) { return this; } + public Builder setPreferredVersionProvider(PreferredVersionProvider preferredVersionProvider) { + this.preferredVersionProvider = preferredVersionProvider; + return this; + } + public SingleWorkerOptions build() { PollerOptions pollerOptions = this.pollerOptions; if (pollerOptions == null) { @@ -218,7 +226,8 @@ public SingleWorkerOptions build() { this.deploymentOptions, this.workerInstanceKey, this.allowActivityHeartbeatDuringShutdown, - this.workerControlTaskQueue); + this.workerControlTaskQueue, + this.preferredVersionProvider); } } @@ -242,6 +251,7 @@ public SingleWorkerOptions build() { private final String workerInstanceKey; private final boolean allowActivityHeartbeatDuringShutdown; private final String workerControlTaskQueue; + private final PreferredVersionProvider preferredVersionProvider; private SingleWorkerOptions( String identity, @@ -263,7 +273,8 @@ private SingleWorkerOptions( WorkerDeploymentOptions deploymentOptions, String workerInstanceKey, boolean allowActivityHeartbeatDuringShutdown, - String workerControlTaskQueue) { + String workerControlTaskQueue, + PreferredVersionProvider preferredVersionProvider) { this.identity = identity; this.binaryChecksum = binaryChecksum; this.buildId = buildId; @@ -284,6 +295,7 @@ private SingleWorkerOptions( this.workerInstanceKey = workerInstanceKey; this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; this.workerControlTaskQueue = workerControlTaskQueue; + this.preferredVersionProvider = preferredVersionProvider; } public String getIdentity() { @@ -377,6 +389,10 @@ public String getWorkerControlTaskQueue() { return workerControlTaskQueue; } + public PreferredVersionProvider getPreferredVersionProvider() { + return preferredVersionProvider; + } + public WorkerVersioningOptions getWorkerVersioningOptions() { return new WorkerVersioningOptions( this.getBuildId(), this.isUsingBuildIdForVersioning(), this.getDeploymentOptions()); diff --git a/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java b/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java new file mode 100644 index 0000000000..cd8351c5af --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java @@ -0,0 +1,30 @@ +package io.temporal.worker; + +import io.temporal.common.Experimental; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Provides the version to record for a {@link io.temporal.workflow.Workflow#getVersion(String, int, + * int)} call when the version marker is created for the first time. + * + *

This provider is called only during non-replay workflow task execution, before the SDK records + * a version marker for the change ID. It is not called during replay or after a version was already + * memoized for the same change ID. + */ +@Experimental +@FunctionalInterface +public interface PreferredVersionProvider { + + /** + * Returns the preferred version for the supplied {@code getVersion} call, or {@code null} to use + * the SDK default of {@code maxSupported}. + * + *

This method is invoked on the workflow thread. Any exception it throws propagates out of the + * {@code getVersion} call and fails the current workflow task, which will be retried; a provider + * that always throws will therefore block the workflow. Implementations should be deterministic + * and side-effect free. + */ + @Nullable + VersionPreference getPreferredVersion(@Nonnull PreferredVersionProviderInput input); +} diff --git a/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProviderInput.java b/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProviderInput.java new file mode 100644 index 0000000000..f29173aa58 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProviderInput.java @@ -0,0 +1,63 @@ +package io.temporal.worker; + +import io.temporal.common.Experimental; +import io.temporal.workflow.WorkflowInfo; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** Input passed to {@link PreferredVersionProvider}. */ +@Experimental +public final class PreferredVersionProviderInput { + private final WorkflowInfo workflowInfo; + private final String changeId; + private final int minSupported; + private final int maxSupported; + + public PreferredVersionProviderInput( + @Nonnull WorkflowInfo workflowInfo, + @Nonnull String changeId, + int minSupported, + int maxSupported) { + this.workflowInfo = Objects.requireNonNull(workflowInfo); + this.changeId = Objects.requireNonNull(changeId); + this.minSupported = minSupported; + this.maxSupported = maxSupported; + } + + /** Returns information about the workflow execution handling the {@code getVersion} call. */ + @Nonnull + public WorkflowInfo getWorkflowInfo() { + return workflowInfo; + } + + /** Returns the change ID passed to {@code Workflow.getVersion}. */ + @Nonnull + public String getChangeId() { + return changeId; + } + + /** Returns the minimum supported version passed to {@code Workflow.getVersion}. */ + public int getMinSupported() { + return minSupported; + } + + /** Returns the maximum supported version passed to {@code Workflow.getVersion}. */ + public int getMaxSupported() { + return maxSupported; + } + + @Override + public String toString() { + return "PreferredVersionProviderInput{" + + "workflowInfo=" + + workflowInfo + + ", changeId='" + + changeId + + '\'' + + ", minSupported=" + + minSupported + + ", maxSupported=" + + maxSupported + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/worker/VersionPreference.java b/temporal-sdk/src/main/java/io/temporal/worker/VersionPreference.java new file mode 100644 index 0000000000..eb612c8dd0 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/worker/VersionPreference.java @@ -0,0 +1,62 @@ +package io.temporal.worker; + +import io.temporal.common.Experimental; +import java.util.Objects; + +/** Version preference returned by {@link PreferredVersionProvider}. */ +@Experimental +public final class VersionPreference { + private final int version; + private final boolean clampToSupportedRange; + + private VersionPreference(int version, boolean clampToSupportedRange) { + this.version = version; + this.clampToSupportedRange = clampToSupportedRange; + } + + /** + * Creates a preference for {@code version}. If the version is outside the supported range for the + * call, the SDK fails the workflow task unless {@link #clampToSupportedRange()} is used. + */ + public static VersionPreference of(int version) { + return new VersionPreference(version, false); + } + + /** + * Returns a preference that clamps the version into the call's supported range before recording. + */ + public VersionPreference clampToSupportedRange() { + return new VersionPreference(version, true); + } + + public int getVersion() { + return version; + } + + public boolean isClampToSupportedRange() { + return clampToSupportedRange; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + VersionPreference that = (VersionPreference) o; + return version == that.version && clampToSupportedRange == that.clampToSupportedRange; + } + + @Override + public int hashCode() { + return Objects.hash(version, clampToSupportedRange); + } + + @Override + public String toString() { + return "VersionPreference{" + + "version=" + + version + + ", clampToSupportedRange=" + + clampToSupportedRange + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index 5c77e93a6c..5ba7613865 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -1072,7 +1072,8 @@ private static SingleWorkerOptions.Builder toSingleWorkerOptions( .setDefaultHeartbeatThrottleInterval(options.getDefaultHeartbeatThrottleInterval()) .setDeploymentOptions(options.getDeploymentOptions()) .setWorkerInstanceKey(workerInstanceKey) - .setWorkerControlTaskQueue(workerControlTaskQueue); + .setWorkerControlTaskQueue(workerControlTaskQueue) + .setPreferredVersionProvider(options.getPreferredVersionProvider()); } /** diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index 84db0b8d62..2f69517b6c 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -78,6 +78,7 @@ public static final class Builder { private PollerBehavior activityTaskPollersBehavior; private PollerBehavior nexusTaskPollersBehavior; private boolean allowActivityHeartbeatDuringShutdown; + private PreferredVersionProvider preferredVersionProvider; private Builder() {} @@ -114,6 +115,7 @@ private Builder(WorkerOptions o) { this.activityTaskPollersBehavior = o.activityTaskPollersBehavior; this.nexusTaskPollersBehavior = o.nexusTaskPollersBehavior; this.allowActivityHeartbeatDuringShutdown = o.allowActivityHeartbeatDuringShutdown; + this.preferredVersionProvider = o.preferredVersionProvider; } /** @@ -548,6 +550,19 @@ public Builder setAllowActivityHeartbeatDuringShutdown( return this; } + /** + * Sets a provider that can choose the version recorded by the first non-replay {@link + * io.temporal.workflow.Workflow#getVersion(String, int, int)} call for a change ID. + * + *

If unset, or if the provider returns {@link java.util.Optional#empty()}, the SDK keeps the + * existing behavior of recording {@code maxSupported}. + */ + @Experimental + public Builder setPreferredVersionProvider(PreferredVersionProvider preferredVersionProvider) { + this.preferredVersionProvider = preferredVersionProvider; + return this; + } + public WorkerOptions build() { return new WorkerOptions( maxWorkerActivitiesPerSecond, @@ -578,7 +593,8 @@ public WorkerOptions build() { workflowTaskPollersBehavior, activityTaskPollersBehavior, nexusTaskPollersBehavior, - allowActivityHeartbeatDuringShutdown); + allowActivityHeartbeatDuringShutdown, + preferredVersionProvider); } public WorkerOptions validateAndBuildWithDefaults() { @@ -711,7 +727,8 @@ public WorkerOptions validateAndBuildWithDefaults() { workflowTaskPollersBehavior, activityTaskPollersBehavior, nexusTaskPollersBehavior, - allowActivityHeartbeatDuringShutdown); + allowActivityHeartbeatDuringShutdown, + preferredVersionProvider); } } @@ -744,6 +761,7 @@ public WorkerOptions validateAndBuildWithDefaults() { private final PollerBehavior activityTaskPollersBehavior; private final PollerBehavior nexusTaskPollersBehavior; private final boolean allowActivityHeartbeatDuringShutdown; + private final PreferredVersionProvider preferredVersionProvider; private WorkerOptions( double maxWorkerActivitiesPerSecond, @@ -774,7 +792,8 @@ private WorkerOptions( PollerBehavior workflowTaskPollersBehavior, PollerBehavior activityTaskPollersBehavior, PollerBehavior nexusTaskPollersBehavior, - boolean allowActivityHeartbeatDuringShutdown) { + boolean allowActivityHeartbeatDuringShutdown, + PreferredVersionProvider preferredVersionProvider) { this.maxWorkerActivitiesPerSecond = maxWorkerActivitiesPerSecond; this.maxConcurrentActivityExecutionSize = maxConcurrentActivityExecutionSize; this.maxConcurrentWorkflowTaskExecutionSize = maxConcurrentWorkflowTaskExecutionSize; @@ -804,6 +823,7 @@ private WorkerOptions( this.activityTaskPollersBehavior = activityTaskPollersBehavior; this.nexusTaskPollersBehavior = nexusTaskPollersBehavior; this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; + this.preferredVersionProvider = preferredVersionProvider; } public double getMaxWorkerActivitiesPerSecond() { @@ -946,6 +966,11 @@ public boolean getAllowActivityHeartbeatDuringShutdown() { return allowActivityHeartbeatDuringShutdown; } + @Experimental + public PreferredVersionProvider getPreferredVersionProvider() { + return preferredVersionProvider; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -979,7 +1004,8 @@ && compare(maxTaskQueueActivitiesPerSecond, that.maxTaskQueueActivitiesPerSecond && Objects.equals(workflowTaskPollersBehavior, that.workflowTaskPollersBehavior) && Objects.equals(activityTaskPollersBehavior, that.activityTaskPollersBehavior) && Objects.equals(nexusTaskPollersBehavior, that.nexusTaskPollersBehavior) - && allowActivityHeartbeatDuringShutdown == that.allowActivityHeartbeatDuringShutdown; + && allowActivityHeartbeatDuringShutdown == that.allowActivityHeartbeatDuringShutdown + && Objects.equals(preferredVersionProvider, that.preferredVersionProvider); } @Override @@ -1013,7 +1039,8 @@ public int hashCode() { workflowTaskPollersBehavior, activityTaskPollersBehavior, nexusTaskPollersBehavior, - allowActivityHeartbeatDuringShutdown); + allowActivityHeartbeatDuringShutdown, + preferredVersionProvider); } @Override @@ -1078,6 +1105,8 @@ public String toString() { + nexusTaskPollersBehavior + ", allowActivityHeartbeatDuringShutdown=" + allowActivityHeartbeatDuringShutdown + + ", preferredVersionProvider=" + + preferredVersionProvider + '}'; } } diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java index 0378b8b6c2..cc2c3e476f 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java @@ -1087,10 +1087,11 @@ public static R mutableSideEffect( * is going to break determinism. The solution is to have both old code that is used to replay * existing workflows as well as the new one that is used when it is executed for the first time.\ * - *

{@code getVersion} returns maxSupported version when is executed for the first time. This + *

{@code getVersion} returns maxSupported version when it is executed for the first time, + * unless the worker has a {@link io.temporal.worker.PreferredVersionProvider} configured. This * version is recorded into the workflow history as a marker event. Even if maxSupported version - * is changed the version that was recorded is returned on replay. DefaultVersion constant - * contains version of code that wasn't versioned before. + * or the worker preference is changed, the version that was recorded is returned on replay. + * DefaultVersion constant contains version of code that wasn't versioned before. * *

For example initially workflow has the following code: * diff --git a/temporal-sdk/src/test/java/io/temporal/internal/statemachines/VersionStateMachineTest.java b/temporal-sdk/src/test/java/io/temporal/internal/statemachines/VersionStateMachineTest.java index 62fcf501c4..5a6d54f625 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/statemachines/VersionStateMachineTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/statemachines/VersionStateMachineTest.java @@ -17,10 +17,12 @@ import io.temporal.common.converter.DefaultDataConverter; import io.temporal.internal.history.VersionMarkerUtils; import io.temporal.worker.NonDeterministicException; +import io.temporal.worker.VersionPreference; import io.temporal.workflow.Functions; import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.AfterClass; import org.junit.Test; @@ -41,6 +43,235 @@ private WorkflowStateMachines newStateMachines(TestEntityManagerListenerBase lis return new WorkflowStateMachines(listener, stateMachineList::add); } + @Test + public void testPreferredVersionIsRecorded() { + final int preferredVersion = 7; + class TestListener extends TestEntityManagerListenerBase { + @Override + public void buildWorkflow(AsyncWorkflowBuilder builder) { + builder + .add2( + (v, c) -> + stateMachines.getVersion( + "id1", + DEFAULT_VERSION, + 12, + (min, max) -> VersionPreference.of(preferredVersion), + c)) + .add((v) -> stateMachines.completeWorkflow(converter.toPayloads(v.getT1()))); + } + } + + TestHistoryBuilder h = + new TestHistoryBuilder() + .add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .addWorkflowTask(); + + stateMachines = newStateMachines(new TestListener()); + List commands = h.handleWorkflowTaskTakeCommands(stateMachines, 1); + + assertEquals(preferredVersion, getVersionFromMarker(commands.get(0))); + assertEquals( + preferredVersion, + (int) + converter.fromPayloads( + 0, + Optional.of( + commands.get(1).getCompleteWorkflowExecutionCommandAttributes().getResult()), + Integer.class, + Integer.class)); + } + + @Test + public void testPreferredVersionEmptyFallsBackToMaxSupported() { + final int maxSupported = 12; + class TestListener extends TestEntityManagerListenerBase { + @Override + public void buildWorkflow(AsyncWorkflowBuilder builder) { + builder + .add2( + (v, c) -> + stateMachines.getVersion( + "id1", DEFAULT_VERSION, maxSupported, (min, max) -> null, c)) + .add((v) -> stateMachines.completeWorkflow(converter.toPayloads(v.getT1()))); + } + } + + TestHistoryBuilder h = + new TestHistoryBuilder() + .add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .addWorkflowTask(); + + stateMachines = newStateMachines(new TestListener()); + List commands = h.handleWorkflowTaskTakeCommands(stateMachines, 1); + + assertEquals(maxSupported, getVersionFromMarker(commands.get(0))); + } + + @Test + public void testPreferredVersionCalledOnceForChangeId() { + AtomicInteger providerCalls = new AtomicInteger(); + class TestListener extends TestEntityManagerListenerBase { + @Override + public void buildWorkflow(AsyncWorkflowBuilder builder) { + builder + .add2( + (v, c) -> + stateMachines.getVersion( + "id1", + DEFAULT_VERSION, + 12, + (min, max) -> { + providerCalls.incrementAndGet(); + return VersionPreference.of(3); + }, + c)) + .add2( + (v, c) -> + stateMachines.getVersion( + "id1", + DEFAULT_VERSION, + 12, + (min, max) -> { + providerCalls.incrementAndGet(); + return VersionPreference.of(4); + }, + c)) + .add((v) -> stateMachines.completeWorkflow(converter.toPayloads(v.getT1()))); + } + } + + TestHistoryBuilder h = + new TestHistoryBuilder() + .add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .addWorkflowTask(); + + stateMachines = newStateMachines(new TestListener()); + List commands = h.handleWorkflowTaskTakeCommands(stateMachines, 1); + + assertEquals(1, providerCalls.get()); + assertEquals(3, getVersionFromMarker(commands.get(0))); + } + + @Test + public void testPreferredVersionOutOfRangeThrows() { + class TestListener extends TestEntityManagerListenerBase { + @Override + public void buildWorkflow(AsyncWorkflowBuilder builder) { + builder.add2( + (v, c) -> + stateMachines.getVersion( + "id1", DEFAULT_VERSION, 1, (min, max) -> VersionPreference.of(2), c)); + } + } + + TestHistoryBuilder h = + new TestHistoryBuilder() + .add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .addWorkflowTask(); + + stateMachines = newStateMachines(new TestListener()); + RuntimeException exception = + assertThrows( + RuntimeException.class, () -> h.handleWorkflowTaskTakeCommands(stateMachines, 1)); + + assertEquals( + "Version 2 of changeId id1 is not supported. Supported v is between -1 and 1.", + rootCause(exception).getMessage()); + } + + private Throwable rootCause(Throwable throwable) { + Throwable result = throwable; + while (result.getCause() != null) { + result = result.getCause(); + } + return result; + } + + @Test + public void testPreferredVersionCanClampToSupportedRange() { + class TestListener extends TestEntityManagerListenerBase { + @Override + public void buildWorkflow(AsyncWorkflowBuilder builder) { + builder + .add2( + (v, c) -> + stateMachines.getVersion( + "id1", + DEFAULT_VERSION, + 1, + (min, max) -> VersionPreference.of(2).clampToSupportedRange(), + c)) + .add((v) -> stateMachines.completeWorkflow(converter.toPayloads(v.getT1()))); + } + } + + TestHistoryBuilder h = + new TestHistoryBuilder() + .add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .addWorkflowTask(); + + stateMachines = newStateMachines(new TestListener()); + List commands = h.handleWorkflowTaskTakeCommands(stateMachines, 1); + + assertEquals(1, getVersionFromMarker(commands.get(0))); + } + + @Test + public void testPreferredVersionProviderNotCalledDuringReplay() { + AtomicInteger providerCalls = new AtomicInteger(); + class TestListener extends TestEntityManagerListenerBase { + @Override + public void buildWorkflow(AsyncWorkflowBuilder builder) { + builder + .add2( + (v, c) -> + stateMachines.getVersion( + "id1", + DEFAULT_VERSION, + 12, + (min, max) -> { + providerCalls.incrementAndGet(); + return VersionPreference.of(4); + }, + c)) + .add((v) -> stateMachines.completeWorkflow(converter.toPayloads(v.getT1()))); + } + } + + TestHistoryBuilder h = + historyWithVersionMarker("id1", 3).add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED); + + stateMachines = newStateMachines(new TestListener()); + h.handleWorkflowTaskTakeCommands(stateMachines); + + assertEquals(0, providerCalls.get()); + } + + private int getVersionFromMarker(Command command) { + return converter.fromPayloads( + 0, + Optional.ofNullable( + command + .getRecordMarkerCommandAttributes() + .getDetailsOrThrow(VersionMarkerUtils.MARKER_VERSION_KEY)), + Integer.class, + Integer.class); + } + + private TestHistoryBuilder historyWithVersionMarker(String changeId, int version) { + MarkerRecordedEventAttributes.Builder markerBuilder = + MarkerRecordedEventAttributes.newBuilder() + .setMarkerName(VersionMarkerUtils.MARKER_NAME) + .putDetails( + VersionMarkerUtils.MARKER_CHANGE_ID_KEY, converter.toPayloads(changeId).get()) + .putDetails(VersionMarkerUtils.MARKER_VERSION_KEY, converter.toPayloads(version).get()); + return new TestHistoryBuilder() + .add(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED) + .addWorkflowTask() + .add(EventType.EVENT_TYPE_MARKER_RECORDED, markerBuilder.build()); + } + @AfterClass public static void generateCoverage() { List>> diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java index 9bde963162..877f6fdce8 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java @@ -13,7 +13,8 @@ public void build() { WorkerOptions.Builder builder = WorkerOptions.newBuilder() .setMaxConcurrentActivityExecutionSize(10) - .setMaxConcurrentLocalActivityExecutionSize(11); + .setMaxConcurrentLocalActivityExecutionSize(11) + .setPreferredVersionProvider((input) -> null); verifyBuild(builder.build()); verifyBuild(builder.validateAndBuildWithDefaults()); @@ -22,6 +23,7 @@ public void build() { private void verifyBuild(WorkerOptions options) { assertEquals(10, options.getMaxConcurrentActivityExecutionSize()); assertEquals(11, options.getMaxConcurrentLocalActivityExecutionSize()); + assertNotNull(options.getPreferredVersionProvider()); } @Test @@ -33,6 +35,7 @@ public void verifyWorkerOptionsEquality() { @Test public void verifyNewBuilderFromExistingWorkerOptions() { + PreferredVersionProvider preferredVersionProvider = mock(PreferredVersionProvider.class); @SuppressWarnings("deprecation") WorkerOptions w1 = WorkerOptions.newBuilder() @@ -57,6 +60,7 @@ public void verifyNewBuilderFromExistingWorkerOptions() { .setStickyTaskQueueDrainTimeout(Duration.ofSeconds(15)) .setIdentity("worker-identity") .setAllowActivityHeartbeatDuringShutdown(true) + .setPreferredVersionProvider(preferredVersionProvider) .build(); WorkerOptions w2 = WorkerOptions.newBuilder(w1).build(); @@ -92,6 +96,7 @@ public void verifyNewBuilderFromExistingWorkerOptions() { assertEquals(w1.getIdentity(), w2.getIdentity()); assertEquals( w1.getAllowActivityHeartbeatDuringShutdown(), w2.getAllowActivityHeartbeatDuringShutdown()); + assertSame(w1.getPreferredVersionProvider(), w2.getPreferredVersionProvider()); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderRolloutTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderRolloutTest.java new file mode 100644 index 0000000000..cfdb1b9b5f --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderRolloutTest.java @@ -0,0 +1,239 @@ +package io.temporal.workflow.versionTests; + +import static io.temporal.testUtils.Eventually.assertEventually; +import static org.junit.Assert.assertEquals; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.worker.PreferredVersionProvider; +import io.temporal.worker.VersionPreference; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerOptions; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Test; + +public class PreferredVersionProviderRolloutTest { + private static final String CHANGE_ID = "preferred-change"; + private static final AtomicInteger unactivatedProviderCalls = new AtomicInteger(); + private static final AtomicInteger activatedProviderCalls = new AtomicInteger(); + private static final AtomicInteger oldWorkerSignals = new AtomicInteger(); + private static final AtomicInteger unactivatedNewWorkerSignals = new AtomicInteger(); + + @Before + public void setUp() { + unactivatedProviderCalls.set(0); + activatedProviderCalls.set(0); + oldWorkerSignals.set(0); + unactivatedNewWorkerSignals.set(0); + } + + @Test + public void unactivatedNewGetVersionCallReplaysOnOldWorkerWithoutTheCall() throws Exception { + String taskQueue = "preferred-version-provider-rollout-" + UUID.randomUUID(); + try (TestWorkflowEnvironment testEnvironment = TestWorkflowEnvironment.newInstance()) { + WorkerFactory newWorkerFactory = + WorkerFactory.newInstance(newClient(testEnvironment, "new-rollout-worker")); + Worker newWorker = + newWorkerFactory.newWorker( + taskQueue, + workerOptions( + (input) -> { + unactivatedProviderCalls.incrementAndGet(); + return VersionPreference.of(Workflow.DEFAULT_VERSION); + })); + newWorker.registerWorkflowImplementationTypes(NewRolloutWorkflowImpl.class); + + testEnvironment.start(); + newWorkerFactory.start(); + + RolloutWorkflow workflow = newWorkflowStub(testEnvironment, taskQueue); + WorkflowClient.start(workflow::run); + + assertEventually( + Duration.ofSeconds(5), () -> assertEquals(1, unactivatedProviderCalls.get())); + assertEquals("new-0", workflow.state()); + + shutdown(newWorkerFactory); + + WorkerFactory oldWorkerFactory = + WorkerFactory.newInstance(newClient(testEnvironment, "old-rollout-worker")); + Worker oldWorker = oldWorkerFactory.newWorker(taskQueue, workerOptions(null)); + oldWorker.registerWorkflowImplementationTypes(OldRolloutWorkflowImpl.class); + oldWorkerFactory.start(); + + workflow.release(); + testEnvironment.sleep(Duration.ofSeconds(1)); + + assertEventually(Duration.ofSeconds(5), () -> assertEquals(1, oldWorkerSignals.get())); + + workflow.release(); + assertEquals("old", WorkflowStub.fromTyped(workflow).getResult(String.class)); + shutdown(oldWorkerFactory); + } + } + + @Test + public void activatedGetVersionCallReplaysOnUnactivatedNewWorker() throws Exception { + String taskQueue = "preferred-version-provider-rollout-" + UUID.randomUUID(); + try (TestWorkflowEnvironment testEnvironment = TestWorkflowEnvironment.newInstance()) { + WorkerFactory activatedWorkerFactory = + WorkerFactory.newInstance(newClient(testEnvironment, "activated-rollout-worker")); + Worker activatedWorker = + activatedWorkerFactory.newWorker( + taskQueue, + workerOptions( + (input) -> { + activatedProviderCalls.incrementAndGet(); + return VersionPreference.of(1); + })); + activatedWorker.registerWorkflowImplementationTypes(NewRolloutWorkflowImpl.class); + + testEnvironment.start(); + activatedWorkerFactory.start(); + + RolloutWorkflow workflow = newWorkflowStub(testEnvironment, taskQueue); + WorkflowClient.start(workflow::run); + + assertEventually(Duration.ofSeconds(5), () -> assertEquals(1, activatedProviderCalls.get())); + assertEquals("new-0", workflow.state()); + + shutdown(activatedWorkerFactory); + + WorkerFactory unactivatedWorkerFactory = + WorkerFactory.newInstance(newClient(testEnvironment, "unactivated-rollout-worker")); + Worker unactivatedWorker = + unactivatedWorkerFactory.newWorker( + taskQueue, + workerOptions( + (input) -> { + unactivatedProviderCalls.incrementAndGet(); + return VersionPreference.of(Workflow.DEFAULT_VERSION); + })); + unactivatedWorker.registerWorkflowImplementationTypes( + UnactivatedNewRolloutWorkflowImpl.class); + unactivatedWorkerFactory.start(); + + workflow.release(); + testEnvironment.sleep(Duration.ofSeconds(1)); + + assertEventually( + Duration.ofSeconds(5), () -> assertEquals(1, unactivatedNewWorkerSignals.get())); + + workflow.release(); + assertEquals("new", WorkflowStub.fromTyped(workflow).getResult(String.class)); + assertEquals(0, unactivatedProviderCalls.get()); + shutdown(unactivatedWorkerFactory); + } + } + + private static RolloutWorkflow newWorkflowStub( + TestWorkflowEnvironment testEnvironment, String taskQueue) { + return testEnvironment + .getWorkflowClient() + .newWorkflowStub( + RolloutWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(taskQueue) + .setWorkflowId(UUID.randomUUID().toString()) + .build()); + } + + private static WorkerOptions workerOptions(PreferredVersionProvider preferredVersionProvider) { + return WorkerOptions.newBuilder() + .setStickyQueueScheduleToStartTimeout(Duration.ZERO) + .setPreferredVersionProvider(preferredVersionProvider) + .build(); + } + + private static WorkflowClient newClient( + TestWorkflowEnvironment testEnvironment, String identity) { + WorkflowClientOptions clientOptions = + testEnvironment.getWorkflowClient().getOptions().toBuilder() + .setIdentity(identity + "-" + UUID.randomUUID()) + .build(); + return WorkflowClient.newInstance(testEnvironment.getWorkflowServiceStubs(), clientOptions); + } + + private static void shutdown(WorkerFactory workerFactory) throws InterruptedException { + workerFactory.shutdownNow(); + workerFactory.awaitTermination(10, TimeUnit.SECONDS); + } + + @WorkflowInterface + public interface RolloutWorkflow { + @WorkflowMethod + String run(); + + @SignalMethod + void release(); + + @QueryMethod + String state(); + } + + public static class OldRolloutWorkflowImpl implements RolloutWorkflow { + private int releases; + + @Override + public String run() { + Workflow.await(() -> releases >= 1); + Workflow.await(() -> releases >= 2); + return "old"; + } + + @Override + public void release() { + oldWorkerSignals.incrementAndGet(); + releases++; + } + + @Override + public String state() { + return "old-" + releases; + } + } + + public static class NewRolloutWorkflowImpl implements RolloutWorkflow { + private int releases; + + @Override + public String run() { + int version = Workflow.getVersion(CHANGE_ID, Workflow.DEFAULT_VERSION, 1); + Workflow.await(() -> releases >= 1); + assertEquals(version, Workflow.getVersion(CHANGE_ID, Workflow.DEFAULT_VERSION, 1)); + Workflow.await(() -> releases >= 2); + return version == Workflow.DEFAULT_VERSION ? "old" : "new"; + } + + @Override + public void release() { + releases++; + } + + @Override + public String state() { + return "new-" + releases; + } + } + + public static class UnactivatedNewRolloutWorkflowImpl extends NewRolloutWorkflowImpl { + @Override + public void release() { + unactivatedNewWorkerSignals.incrementAndGet(); + super.release(); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderTest.java new file mode 100644 index 0000000000..f3da4f7e76 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/versionTests/PreferredVersionProviderTest.java @@ -0,0 +1,121 @@ +package io.temporal.workflow.versionTests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.internal.sync.ReadOnlyException; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.PreferredVersionProviderInput; +import io.temporal.worker.VersionPreference; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.shared.TestWorkflows.TestWorkflowReturnString; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class PreferredVersionProviderTest { + private static final String CHANGE_ID = "preferred-change"; + private static final AtomicInteger providerCalls = new AtomicInteger(); + private static final AtomicReference providerInput = + new AtomicReference<>(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestPreferredVersionWorkflow.class) + .setWorkerOptions( + WorkerOptions.newBuilder() + .setStickyQueueScheduleToStartTimeout(Duration.ZERO) + .setPreferredVersionProvider( + (input) -> { + providerCalls.incrementAndGet(); + providerInput.set(input); + return VersionPreference.of(Workflow.DEFAULT_VERSION); + }) + .build()) + .build(); + + @Before + public void setUp() { + providerCalls.set(0); + providerInput.set(null); + } + + @Test + public void providerReceivesGetVersionInputAndIsNotCalledOnReplay() { + TestWorkflowReturnString workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflowReturnString.class); + + String result = workflowStub.execute(); + + assertEquals("old", result); + assertEquals(1, providerCalls.get()); + + PreferredVersionProviderInput input = providerInput.get(); + assertNotNull(input); + assertEquals(CHANGE_ID, input.getChangeId()); + assertEquals(Workflow.DEFAULT_VERSION, input.getMinSupported()); + assertEquals(1, input.getMaxSupported()); + assertEquals( + WorkflowStub.fromTyped(workflowStub).getExecution().getWorkflowId(), + input.getWorkflowInfo().getWorkflowId()); + } + + @Test + public void providerRunsInReadOnlyContext() { + String taskQueue = "preferred-version-provider-read-only-" + UUID.randomUUID(); + AtomicReference readOnlyException = new AtomicReference<>(); + try (TestWorkflowEnvironment testEnvironment = TestWorkflowEnvironment.newInstance()) { + Worker worker = + testEnvironment.newWorker( + taskQueue, + WorkerOptions.newBuilder() + .setPreferredVersionProvider( + (input) -> { + try { + Workflow.randomUUID(); + } catch (ReadOnlyException e) { + readOnlyException.set(e); + return VersionPreference.of(Workflow.DEFAULT_VERSION); + } + throw new AssertionError("provider should run in a read-only context"); + }) + .build()); + worker.registerWorkflowImplementationTypes(TestPreferredVersionWorkflow.class); + testEnvironment.start(); + + TestWorkflowReturnString workflow = + testEnvironment + .getWorkflowClient() + .newWorkflowStub( + TestWorkflowReturnString.class, + WorkflowOptions.newBuilder().setTaskQueue(taskQueue).build()); + + assertEquals("old", workflow.execute()); + } + + assertNotNull(readOnlyException.get()); + assertEquals( + "While in read-only function, action attempted: random UUID", + readOnlyException.get().getMessage()); + } + + public static class TestPreferredVersionWorkflow implements TestWorkflowReturnString { + @Override + public String execute() { + int version = Workflow.getVersion(CHANGE_ID, Workflow.DEFAULT_VERSION, 1); + Workflow.sleep(Duration.ofMillis(1)); + assertEquals(version, Workflow.getVersion(CHANGE_ID, Workflow.DEFAULT_VERSION, 1)); + return version == Workflow.DEFAULT_VERSION ? "old" : "new"; + } + } +} From 2c5e662d2bec9bdac051e0d850acc91090792639 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 10 Jul 2026 10:54:25 -0700 Subject: [PATCH 033/107] Fix misleading docstring in preferred version PR (#2944) --- .../main/java/io/temporal/worker/PreferredVersionProvider.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java b/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java index cd8351c5af..f930f1d345 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/PreferredVersionProvider.java @@ -22,8 +22,7 @@ public interface PreferredVersionProvider { * *

This method is invoked on the workflow thread. Any exception it throws propagates out of the * {@code getVersion} call and fails the current workflow task, which will be retried; a provider - * that always throws will therefore block the workflow. Implementations should be deterministic - * and side-effect free. + * that always throws will therefore block the workflow. */ @Nullable VersionPreference getPreferredVersion(@Nonnull PreferredVersionProviderInput input); From c0c91323d01bf8207931518f46a8b1d923f6b04b Mon Sep 17 00:00:00 2001 From: Brian Strauch Date: Mon, 13 Jul 2026 09:44:12 -0700 Subject: [PATCH 034/107] Add temporal-workflowstreams contrib module (#2912) A durable, multi-topic pub/sub log hosted inside a workflow, mirroring the workflow streams contrib packages in the Go, Python, and TypeScript SDKs. External publishers send batches via a signal, subscribers long-poll via an update, and a query exposes the current offset; the wire protocol (handler names, JSON envelope field names, base64-of-proto per-item payload encoding) matches the other SDKs exactly for cross-language interop. The workflow side registers a typed listener and supports publisher dedup, ~1 MB poll response paging, truncation, and continue-as-new state carryover. The client side provides a batching publisher with retry and sequence-based exactly-once delivery, and a blocking subscription iterator that follows continue-as-new chains and ends cleanly on terminal workflow states. Like the Go SDK, the core SDK now permits registering signal, update, and query handlers in the __temporal_workflow_stream_ sub-namespace, which is otherwise reserved. --------- Co-authored-by: Claude Fable 5 --- .github/CODEOWNERS | 3 +- contrib/temporal-workflowstreams/README.md | 216 ++++++++++ contrib/temporal-workflowstreams/build.gradle | 14 + .../FlushTimeoutException.java | 14 + .../temporal/workflowstreams/PollInput.java | 30 ++ .../temporal/workflowstreams/PollResult.java | 34 ++ .../workflowstreams/PublishEntry.java | 27 ++ .../workflowstreams/PublishInput.java | 32 ++ .../workflowstreams/SubscribeOptions.java | 76 ++++ .../temporal/workflowstreams/TopicHandle.java | 57 +++ .../io/temporal/workflowstreams/WireItem.java | 31 ++ .../workflowstreams/WorkflowStream.java | 314 ++++++++++++++ .../workflowstreams/WorkflowStreamClient.java | 237 ++++++++++ .../WorkflowStreamClientOptions.java | 136 ++++++ .../WorkflowStreamConstants.java | 60 +++ .../WorkflowStreamHandlers.java | 30 ++ .../workflowstreams/WorkflowStreamItem.java | 35 ++ .../WorkflowStreamListener.java | 42 ++ .../WorkflowStreamOptions.java | 59 +++ .../workflowstreams/WorkflowStreamState.java | 34 ++ .../WorkflowStreamSubscription.java | 169 ++++++++ .../WorkflowStreamSubscriptionHandle.java | 26 ++ .../workflowstreams/WorkflowTopicHandle.java | 29 ++ .../workflowstreams/internal/PayloadWire.java | 40 ++ .../internal/StreamPublisher.java | 271 ++++++++++++ .../internal/SubscriptionDriver.java | 380 ++++++++++++++++ .../ListenerSubscribeTest.java | 408 ++++++++++++++++++ .../workflowstreams/PayloadWireTest.java | 75 ++++ .../workflowstreams/StreamPublisherTest.java | 241 +++++++++++ .../workflowstreams/SubscribeTest.java | 207 +++++++++ .../SubscribeTestWorkflows.java | 74 ++++ .../workflowstreams/WorkflowStreamTest.java | 321 ++++++++++++++ settings.gradle | 2 + temporal-bom/build.gradle | 1 + .../internal/common/InternalUtils.java | 17 +- .../internal/sync/QueryDispatcher.java | 4 +- .../internal/sync/SignalDispatcher.java | 4 +- .../internal/sync/UpdateDispatcher.java | 4 +- .../WorkflowStreamReservedNameTest.java | 123 ++++++ 39 files changed, 3871 insertions(+), 6 deletions(-) create mode 100644 contrib/temporal-workflowstreams/README.md create mode 100644 contrib/temporal-workflowstreams/build.gradle create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/FlushTimeoutException.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollInput.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollResult.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishEntry.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishInput.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/SubscribeOptions.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/TopicHandle.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WireItem.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStream.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClient.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClientOptions.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamConstants.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamHandlers.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamItem.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamListener.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamOptions.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamState.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscription.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscriptionHandle.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowTopicHandle.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/PayloadWire.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/StreamPublisher.java create mode 100644 contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/SubscriptionDriver.java create mode 100644 contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/ListenerSubscribeTest.java create mode 100644 contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/PayloadWireTest.java create mode 100644 contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/StreamPublisherTest.java create mode 100644 contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTest.java create mode 100644 contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTestWorkflows.java create mode 100644 contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/WorkflowStreamTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/WorkflowStreamReservedNameTest.java diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e958766a31..14580efb4f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,4 +12,5 @@ # For each one, we add the owning team, as well as # @temporalio/sdk, so the SDK team can continue to # manage repo-wide concerns -/contrib/temporal-spring-ai/ @temporalio/ai-sdk @temporalio/sdk +/contrib/temporal-spring-ai/ @temporalio/sdk @temporalio/ai-sdk +/contrib/temporal-workflowstreams/ @temporalio/sdk @temporalio/ai-sdk diff --git a/contrib/temporal-workflowstreams/README.md b/contrib/temporal-workflowstreams/README.md new file mode 100644 index 0000000000..ad630c89b7 --- /dev/null +++ b/contrib/temporal-workflowstreams/README.md @@ -0,0 +1,216 @@ +# Workflow Streams + +A durable publish/subscribe log hosted inside a Temporal Workflow. + +External code (activities, starters, other processes) publishes messages to +named topics via **signals**; subscribers long-poll for new items via +**updates**; a **query** exposes the current offset. The stream is backed by +Temporal's durable execution, giving ordered, durable, exactly-once delivery +with client-side batching, publisher dedup, continue-as-new survival, +truncation, and ~1 MB response paging. + +It is well suited to durable event streams whose cost scales with durable +batches rather than message count. Each poll round-trip costs ~100 ms of +latency, so it is not intended for ultra-low-latency streaming. + +All APIs in this module are experimental and may change. + +## Workflow side + +Construct a `WorkflowStream` once in a `@WorkflowInit` constructor. The factory +registers the publish signal, poll update, and offset query handlers, and a +`@WorkflowInit` constructor runs before any handler dispatch, so polls and +offset queries arriving with the first workflow task (e.g. from +update-with-start) are accepted rather than rejected. + +```java +public class MyInput { + public int itemsProcessed; // your own workflow state + public WorkflowStreamState streamState; +} + +public class MyWorkflowImpl implements MyWorkflow { + private final WorkflowStream stream; + + @WorkflowInit + public MyWorkflowImpl(MyInput input) { + stream = WorkflowStream.newInstance(input.streamState); + } + + @Override + public void execute(MyInput input) { + // Optionally publish from workflow code: + stream.topic("events").publish("hello from the workflow"); + + // Run your workflow; the stream serves external publishers and subscribers + // for as long as the workflow is running. Block until your workflow's exit + // condition is met (here, a `done` flag set elsewhere, e.g. by a signal). + Workflow.await(() -> done); + } +} +``` + +Constructing the stream at the top of the workflow method also works — signals +received earlier are buffered by the SDK — but polls and offset queries are +rejected until the stream exists, so prefer `@WorkflowInit`. + +For workflows that use continue-as-new, the stream's log and offsets must be +carried across each boundary, since continue-as-new starts a fresh run with an +empty history. This is a round-trip with two halves: + +- **Capture** the state when rolling over. Instead of calling + `Workflow.continueAsNew` directly, call `stream.continueAsNew`. It drains + pollers, waits for in-flight handlers, snapshots the current stream state, and + hands it to your callback, which builds the argument list for the next run. + The callback is where you assemble the full input — carry forward your own + workflow state alongside the captured `state`: + + ```java + stream.continueAsNew(state -> { + MyInput next = new MyInput(); + next.itemsProcessed = itemsProcessed; // your own state, carried across the boundary + next.streamState = state; // the captured stream state + return new Object[] {next}; + }); + ``` + +- **Restore** it on the next run. That `MyInput` arrives as the next run's + input, and its `streamState` field is the value already passed to + `WorkflowStream.newInstance` in the example above. It is `null` on a fresh + start and non-null after a roll-over, so the stream rehydrates the log + automatically. + +The `WorkflowStreamState` field is what gives the captured stream state +somewhere to live between runs; the other fields on `MyInput` are your own and +are threaded through the same way. + +## Publishing (client side) + +From an activity, use `fromActivity` to target the parent workflow: + +```java +public void publishActivity() { + try (WorkflowStreamClient client = WorkflowStreamClient.fromActivity()) { + TopicHandle topic = client.topic("events"); + for (int i = 0; i < 100; i++) { + topic.publish("item " + i); + } + } // client.close() is called on completion, which flushes the remaining buffer +} +``` + +From a starter or any code with a `WorkflowClient`, use `newInstance` with an +explicit workflow ID: + +```java +try (WorkflowStreamClient client = WorkflowStreamClient.newInstance(workflowClient, workflowId)) { + client.topic("events").publish("from outside", /* forceFlush */ true); +} +``` + +Items are buffered and flushed automatically every batch interval (default 2s), +when the buffer reaches the max batch size, on `forceFlush`, on an explicit +`flush()`, or on `close()`. + +## Subscribing + +There are two subscriber APIs over one shared poll engine: a non-blocking +listener and a blocking iterator. Neither occupies a thread while a poll is +blocked on the server — polling runs on a small executor shared by all of a +client's subscriptions (2 daemon threads by default; see `pollExecutor`) — so +many concurrent subscriptions do not mean many threads. Either way, the +subscription ends cleanly when the workflow reaches a terminal state, +automatically follows continue-as-new chains, recovers from truncation by +restarting from the current base offset, and also ends when the owning +`WorkflowStreamClient` is closed. + +Items carry the raw `io.temporal.api.common.v1.Payload`; decode at the call +site with your data converter. Offsets are **global** (across all topics), not +per-topic. + +### Listener (non-blocking) + +Pass a `WorkflowStreamListener` to `subscribe` to have items delivered on the +poll executor. Callbacks are serialized (never invoked concurrently) and must +not block; the `CompletionStage` returned by `onNext` is the backpressure +boundary — return `null` (or a completed stage) to receive the next item +immediately, or a pending stage to defer further delivery and polling until it +completes: + +```java +SubscribeOptions options = SubscribeOptions.newBuilder() + .setTopics("events") // unset = all topics + .build(); +WorkflowStreamSubscriptionHandle handle = + client.subscribe( + options, + new WorkflowStreamListener() { + @Override + public CompletionStage onNext(WorkflowStreamItem item) { + String value = + DefaultDataConverter.STANDARD_INSTANCE.fromPayload( + item.getPayload(), String.class, String.class); + System.out.printf( + "offset=%d topic=%s value=%s%n", item.getOffset(), item.getTopic(), value); + return null; // or a pending stage to apply backpressure + } + + @Override + public void onCompleted() { + System.out.println("stream ended"); + } + }); +``` + +`handle.close()` stops the subscription before the next poll (without calling +`onCompleted`); `handle.getDoneFuture()` completes when the subscription ends — +normally on a clean end or close, exceptionally with the failure passed to +`onError`. + +### Iterator (blocking) + +For synchronous consumers, `subscribe` without a listener returns a blocking, +single-use subscription; the consuming thread blocks waiting for items while +polling still runs on the shared executor: + +```java +try (WorkflowStreamSubscription subscription = client.subscribe(options)) { + for (WorkflowStreamItem item : subscription) { + String value = + DefaultDataConverter.STANDARD_INSTANCE.fromPayload( + item.getPayload(), String.class, String.class); + System.out.printf("offset=%d topic=%s value=%s%n", item.getOffset(), item.getTopic(), value); + } +} +``` + +`close()` stops it before the next poll; items already fetched still drain. An +unrecoverable poll failure is rethrown from `hasNext()`. + +## Options + +| Option | Default | Meaning | +| --- | --- | --- | +| `batchInterval` | 2s | Automatic flush interval | +| `maxBatchSize` | unset | Flush once the buffer reaches this size | +| `maxRetryDuration` | 10m | Max time to retry a failed flush before `FlushTimeoutException`. Must be < the workflow's publisher TTL (15m) to preserve exactly-once delivery | +| `payloadConverters` | standard set | Per-item serialization. Payload conversion only — the client's codec chain runs once on the envelope, never per item | +| `pollExecutor` | 2 daemon threads, client-owned | Scheduler shared by the client's subscriptions. It runs the short update-admission and delivery steps and poll cooldowns — never held during the long poll itself. A user-supplied executor is never shut down by the client; supply a bigger pool for many subscriptions against slow workflows | +| `SubscribeOptions.pollCooldown` | 100ms | Min interval between polls | + +## Cross-language protocol + +The handler names (`WorkflowStreamConstants.PUBLISH_SIGNAL_NAME`, +`POLL_UPDATE_NAME`, `OFFSET_QUERY_NAME`), the JSON envelope field names, and +the per-item payload encoding (base64 of the serialized +`temporal.api.common.v1.Payload`) match other languages' packages +exactly, so a Java publisher or subscriber interoperates with a workflow +written in any of them and vice versa. The data converter codec chain +(encryption, compression) runs once on the signal/update envelope — never per +item — so payloads are not double-encoded. + +One Java-specific caveat: the protocol envelope types are serialized by the +workflow's and client's *configured* data converter. The default Jackson JSON +converter produces the wire-compatible snake_case field names (the types are +annotated with `@JsonProperty`); if you configure a non-Jackson JSON converter, +it must produce the same field names for cross-language interop. diff --git a/contrib/temporal-workflowstreams/build.gradle b/contrib/temporal-workflowstreams/build.gradle new file mode 100644 index 0000000000..46a8ec9e6f --- /dev/null +++ b/contrib/temporal-workflowstreams/build.gradle @@ -0,0 +1,14 @@ +description = '''Temporal Workflow Streams: a durable, multi-topic pub/sub log hosted inside a workflow''' + +dependencies { + // this module shouldn't carry temporal-sdk with it, especially for situations when users may be using a shaded artifact + compileOnly project(':temporal-sdk') + // Jackson annotations lock the cross-SDK wire field names; provided at runtime by temporal-sdk + compileOnly "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" + + testImplementation project(':temporal-sdk') + testImplementation project(':temporal-testing') + testImplementation "junit:junit:${junitVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/FlushTimeoutException.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/FlushTimeoutException.java new file mode 100644 index 0000000000..4d1b36a243 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/FlushTimeoutException.java @@ -0,0 +1,14 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; + +/** + * Thrown when a flush retry exceeds the client's max retry duration. The pending batch is dropped; + * if the signal had already been delivered the items are in the log, otherwise they are lost. + */ +@Experimental +public final class FlushTimeoutException extends RuntimeException { + public FlushTimeoutException(String message) { + super(message); + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollInput.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollInput.java new file mode 100644 index 0000000000..373133047f --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollInput.java @@ -0,0 +1,30 @@ +package io.temporal.workflowstreams; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.common.Experimental; +import java.util.ArrayList; +import java.util.List; + +/** + * The poll update payload: a request to long-poll for new items. + * + *

Field names are part of the cross-language wire protocol; this type must serialize to JSON + * with exactly these names. + */ +@Experimental +public final class PollInput { + /** Topics to filter on. Empty means all topics. */ + @JsonProperty("topics") + public List topics = new ArrayList<>(); + + /** Global offset to start from. Zero means the beginning. */ + @JsonProperty("from_offset") + public long fromOffset; + + public PollInput() {} + + public PollInput(List topics, long fromOffset) { + this.topics = topics; + this.fromOffset = fromOffset; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollResult.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollResult.java new file mode 100644 index 0000000000..5a1c0f0e56 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PollResult.java @@ -0,0 +1,34 @@ +package io.temporal.workflowstreams; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.common.Experimental; +import java.util.ArrayList; +import java.util.List; + +/** + * The poll update response: items matching the poll request. When {@code more_ready} is true the + * response was truncated to stay within size limits and the subscriber should poll again + * immediately rather than applying a cooldown. + * + *

Field names are part of the cross-language wire protocol; this type must serialize to JSON + * with exactly these names. + */ +@Experimental +public final class PollResult { + @JsonProperty("items") + public List items = new ArrayList<>(); + + @JsonProperty("next_offset") + public long nextOffset; + + @JsonProperty("more_ready") + public boolean moreReady; + + public PollResult() {} + + public PollResult(List items, long nextOffset, boolean moreReady) { + this.items = items; + this.nextOffset = nextOffset; + this.moreReady = moreReady; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishEntry.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishEntry.java new file mode 100644 index 0000000000..f11b2c9893 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishEntry.java @@ -0,0 +1,27 @@ +package io.temporal.workflowstreams; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.common.Experimental; + +/** + * A single entry within a publish batch on the wire. {@code data} is a base64-encoded, serialized + * {@link io.temporal.api.common.v1.Payload}. + * + *

Field names are part of the cross-language wire protocol; this type must serialize to JSON + * with exactly these names. + */ +@Experimental +public final class PublishEntry { + @JsonProperty("topic") + public String topic; + + @JsonProperty("data") + public String data; + + public PublishEntry() {} + + public PublishEntry(String topic, String data) { + this.topic = topic; + this.data = data; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishInput.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishInput.java new file mode 100644 index 0000000000..f0c2c40635 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/PublishInput.java @@ -0,0 +1,32 @@ +package io.temporal.workflowstreams; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.common.Experimental; +import java.util.ArrayList; +import java.util.List; + +/** + * The publish signal payload carrying a batch of entries, along with the dedup fields. + * + *

Field names are part of the cross-language wire protocol; this type must serialize to JSON + * with exactly these names. + */ +@Experimental +public final class PublishInput { + @JsonProperty("items") + public List items = new ArrayList<>(); + + @JsonProperty("publisher_id") + public String publisherId = ""; + + @JsonProperty("sequence") + public long sequence; + + public PublishInput() {} + + public PublishInput(List items, String publisherId, long sequence) { + this.items = items; + this.publisherId = publisherId; + this.sequence = sequence; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/SubscribeOptions.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/SubscribeOptions.java new file mode 100644 index 0000000000..3c1bbc19d0 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/SubscribeOptions.java @@ -0,0 +1,76 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** Options for {@link WorkflowStreamClient#subscribe}. */ +@Experimental +public final class SubscribeOptions { + public static Builder newBuilder() { + return new Builder(); + } + + public static SubscribeOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final SubscribeOptions DEFAULT_INSTANCE = newBuilder().build(); + + private final List topics; + private final long fromOffset; + private final Duration pollCooldown; + + private SubscribeOptions(List topics, long fromOffset, Duration pollCooldown) { + this.topics = topics; + this.fromOffset = fromOffset; + this.pollCooldown = pollCooldown; + } + + public List getTopics() { + return topics; + } + + public long getFromOffset() { + return fromOffset; + } + + public Duration getPollCooldown() { + return pollCooldown; + } + + public static final class Builder { + private List topics = new ArrayList<>(); + private long fromOffset; + private Duration pollCooldown = WorkflowStreamConstants.DEFAULT_POLL_COOLDOWN; + + private Builder() {} + + /** Filters the subscription. Empty (the default) means all topics. */ + public Builder setTopics(String... topics) { + this.topics = new ArrayList<>(Arrays.asList(topics)); + return this; + } + + /** Global offset to start from. Zero (the default) means the beginning. */ + public Builder setFromOffset(long fromOffset) { + this.fromOffset = fromOffset; + return this; + } + + /** + * Minimum interval between polls when no more items are immediately ready. Default: 100 + * milliseconds. + */ + public Builder setPollCooldown(Duration pollCooldown) { + this.pollCooldown = pollCooldown; + return this; + } + + public SubscribeOptions build() { + return new SubscribeOptions(topics, fromOffset, pollCooldown); + } + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/TopicHandle.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/TopicHandle.java new file mode 100644 index 0000000000..09807319e3 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/TopicHandle.java @@ -0,0 +1,57 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; + +/** + * Publishes to and subscribes from a single topic. Obtained via {@link WorkflowStreamClient#topic}. + */ +@Experimental +public final class TopicHandle { + private final String name; + private final WorkflowStreamClient client; + + TopicHandle(String name, WorkflowStreamClient client) { + this.name = name; + this.client = client; + } + + /** Returns the topic name. */ + public String getName() { + return name; + } + + /** Buffers {@code value} for publishing on this topic. See {@link #publish(Object, boolean)}. */ + public void publish(Object value) { + publish(value, false); + } + + /** + * Buffers {@code value} for publishing on this topic. {@code value} goes through the client's + * payload converters immediately, so an unconvertible value fails this call rather than a later + * background flush; a pre-built {@link io.temporal.api.common.v1.Payload} bypasses conversion. + * Pass {@code forceFlush} to wake the publisher and send immediately. + */ + public void publish(Object value, boolean forceFlush) { + client.publishToTopic(name, value, forceFlush); + } + + /** + * Returns a subscription over items on this topic, starting at {@code fromOffset}. See {@link + * WorkflowStreamClient#subscribe(SubscribeOptions)}. + */ + public WorkflowStreamSubscription subscribe(long fromOffset) { + return client.subscribe( + SubscribeOptions.newBuilder().setTopics(name).setFromOffset(fromOffset).build()); + } + + /** + * Subscribes {@code listener} to items on this topic, starting at {@code fromOffset}, without + * occupying a caller thread. See {@link WorkflowStreamClient#subscribe(SubscribeOptions, + * WorkflowStreamListener)}. + */ + public WorkflowStreamSubscriptionHandle subscribe( + long fromOffset, WorkflowStreamListener listener) { + return client.subscribe( + SubscribeOptions.newBuilder().setTopics(name).setFromOffset(fromOffset).build(), listener); + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WireItem.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WireItem.java new file mode 100644 index 0000000000..b88dd90f82 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WireItem.java @@ -0,0 +1,31 @@ +package io.temporal.workflowstreams; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.common.Experimental; + +/** + * The wire representation of a stream item. {@code data} is a base64-encoded, serialized {@link + * io.temporal.api.common.v1.Payload}. + * + *

Field names are part of the cross-language wire protocol; this type must serialize to JSON + * with exactly these names. + */ +@Experimental +public final class WireItem { + @JsonProperty("topic") + public String topic; + + @JsonProperty("data") + public String data; + + @JsonProperty("offset") + public long offset; + + public WireItem() {} + + public WireItem(String topic, String data, long offset) { + this.topic = topic; + this.data = data; + this.offset = offset; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStream.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStream.java new file mode 100644 index 0000000000..32bfbc64bf --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStream.java @@ -0,0 +1,314 @@ +package io.temporal.workflowstreams; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; +import io.temporal.workflow.ContinueAsNewOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflowstreams.internal.PayloadWire; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import javax.annotation.Nullable; + +/** + * The workflow-side stream object: an append-only, multi-topic log served to external publishers + * (via signal), subscribers (via update), and offset queries (via query). + * + *

Construct it once with {@link #newInstance}, preferably in a {@link + * io.temporal.workflow.WorkflowInit} constructor: the factory registers all three handlers on the + * current workflow, and a {@code @WorkflowInit} constructor runs before any handler dispatch, so + * polls arriving with the first workflow task are accepted. Constructing it at the start of the + * workflow method also works — signals received earlier are buffered by the SDK — but polls and + * offset queries are rejected until the stream exists. + */ +@Experimental +public final class WorkflowStream { + + /** A single decoded log entry held in workflow memory. */ + private static final class InternalEntry { + final String topic; + final Payload payload; + + InternalEntry(String topic, Payload payload) { + this.topic = topic; + this.payload = payload; + } + } + + private final DataConverter dataConverter; + + private final List log = new ArrayList<>(); + private long baseOffset; + private final Map publisherSequences = new HashMap<>(); + private final Map publisherLastSeen = new HashMap<>(); + private boolean draining; + + private final Map topicHandles = new HashMap<>(); + + /** Constructs a stream with no prior state and default options. */ + public static WorkflowStream newInstance() { + return newInstance(null, WorkflowStreamOptions.getDefaultInstance()); + } + + /** + * Constructs a stream, restoring state carried across a continue-as-new boundary. {@code + * priorState} may be null. + */ + public static WorkflowStream newInstance(@Nullable WorkflowStreamState priorState) { + return newInstance(priorState, WorkflowStreamOptions.getDefaultInstance()); + } + + /** + * Constructs a {@code WorkflowStream} and registers its signal, update, and query handlers on the + * current workflow. Pass {@code priorState} (which may be null) to restore state carried across a + * continue-as-new boundary. + */ + public static WorkflowStream newInstance( + @Nullable WorkflowStreamState priorState, WorkflowStreamOptions options) { + return new WorkflowStream(priorState, options); + } + + private WorkflowStream(@Nullable WorkflowStreamState priorState, WorkflowStreamOptions options) { + // A converter built only from PayloadConverters is codec-free, so workflow-published + // items are never double-encoded against the worker's response codec. + if (options.getPayloadConverters().length > 0) { + this.dataConverter = new DefaultDataConverter(options.getPayloadConverters()); + } else { + this.dataConverter = DefaultDataConverter.STANDARD_INSTANCE; + } + + if (priorState != null) { + baseOffset = priorState.baseOffset; + if (priorState.log != null) { + for (WireItem item : priorState.log) { + log.add(new InternalEntry(item.topic, PayloadWire.decode(item.data))); + } + } + if (priorState.publisherSequences != null) { + publisherSequences.putAll(priorState.publisherSequences); + } + if (priorState.publisherLastSeen != null) { + publisherLastSeen.putAll(priorState.publisherLastSeen); + } + } + + Workflow.registerListener(new HandlersImpl()); + } + + /** + * Returns a handle for publishing to {@code name}. Repeated calls with the same name return the + * same handle. + */ + public WorkflowTopicHandle topic(String name) { + return topicHandles.computeIfAbsent(name, n -> new WorkflowTopicHandle(n, this)); + } + + /** Unblocks all waiting poll handlers and rejects new polls. Used before continue-as-new. */ + public void detachPollers() { + draining = true; + } + + /** + * Returns a serializable snapshot of stream state for continue-as-new. It drops per-publisher + * sequence tracking for publishers that have not sent a batch within {@code publisherTtl}. + */ + public WorkflowStreamState getState(Duration publisherTtl) { + double now = Workflow.currentTimeMillis() / 1000.0; + double ttlSeconds = publisherTtl.toMillis() / 1000.0; + + WorkflowStreamState state = new WorkflowStreamState(); + state.baseOffset = baseOffset; + for (Map.Entry e : publisherSequences.entrySet()) { + Double ts = publisherLastSeen.get(e.getKey()); + double lastSeen = ts == null ? 0 : ts; + if (now - lastSeen < ttlSeconds) { + state.publisherSequences.put(e.getKey(), e.getValue()); + state.publisherLastSeen.put(e.getKey(), lastSeen); + } + } + + for (InternalEntry entry : log) { + // Per-item offset is re-derivable from baseOffset + index on reload. + state.log.add(new WireItem(entry.topic, PayloadWire.encode(entry.payload), 0)); + } + return state; + } + + /** + * Drains pollers, waits for in-flight handlers to finish, captures stream state, and + * continues-as-new with the arguments built by {@code buildArgs}, so it can take a moment before + * the current run ends. {@code buildArgs} receives the post-detach stream state and returns the + * positional arguments for the new run; thread the {@link WorkflowStreamState} into your workflow + * input so the stream survives the rollover. + * + *

State is captured with the default 15-minute publisher TTL. For a custom TTL, use the manual + * recipe: {@link #detachPollers}, {@code Workflow.await(() -> + * Workflow.isEveryHandlerFinished())}, {@link #getState}, then {@link Workflow#continueAsNew}. + * + *

This method never returns. + */ + public void continueAsNew(Function buildArgs) { + continueAsNew(null, buildArgs); + } + + /** See {@link #continueAsNew(Function)}. */ + public void continueAsNew( + @Nullable ContinueAsNewOptions options, Function buildArgs) { + detachPollers(); + Workflow.await(() -> Workflow.isEveryHandlerFinished()); + WorkflowStreamState state = getState(WorkflowStreamConstants.DEFAULT_PUBLISHER_TTL); + Workflow.continueAsNew(options, buildArgs.apply(state)); + } + + /** + * Discards log entries before {@code upToOffset} and advances the base offset. After truncation, + * polls requesting an offset before the new base receive a {@code TruncatedOffset} error. + * + * @throws io.temporal.failure.ApplicationFailure a non-retryable {@code TruncateOutOfRange} + * failure if {@code upToOffset} is past the end of the log + */ + public void truncate(long upToOffset) { + long logIndex = upToOffset - baseOffset; + if (logIndex <= 0) { + return; + } + if (logIndex > log.size()) { + throw ApplicationFailure.newNonRetryableFailure( + String.format( + "cannot truncate to offset %d: only %d items exist", + upToOffset, baseOffset + log.size()), + WorkflowStreamConstants.ERROR_TYPE_TRUNCATE_OUT_OF_RANGE); + } + log.subList(0, (int) logIndex).clear(); + baseOffset = upToOffset; + } + + void publishToTopic(String topic, Object value) { + Payload payload; + if (value instanceof Payload) { + payload = (Payload) value; + } else { + payload = + dataConverter + .toPayload(value) + .orElseThrow( + () -> + new IllegalArgumentException( + "workflowstreams: no payload converter accepted the published value")); + } + log.add(new InternalEntry(topic, payload)); + } + + private class HandlersImpl implements WorkflowStreamHandlers { + @Override + public void publish(PublishInput input) { + if (input.publisherId != null && !input.publisherId.isEmpty()) { + Long lastSeq = publisherSequences.get(input.publisherId); + if (lastSeq != null && input.sequence <= lastSeq) { + return; // duplicate — skip + } + publisherSequences.put(input.publisherId, input.sequence); + publisherLastSeen.put(input.publisherId, Workflow.currentTimeMillis() / 1000.0); + } + if (input.items == null) { + return; + } + for (PublishEntry entry : input.items) { + Payload payload; + try { + payload = PayloadWire.decode(entry.data); + } catch (RuntimeException e) { + // A malformed entry would be a protocol violation; skip it rather than + // corrupting the log. + continue; + } + log.add(new InternalEntry(entry.topic, payload)); + } + } + + @Override + public void validatePoll(PollInput input) { + if (draining) { + throw ApplicationFailure.newNonRetryableFailure( + "workflow is draining for continue-as-new", + WorkflowStreamConstants.ERROR_TYPE_STREAM_DRAINING); + } + } + + @Override + public PollResult poll(PollInput input) { + // Wait until items at or after the requested offset are available, the requested + // offset has been truncated away, or the stream is draining. baseOffset can advance + // via truncate while we wait, so re-evaluate the requested position against the + // current baseOffset on every check rather than capturing it once up front — + // otherwise a truncation that passes the waiting offset leaves the condition + // permanently unsatisfiable. + boolean[] truncated = new boolean[1]; + Workflow.await( + () -> { + if (draining) { + return true; + } + if (input.fromOffset != 0 && input.fromOffset < baseOffset) { + // The subscriber's position was truncated, possibly while waiting. + truncated[0] = true; + return true; + } + // max clamps "from the beginning" to whatever is available. + long logOffset = Math.max(input.fromOffset - baseOffset, 0); + return log.size() > logOffset; + }); + if (truncated[0]) { + throw ApplicationFailure.newNonRetryableFailure( + String.format( + "requested offset %d has been truncated; current base offset is %d", + input.fromOffset, baseOffset), + WorkflowStreamConstants.ERROR_TYPE_TRUNCATED_OFFSET); + } + + long logOffset = Math.max(input.fromOffset - baseOffset, 0); + + Set topicSet = + input.topics == null || input.topics.isEmpty() ? null : new HashSet<>(input.topics); + + List wireItems = new ArrayList<>(); + int size = 0; + boolean moreReady = false; + long nextOffset = baseOffset + log.size(); + + for (long i = logOffset; i < log.size(); i++) { + InternalEntry entry = log.get((int) i); + if (topicSet != null && !topicSet.contains(entry.topic)) { + continue; + } + long globalOffset = baseOffset + i; + String encoded = PayloadWire.encode(entry.payload); + int itemSize = PayloadWire.wireSize(encoded, entry.topic); + if (size + itemSize > WorkflowStreamConstants.MAX_POLL_RESPONSE_BYTES + && !wireItems.isEmpty()) { + // Resume from this item on the next poll. + nextOffset = globalOffset; + moreReady = true; + break; + } + size += itemSize; + wireItems.add(new WireItem(entry.topic, encoded, globalOffset)); + } + + return new PollResult(wireItems, nextOffset, moreReady); + } + + @Override + public long offset() { + return baseOffset + log.size(); + } + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClient.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClient.java new file mode 100644 index 0000000000..b5348879d4 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClient.java @@ -0,0 +1,237 @@ +package io.temporal.workflowstreams; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.workflowstreams.internal.StreamPublisher; +import io.temporal.workflowstreams.internal.SubscriptionDriver; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; + +/** + * Publishes to and subscribes from a workflow stream from external code (activities, starters, + * other processes). The publish path is owned by an internal publisher that batches buffered items + * and signals them to the workflow; the client itself holds the target workflow and the read + * (subscribe/query) surface. + * + *

Close the client (e.g. via try-with-resources) to guarantee a final flush of buffered items. + */ +@Experimental +public final class WorkflowStreamClient implements AutoCloseable { + private final WorkflowClient client; + private final String workflowId; + private final StreamPublisher publisher; + @Nullable private final ScheduledExecutorService userPollExecutor; + + private final Map topicHandles = new HashMap<>(); + private final Set liveSubscriptions = ConcurrentHashMap.newKeySet(); + + // Lazily created when the first subscription needs it and no user executor was supplied; + // owned by this client and shut down in close(). Guarded by `this`. + @Nullable private ScheduledExecutorService ownedPollExecutor; + + /** Creates a client targeting {@code workflowId} through the given Temporal client. */ + public static WorkflowStreamClient newInstance(WorkflowClient client, String workflowId) { + return newInstance(client, workflowId, WorkflowStreamClientOptions.getDefaultInstance()); + } + + /** + * Creates a client targeting {@code workflowId} through the given Temporal client. The returned + * client follows continue-as-new chains in {@link #subscribe}. + */ + public static WorkflowStreamClient newInstance( + WorkflowClient client, String workflowId, WorkflowStreamClientOptions options) { + return new WorkflowStreamClient(client, workflowId, options); + } + + /** See {@link #fromActivity(WorkflowStreamClientOptions)}. */ + public static WorkflowStreamClient fromActivity() { + return fromActivity(WorkflowStreamClientOptions.getDefaultInstance()); + } + + /** + * Creates a client targeting the current activity's parent workflow, using the activity's + * Temporal client. Must be called from an activity thread. + * + * @throws IllegalStateException if not called from an activity, or if the activity has no parent + * workflow; in the latter case use {@link #newInstance} with an explicit workflow ID + */ + public static WorkflowStreamClient fromActivity(WorkflowStreamClientOptions options) { + ActivityExecutionContext context = Activity.getExecutionContext(); + String workflowId = context.getInfo().getWorkflowId(); + if (workflowId == null || workflowId.isEmpty()) { + throw new IllegalStateException( + "workflowstreams: fromActivity requires an activity scheduled by a workflow; otherwise" + + " use newInstance with an explicit workflow ID"); + } + return newInstance(context.getWorkflowClient(), workflowId, options); + } + + private WorkflowStreamClient( + WorkflowClient client, String workflowId, WorkflowStreamClientOptions options) { + this.client = client; + this.workflowId = workflowId; + + // A converter built only from PayloadConverters is codec-free, so items are never + // double-encoded against the codec on the client's signal/update envelope. + DataConverter dataConverter; + if (options.getPayloadConverters().length > 0) { + dataConverter = new DefaultDataConverter(options.getPayloadConverters()); + } else { + dataConverter = DefaultDataConverter.STANDARD_INSTANCE; + } + + this.userPollExecutor = options.getPollExecutor(); + + WorkflowStub stub = client.newUntypedWorkflowStub(workflowId); + this.publisher = + new StreamPublisher( + input -> stub.signal(WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, input), + dataConverter, + options.getBatchInterval(), + options.getMaxBatchSize(), + options.getMaxRetryDuration()); + } + + /** + * Returns a handle for publishing to and subscribing from {@code name}. Repeated calls with the + * same name return the same handle. + */ + public synchronized TopicHandle topic(String name) { + return topicHandles.computeIfAbsent(name, n -> new TopicHandle(n, this)); + } + + /** + * Sends buffered (and pending) items and waits for server confirmation. Returns once the items + * buffered at call time have been signaled to the workflow and acknowledged. + * + * @throws FlushTimeoutException if a pending batch cannot be sent within the max retry duration + */ + public void flush() { + publisher.flush(); + } + + /** Queries the current global offset of the stream. */ + public long getOffset() { + return client + .newUntypedWorkflowStub(workflowId) + .query(WorkflowStreamConstants.OFFSET_QUERY_NAME, Long.class); + } + + /** + * Returns a subscription that long-polls for new items. Iterate with: + * + *

{@code
+   * try (WorkflowStreamSubscription subscription = streamClient.subscribe(options)) {
+   *   for (WorkflowStreamItem item : subscription) {
+   *     // use item
+   *   }
+   * }
+   * }
+ * + *

The consuming thread blocks waiting for items; polling itself runs on the client's poll + * executor. Each item carries the raw {@link io.temporal.api.common.v1.Payload}; decode it with + * your data converter. The subscription ends cleanly when the workflow reaches a terminal state, + * automatically follows continue-as-new chains, and also ends when this client is closed. + */ + public WorkflowStreamSubscription subscribe(SubscribeOptions options) { + return new WorkflowStreamSubscription(listener -> newSubscriptionDriver(options, listener)); + } + + /** + * Subscribes {@code listener} to the stream without occupying a caller thread: polling runs on + * the client's poll executor (see {@link WorkflowStreamClientOptions.Builder#setPollExecutor}), + * which is never held while a poll is blocked on the server, so many subscriptions share a small + * pool. Delivery starts immediately. + * + *

The stream ends cleanly with {@link WorkflowStreamListener#onCompleted} when the workflow + * reaches a terminal state, automatically follows continue-as-new chains, and reports + * unrecoverable failures to {@link WorkflowStreamListener#onError}. Stop it early with {@link + * WorkflowStreamSubscriptionHandle#close}; closing this client also stops it. + */ + public WorkflowStreamSubscriptionHandle subscribe( + SubscribeOptions options, WorkflowStreamListener listener) { + SubscriptionDriver driver = newSubscriptionDriver(options, listener); + driver.start(); + return driver; + } + + SubscriptionDriver newSubscriptionDriver( + SubscribeOptions options, WorkflowStreamListener listener) { + SubscriptionDriver driver = + new SubscriptionDriver( + client, workflowId, options, pollExecutor(), listener, liveSubscriptions::remove); + liveSubscriptions.add(driver); + return driver; + } + + private ScheduledExecutorService pollExecutor() { + if (userPollExecutor != null) { + return userPollExecutor; + } + synchronized (this) { + if (ownedPollExecutor == null) { + AtomicInteger threadNumber = new AtomicInteger(); + ScheduledThreadPoolExecutor executor = + new ScheduledThreadPoolExecutor( + WorkflowStreamConstants.DEFAULT_POLL_EXECUTOR_THREADS, + r -> { + Thread t = + new Thread( + r, "temporal-workflow-stream-poll-" + threadNumber.incrementAndGet()); + t.setDaemon(true); + return t; + }); + executor.setRemoveOnCancelPolicy(true); + ownedPollExecutor = executor; + } + return ownedPollExecutor; + } + } + + /** + * Stops the background publisher and drains any remaining items, guaranteeing a final flush. It + * surfaces any deferred {@link FlushTimeoutException} from a prior background flush failure. + * + *

Also stops this client's live subscriptions (their done futures complete normally, without + * {@link WorkflowStreamListener#onCompleted}) and, if the client owns the default poll executor, + * shuts it down. A user-supplied poll executor is never shut down. + */ + @Override + public void close() { + publisher.close(); + for (SubscriptionDriver driver : liveSubscriptions.toArray(new SubscriptionDriver[0])) { + driver.close(); + } + ScheduledExecutorService owned; + synchronized (this) { + owned = ownedPollExecutor; + } + if (owned != null) { + owned.shutdown(); + try { + if (!owned.awaitTermination(1, TimeUnit.SECONDS)) { + owned.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + owned.shutdownNow(); + } + } + } + + void publishToTopic(String topic, Object value, boolean forceFlush) { + publisher.publish(topic, value, forceFlush); + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClientOptions.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClientOptions.java new file mode 100644 index 0000000000..ffd0d8e000 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamClientOptions.java @@ -0,0 +1,136 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import io.temporal.common.converter.PayloadConverter; +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.Nullable; + +/** Options for constructing a {@link WorkflowStreamClient}. */ +@Experimental +public final class WorkflowStreamClientOptions { + public static Builder newBuilder() { + return new Builder(); + } + + public static WorkflowStreamClientOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final WorkflowStreamClientOptions DEFAULT_INSTANCE = newBuilder().build(); + + private final Duration batchInterval; + private final int maxBatchSize; + private final Duration maxRetryDuration; + private final PayloadConverter[] payloadConverters; + @Nullable private final ScheduledExecutorService pollExecutor; + + private WorkflowStreamClientOptions( + Duration batchInterval, + int maxBatchSize, + Duration maxRetryDuration, + PayloadConverter[] payloadConverters, + @Nullable ScheduledExecutorService pollExecutor) { + this.batchInterval = batchInterval; + this.maxBatchSize = maxBatchSize; + this.maxRetryDuration = maxRetryDuration; + this.payloadConverters = payloadConverters.clone(); + this.pollExecutor = pollExecutor; + } + + public Duration getBatchInterval() { + return batchInterval; + } + + public int getMaxBatchSize() { + return maxBatchSize; + } + + public Duration getMaxRetryDuration() { + return maxRetryDuration; + } + + public PayloadConverter[] getPayloadConverters() { + return payloadConverters.clone(); + } + + @Nullable + public ScheduledExecutorService getPollExecutor() { + return pollExecutor; + } + + public static final class Builder { + private Duration batchInterval = WorkflowStreamConstants.DEFAULT_BATCH_INTERVAL; + private int maxBatchSize; + private Duration maxRetryDuration = WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION; + private PayloadConverter[] payloadConverters = new PayloadConverter[0]; + @Nullable private ScheduledExecutorService pollExecutor; + + private Builder() {} + + /** Interval between automatic flushes. Default: 2 seconds. */ + public Builder setBatchInterval(Duration batchInterval) { + this.batchInterval = batchInterval; + return this; + } + + /** + * Triggers a flush once the buffer reaches this many items. Zero (the default) disables + * size-based flushing. + */ + public Builder setMaxBatchSize(int maxBatchSize) { + this.maxBatchSize = maxBatchSize; + return this; + } + + /** + * Maximum time to retry a failed flush before surfacing a {@link FlushTimeoutException}. Must + * be less than the workflow's publisher TTL (default 15 minutes) to preserve exactly-once + * delivery. Default: 10 minutes. + */ + public Builder setMaxRetryDuration(Duration maxRetryDuration) { + this.maxRetryDuration = maxRetryDuration; + return this; + } + + /** + * Customizes how published values are serialized into the per-item Payloads carried inside each + * batch. They are combined into a {@link io.temporal.common.converter.DefaultDataConverter} in + * the order given, so the last one should be a catch-all such as a JSON converter. + * + *

Only payload conversion happens here — never a payload codec (encryption, compression). + * The codec chain configured on the Temporal client runs once on the signal/update envelope + * that carries each batch, so encoding items here too would double-encode them; the {@code + * PayloadConverter[]} type makes that mistake impossible. To decode subscribed items, use a + * converter built from the same payload converters. + * + *

Default: the standard converter set. + */ + public Builder setPayloadConverters(PayloadConverter... payloadConverters) { + this.payloadConverters = payloadConverters; + return this; + } + + /** + * Executor that drives the client's subscriptions: it runs the short update-admission and + * item-delivery steps and schedules poll cooldowns, but is never occupied while a poll is + * blocked on the server, so a small pool serves many subscriptions. The caller owns its + * lifecycle; it is shared across all subscriptions of this client and must have at least one + * thread. Listener callbacks run on it. + * + *

Default: an executor with {@code 2} daemon threads, created lazily and owned by the client + * (shut down by {@link WorkflowStreamClient#close}). The known worst case for pool pressure is + * a backlogged workflow pinning a thread in the update-admission call; supply a bigger pool + * when running many subscriptions against slow workflows. + */ + public Builder setPollExecutor(ScheduledExecutorService pollExecutor) { + this.pollExecutor = pollExecutor; + return this; + } + + public WorkflowStreamClientOptions build() { + return new WorkflowStreamClientOptions( + batchInterval, maxBatchSize, maxRetryDuration, payloadConverters, pollExecutor); + } + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamConstants.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamConstants.java new file mode 100644 index 0000000000..ae18cd4a49 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamConstants.java @@ -0,0 +1,60 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import java.time.Duration; + +/** + * Fixed handler names and error types of the workflow streams wire protocol. These are part of the + * cross-language contract and match the Go, Python, and TypeScript packages exactly. The Java SDK + * normally reserves the {@code __temporal_} prefix, but explicitly permits the {@code + * __temporal_workflow_stream_} sub-namespace for this package. + */ +@Experimental +public final class WorkflowStreamConstants { + /** Signal external publishers send to append a batch of items to the stream. */ + public static final String PUBLISH_SIGNAL_NAME = "__temporal_workflow_stream_publish"; + + /** Update subscribers send to long-poll for new items. */ + public static final String POLL_UPDATE_NAME = "__temporal_workflow_stream_poll"; + + /** Query that returns the current global offset. */ + public static final String OFFSET_QUERY_NAME = "__temporal_workflow_stream_offset"; + + /** + * ApplicationFailure type returned by the poll update when the requested offset has already been + * truncated. + */ + public static final String ERROR_TYPE_TRUNCATED_OFFSET = "TruncatedOffset"; + + /** + * ApplicationFailure type thrown by {@link WorkflowStream#truncate} when the requested offset is + * past the end of the log. + */ + public static final String ERROR_TYPE_TRUNCATE_OUT_OF_RANGE = "TruncateOutOfRange"; + + /** + * ApplicationFailure type the poll update's validator returns while the stream is detaching for + * continue-as-new. It tells a subscriber the rollover is in progress so it retries (rather than + * surfacing an error) until the poll lands on the successor run. + */ + public static final String ERROR_TYPE_STREAM_DRAINING = "StreamDraining"; + + /** + * Caps the estimated wire size of a single poll response. Responses that would exceed this are + * truncated and signal {@code more_ready} so the subscriber pages through the remainder. + */ + static final int MAX_POLL_RESPONSE_BYTES = 1_000_000; + + // Default option values, matching the Go, Python, and TypeScript packages. + static final Duration DEFAULT_BATCH_INTERVAL = Duration.ofSeconds(2); + static final Duration DEFAULT_POLL_COOLDOWN = Duration.ofMillis(100); + static final Duration DEFAULT_PUBLISHER_TTL = Duration.ofMinutes(15); + static final Duration DEFAULT_MAX_RETRY_DURATION = Duration.ofMinutes(10); + + // Size of the default client-owned poll executor. Subscriptions hold a thread only during + // the short update-admission and delivery steps — never during the long poll — so a small + // pool serves many subscriptions. + static final int DEFAULT_POLL_EXECUTOR_THREADS = 2; + + private WorkflowStreamConstants() {} +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamHandlers.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamHandlers.java new file mode 100644 index 0000000000..ca09bb082c --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamHandlers.java @@ -0,0 +1,30 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.UpdateValidatorMethod; + +/** + * The signal, update, and query handlers a {@link WorkflowStream} registers on its workflow via + * {@link io.temporal.workflow.Workflow#registerListener(Object)}. The handler names are part of the + * cross-language wire protocol. + * + *

This interface is public only because handler methods are invoked reflectively; user code + * should not implement it. Construct a {@link WorkflowStream} instead. + */ +@Experimental +public interface WorkflowStreamHandlers { + @SignalMethod(name = WorkflowStreamConstants.PUBLISH_SIGNAL_NAME) + void publish(PublishInput input); + + @UpdateMethod(name = WorkflowStreamConstants.POLL_UPDATE_NAME) + PollResult poll(PollInput input); + + @UpdateValidatorMethod(updateName = WorkflowStreamConstants.POLL_UPDATE_NAME) + void validatePoll(PollInput input); + + @QueryMethod(name = WorkflowStreamConstants.OFFSET_QUERY_NAME) + long offset(); +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamItem.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamItem.java new file mode 100644 index 0000000000..cf611fe641 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamItem.java @@ -0,0 +1,35 @@ +package io.temporal.workflowstreams; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; + +/** + * A single decoded item yielded by a subscription. {@code payload} is the raw {@link Payload}; + * decode it at the call site with a payload converter, e.g. {@code + * DefaultDataConverter.STANDARD_INSTANCE.fromPayload(item.getPayload(), String.class, + * String.class)}. + */ +@Experimental +public final class WorkflowStreamItem { + private final String topic; + private final Payload payload; + private final long offset; + + public WorkflowStreamItem(String topic, Payload payload, long offset) { + this.topic = topic; + this.payload = payload; + this.offset = offset; + } + + public String getTopic() { + return topic; + } + + public Payload getPayload() { + return payload; + } + + public long getOffset() { + return offset; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamListener.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamListener.java new file mode 100644 index 0000000000..9326c6f456 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamListener.java @@ -0,0 +1,42 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import java.util.concurrent.CompletionStage; +import org.slf4j.LoggerFactory; + +/** + * Receives items from a workflow stream subscription without occupying a caller thread. Pass one to + * {@link WorkflowStreamClient#subscribe(SubscribeOptions, WorkflowStreamListener)}; the returned + * {@link WorkflowStreamSubscriptionHandle} stops the subscription. + * + *

Callbacks are serialized (never invoked concurrently, with happens-before ordering between + * invocations) and run on the client's poll executor, so they must not block; to defer further + * delivery, return a pending stage from {@link #onNext}. + */ +@Experimental +public interface WorkflowStreamListener { + /** + * Called with the next item on the stream. Return {@code null} or an already-completed stage to + * receive the next item immediately; return a pending stage to defer both further delivery and + * the next poll until it completes (backpressure). A stage that completes exceptionally — or an + * exception thrown directly — stops the subscription and is reported to {@link #onError}. + */ + CompletionStage onNext(WorkflowStreamItem item); + + /** + * Called once when the subscription stops because of an unrecoverable failure (including a + * failure from {@link #onNext}). No further callbacks follow. The default implementation logs the + * failure at warn level. + */ + default void onError(Throwable failure) { + LoggerFactory.getLogger(WorkflowStreamListener.class) + .warn("workflowstreams: subscription failed", failure); + } + + /** + * Called once when the stream ends cleanly because the workflow reached a terminal state. Not + * called when the subscription is stopped via {@link WorkflowStreamSubscriptionHandle#close}. No + * further callbacks follow. + */ + default void onCompleted() {} +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamOptions.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamOptions.java new file mode 100644 index 0000000000..6c151537ec --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamOptions.java @@ -0,0 +1,59 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import io.temporal.common.converter.PayloadConverter; + +/** Options for constructing a {@link WorkflowStream}. */ +@Experimental +public final class WorkflowStreamOptions { + public static Builder newBuilder() { + return new Builder(); + } + + public static WorkflowStreamOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final WorkflowStreamOptions DEFAULT_INSTANCE = newBuilder().build(); + + private final PayloadConverter[] payloadConverters; + + private WorkflowStreamOptions(PayloadConverter[] payloadConverters) { + this.payloadConverters = payloadConverters.clone(); + } + + public PayloadConverter[] getPayloadConverters() { + return payloadConverters.clone(); + } + + public static final class Builder { + private PayloadConverter[] payloadConverters = new PayloadConverter[0]; + + private Builder() {} + + /** + * Customizes how values published from workflow code (via {@link WorkflowTopicHandle#publish}) + * are serialized into per-item Payloads. They are combined into a {@link + * io.temporal.common.converter.DefaultDataConverter} in the order given, so the last one should + * be a catch-all such as a JSON converter. + * + *

As on the client side, only payload conversion happens here — never a payload codec. The + * worker's codec chain runs once on the poll-update response that carries each batch to + * subscribers, so encoding items here too would double-encode them; the {@code + * PayloadConverter[]} type makes that impossible. + * + *

There is no public accessor for the worker's configured data converter inside workflow + * code, so it cannot be picked up automatically; pass the matching payload converters here to + * keep workflow-side publishes consistent with the rest of your workflow. Default: the standard + * converter set. + */ + public Builder setPayloadConverters(PayloadConverter... payloadConverters) { + this.payloadConverters = payloadConverters; + return this; + } + + public WorkflowStreamOptions build() { + return new WorkflowStreamOptions(payloadConverters); + } + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamState.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamState.java new file mode 100644 index 0000000000..da8039454e --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamState.java @@ -0,0 +1,34 @@ +package io.temporal.workflowstreams; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.common.Experimental; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A serializable snapshot of stream state for continue-as-new. Thread a {@code WorkflowStreamState} + * field through your workflow input and pass it to {@link + * WorkflowStream#newInstance(WorkflowStreamState)}. + * + *

Field names are part of the cross-language wire protocol; this type must serialize to JSON + * with exactly these names. + */ +@Experimental +public final class WorkflowStreamState { + @JsonProperty("log") + public List log = new ArrayList<>(); + + @JsonProperty("base_offset") + public long baseOffset; + + @JsonProperty("publisher_sequences") + public Map publisherSequences = new HashMap<>(); + + /** Unix seconds of the last batch accepted from each publisher. */ + @JsonProperty("publisher_last_seen") + public Map publisherLastSeen = new HashMap<>(); + + public WorkflowStreamState() {} +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscription.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscription.java new file mode 100644 index 0000000000..b3a51ac70f --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscription.java @@ -0,0 +1,169 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import io.temporal.workflowstreams.internal.SubscriptionDriver; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +/** + * A blocking, single-use subscription over a workflow stream. Polling runs on the owning client's + * poll executor (shared with the client's other subscriptions); the consuming thread only blocks + * waiting for the next item. The subscription ends cleanly ({@code hasNext() == false}) when the + * workflow reaches a terminal state, and automatically follows continue-as-new chains; closing the + * owning {@link WorkflowStreamClient} also ends it. + * + *

{@link #close} stops the subscription before the next poll; a poll already blocked on the + * server is not interrupted. + */ +@Experimental +public final class WorkflowStreamSubscription + implements Iterator, Iterable, AutoCloseable { + private final SubscriptionDriver driver; + + private final Object lock = new Object(); + + // Hand-off state, guarded by lock. The driver's pending-stage backpressure keeps the buffer + // at no more than one item: each onNext parks the driver on a gate that next() releases when + // the consumer takes the item, so the next long poll only fires once the consumer drains what + // the driver already fetched — the same pacing as driving the poll loop on the consumer thread. + private final Deque buffer = new ArrayDeque<>(); + private CompletableFuture pendingGate; + private Throwable error; + private boolean streamDone; + + // Consumer-thread state. + private boolean started; + private boolean errorThrown; + + WorkflowStreamSubscription(Function driverFactory) { + this.driver = driverFactory.apply(new AdapterListener()); + // One hook covers every way the stream ends: terminal state, failure, close(), and the + // owning client closing. It wakes a consumer blocked in hasNext(). + driver + .getDoneFuture() + .whenComplete( + (ignored, failure) -> { + synchronized (lock) { + if (failure != null) { + error = unwrap(failure); + } + streamDone = true; + lock.notifyAll(); + } + }); + } + + /** + * Returns this subscription; it is single-use, so iterate it at most once (typically with a + * for-each loop). + */ + @Override + public Iterator iterator() { + return this; + } + + /** + * Returns whether another item is available, blocking until one is (or the stream ends). The + * first call starts the polling; an unrecoverable poll failure is rethrown here (once — the + * subscription is over afterwards). + */ + @Override + public boolean hasNext() { + if (!started) { + started = true; + driver.start(); + } + synchronized (lock) { + while (buffer.isEmpty() && !streamDone) { + try { + lock.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + driver.close(); + return !buffer.isEmpty(); + } + } + if (!buffer.isEmpty()) { + return true; + } + if (error != null && !errorThrown) { + errorThrown = true; + if (error instanceof RuntimeException) { + throw (RuntimeException) error; + } + throw new RuntimeException(error); + } + return false; + } + } + + @Override + public WorkflowStreamItem next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + WorkflowStreamItem item; + CompletableFuture gate = null; + synchronized (lock) { + item = buffer.poll(); + if (buffer.isEmpty() && pendingGate != null) { + gate = pendingGate; + pendingGate = null; + } + } + // Completed outside the lock: it resumes the driver (via an executor hop), which may call + // onNext and take the lock again. + if (gate != null) { + gate.complete(null); + } + return item; + } + + /** Stops the subscription before the next poll. Items already fetched still drain. */ + @Override + public void close() { + CompletableFuture gate; + synchronized (lock) { + gate = pendingGate; + pendingGate = null; + } + driver.close(); + if (gate != null) { + gate.complete(null); + } + } + + private class AdapterListener implements WorkflowStreamListener { + @Override + public CompletionStage onNext(WorkflowStreamItem item) { + CompletableFuture gate = new CompletableFuture<>(); + synchronized (lock) { + buffer.add(item); + pendingGate = gate; + lock.notifyAll(); + } + return gate; + } + + // The done-future hook records the failure for hasNext() to rethrow; the default warn + // log would just duplicate it. + @Override + public void onError(Throwable failure) {} + + @Override + public void onCompleted() {} + } + + private static Throwable unwrap(Throwable e) { + while (e instanceof CompletionException && e.getCause() != null) { + e = e.getCause(); + } + return e; + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscriptionHandle.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscriptionHandle.java new file mode 100644 index 0000000000..114a7903cc --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowStreamSubscriptionHandle.java @@ -0,0 +1,26 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; +import java.util.concurrent.CompletableFuture; + +/** + * Controls a listener-based subscription started with {@link + * WorkflowStreamClient#subscribe(SubscribeOptions, WorkflowStreamListener)}. + */ +@Experimental +public interface WorkflowStreamSubscriptionHandle extends AutoCloseable { + /** + * Stops the subscription before the next poll; a poll already blocked on the server is not + * interrupted, and its result is discarded. Idempotent. Does not trigger {@link + * WorkflowStreamListener#onCompleted}. + */ + @Override + void close(); + + /** + * Returns a future that tracks the end of the subscription: it completes normally when the stream + * ends cleanly (after {@link WorkflowStreamListener#onCompleted}) or the subscription is closed, + * and completes exceptionally with the failure passed to {@link WorkflowStreamListener#onError}. + */ + CompletableFuture getDoneFuture(); +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowTopicHandle.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowTopicHandle.java new file mode 100644 index 0000000000..b447b6ce33 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/WorkflowTopicHandle.java @@ -0,0 +1,29 @@ +package io.temporal.workflowstreams; + +import io.temporal.common.Experimental; + +/** Publishes to a single topic from workflow code. Obtained via {@link WorkflowStream#topic}. */ +@Experimental +public final class WorkflowTopicHandle { + private final String name; + private final WorkflowStream stream; + + WorkflowTopicHandle(String name, WorkflowStream stream) { + this.name = name; + this.stream = stream; + } + + /** Returns the topic name. */ + public String getName() { + return name; + } + + /** + * Appends {@code value} to the stream on this topic. {@code value} is serialized by the stream's + * payload converters (see {@link WorkflowStreamOptions.Builder#setPayloadConverters}), defaulting + * to the standard set; a pre-built {@link io.temporal.api.common.v1.Payload} bypasses conversion. + */ + public void publish(Object value) { + stream.publishToTopic(name, value); + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/PayloadWire.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/PayloadWire.java new file mode 100644 index 0000000000..ae6202c136 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/PayloadWire.java @@ -0,0 +1,40 @@ +package io.temporal.workflowstreams.internal; + +import com.google.protobuf.InvalidProtocolBufferException; +import io.temporal.api.common.v1.Payload; +import java.util.Base64; + +/** + * Encodes and decodes the base64-of-proto per-item wire format shared across the Go, Python, and + * TypeScript workflow streams packages. Internal to the workflow streams module. + */ +public final class PayloadWire { + /** Encodes a Payload to the base64-of-proto wire format. */ + public static String encode(Payload payload) { + return Base64.getEncoder().encodeToString(payload.toByteArray()); + } + + /** + * Decodes the base64-of-proto wire format back to a Payload. + * + * @throws IllegalArgumentException if the input is not valid base64 or not a valid Payload + */ + public static Payload decode(String wire) { + byte[] bytes = Base64.getDecoder().decode(wire); + try { + return Payload.parseFrom(bytes); + } catch (InvalidProtocolBufferException e) { + throw new IllegalArgumentException("workflowstreams: unmarshal payload", e); + } + } + + /** + * Estimates the contribution of a single encoded item to a poll response. {@code encoded} is + * already base64 (its on-wire representation). + */ + public static int wireSize(String encoded, String topic) { + return encoded.length() + topic.length(); + } + + private PayloadWire() {} +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/StreamPublisher.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/StreamPublisher.java new file mode 100644 index 0000000000..f07e7095ad --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/StreamPublisher.java @@ -0,0 +1,271 @@ +package io.temporal.workflowstreams.internal; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.DataConverter; +import io.temporal.workflowstreams.FlushTimeoutException; +import io.temporal.workflowstreams.PublishEntry; +import io.temporal.workflowstreams.PublishInput; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Owns the client-side publish path: it buffers published values, batches them, and sends each + * batch to the workflow via the injected signal function. It assigns the per-publisher dedup key (a + * stable publisher ID plus a monotonic sequence advanced only on a confirmed send) so the workflow + * can drop duplicates, and it retries a failed batch until the max retry duration elapses. + * + *

The signal function is injected (rather than holding a client) so the publish path can be + * exercised in isolation. Internal to the workflow streams module. + */ +public final class StreamPublisher { + + /** Sends a publish signal to the target workflow. Throws on delivery failure. */ + @FunctionalInterface + public interface SignalFunction { + void send(PublishInput input); + } + + private final SignalFunction signal; + private final DataConverter dataConverter; + private final String publisherId; + private final long batchIntervalMs; + private final int maxBatchSize; + private final long maxRetryDurationMs; + + private final Object stateLock = new Object(); + private List buffer = new ArrayList<>(); + private List pending; + private long pendingSeq; + private long sequence; + private long pendingStartNanos; + private boolean started; + private boolean closed; + private FlushTimeoutException deferredError; + private ScheduledExecutorService scheduler; + + /** Serializes doFlush so concurrent callers send sequentially. */ + private final Object flushLock = new Object(); + + public StreamPublisher( + SignalFunction signal, + DataConverter dataConverter, + Duration batchInterval, + int maxBatchSize, + Duration maxRetryDuration) { + this.signal = signal; + this.dataConverter = dataConverter; + this.publisherId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + this.batchIntervalMs = batchInterval.toMillis(); + this.maxBatchSize = maxBatchSize; + this.maxRetryDurationMs = maxRetryDuration.toMillis(); + } + + /** + * Converts and buffers a value, lazily starting the background flush loop. Triggers an immediate + * flush on {@code forceFlush} or once the buffer reaches the max batch size. + * + *

Conversion happens here, on the caller's thread, so an unconvertible value fails the {@code + * publish} call itself instead of poisoning the buffer and silently wedging every later item + * behind it in the background flush loop. + * + * @throws RuntimeException if no configured payload converter accepts {@code value} + */ + public void publish(String topic, Object value, boolean forceFlush) { + PublishEntry entry = encode(topic, value); + boolean trigger; + ScheduledExecutorService toTrigger = null; + synchronized (stateLock) { + buffer.add(entry); + trigger = forceFlush || (maxBatchSize > 0 && buffer.size() >= maxBatchSize); + if (!closed) { + ensureStartedLocked(); + toTrigger = scheduler; + } + } + if (trigger && toTrigger != null) { + toTrigger.execute(this::backgroundFlush); + } + } + + private void ensureStartedLocked() { + if (started || closed) { + return; + } + started = true; + scheduler = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread t = new Thread(r, "temporal-workflow-stream-publisher"); + t.setDaemon(true); + return t; + }); + scheduler.scheduleWithFixedDelay( + this::backgroundFlush, batchIntervalMs, batchIntervalMs, TimeUnit.MILLISECONDS); + } + + private void backgroundFlush() { + try { + doFlush(); + } catch (FlushTimeoutException e) { + // The pending batch was dropped and can't be recovered. Stash the error so + // flush/close surface it and stop the loop. + ScheduledExecutorService toStop; + synchronized (stateLock) { + deferredError = e; + toStop = scheduler; + } + if (toStop != null) { + toStop.shutdown(); + } + } catch (RuntimeException e) { + // Transient failure: pending stays set for retry on the next tick. + } + } + + /** + * Sends the pending batch (retry) or the buffered batch (new batch). Serialized so concurrent + * callers send sequentially. + */ + private void doFlush() { + synchronized (flushLock) { + List batch; + long seq; + + synchronized (stateLock) { + if (pending != null) { + if (System.nanoTime() - pendingStartNanos + > TimeUnit.MILLISECONDS.toNanos(maxRetryDurationMs)) { + // Advance the confirmed sequence so the next batch gets a fresh sequence + // number. Without this the next batch reuses pendingSeq, which the + // workflow may have already accepted — causing silent dedup (data loss). + sequence = pendingSeq; + pending = null; + pendingSeq = 0; + pendingStartNanos = 0; + throw new FlushTimeoutException( + String.format( + "workflowstreams: flush retry exceeded the max retry duration (%dms); pending" + + " batch dropped", + maxRetryDurationMs)); + } + batch = pending; + seq = pendingSeq; + } else if (!buffer.isEmpty()) { + batch = buffer; + buffer = new ArrayList<>(); + seq = sequence + 1; + pending = batch; + pendingSeq = seq; + pendingStartNanos = System.nanoTime(); + } else { + return; + } + } + + // On failure the signal throws and pending stays set for retry. + signal.send(new PublishInput(batch, publisherId, seq)); + + synchronized (stateLock) { + sequence = seq; + pending = null; + pendingSeq = 0; + pendingStartNanos = 0; + } + } + } + + private PublishEntry encode(String topic, Object value) { + Payload payload; + if (value instanceof Payload) { + payload = (Payload) value; + } else { + payload = + dataConverter + .toPayload(value) + .orElseThrow( + () -> + new IllegalArgumentException( + "workflowstreams: no payload converter accepted the published value")); + } + return new PublishEntry(topic, PayloadWire.encode(payload)); + } + + /** + * Sends buffered (and pending) items and waits for confirmation. Returns once the items buffered + * at call time have been signaled and acknowledged. + * + * @throws FlushTimeoutException if a pending batch cannot be sent within the max retry duration + */ + public void flush() { + throwDeferred(); + + long targetSeq; + synchronized (stateLock) { + if (pending == null && buffer.isEmpty()) { + return; + } + long baseSeq = pending != null ? pendingSeq : sequence; + targetSeq = buffer.isEmpty() ? baseSeq : baseSeq + 1; + } + + while (true) { + synchronized (stateLock) { + if (sequence >= targetSeq) { + break; + } + } + doFlush(); + } + throwDeferred(); + } + + /** + * Stops the background flush loop and drains any remaining items, surfacing a deferred {@link + * FlushTimeoutException} from a prior background failure. + */ + public void close() { + ScheduledExecutorService toStop; + synchronized (stateLock) { + if (closed) { + return; + } + closed = true; + toStop = scheduler; + } + + if (toStop != null) { + toStop.shutdownNow(); + try { + toStop.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + // Final drain: a single doFlush processes either pending OR the buffer. + while (true) { + synchronized (stateLock) { + if (pending == null && buffer.isEmpty()) { + break; + } + } + doFlush(); + } + throwDeferred(); + } + + private void throwDeferred() { + synchronized (stateLock) { + if (deferredError != null) { + FlushTimeoutException e = deferredError; + deferredError = null; + throw e; + } + } + } +} diff --git a/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/SubscriptionDriver.java b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/SubscriptionDriver.java new file mode 100644 index 0000000000..fba824574f --- /dev/null +++ b/contrib/temporal-workflowstreams/src/main/java/io/temporal/workflowstreams/internal/SubscriptionDriver.java @@ -0,0 +1,380 @@ +package io.temporal.workflowstreams.internal; + +import io.temporal.api.enums.v1.WorkflowExecutionStatus; +import io.temporal.client.UpdateOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowTargetOptions; +import io.temporal.client.WorkflowUpdateHandle; +import io.temporal.client.WorkflowUpdateStage; +import io.temporal.failure.ApplicationFailure; +import io.temporal.workflowstreams.PollInput; +import io.temporal.workflowstreams.PollResult; +import io.temporal.workflowstreams.SubscribeOptions; +import io.temporal.workflowstreams.WireItem; +import io.temporal.workflowstreams.WorkflowStreamConstants; +import io.temporal.workflowstreams.WorkflowStreamItem; +import io.temporal.workflowstreams.WorkflowStreamListener; +import io.temporal.workflowstreams.WorkflowStreamSubscriptionHandle; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The shared long-poll engine behind both subscription APIs. It runs the blocking {@code + * startUpdate(..., ACCEPTED)} admission call on a shared executor, then waits for the long-poll + * outcome with {@code getResultAsync()}, so no thread is occupied while a poll is blocked on the + * server — many subscriptions can share a small pool. + * + *

The engine is a chained state machine with at most one outstanding step at a time: every + * listener callback runs on the executor, and the next step is only submitted by the current one + * (or by the completion of a future it produced). That serializes callbacks with happens-before + * ordering, so the mutable poll state needs no locks. + * + *

This class is public only for internal wiring; construct subscriptions through {@link + * io.temporal.workflowstreams.WorkflowStreamClient} instead. + */ +public final class SubscriptionDriver implements WorkflowStreamSubscriptionHandle { + private static final Logger log = LoggerFactory.getLogger(SubscriptionDriver.class); + + private final WorkflowClient client; + private final String workflowId; + private final WorkflowStub latestRunStub; + private final List topics; + private final long pollCooldownMs; + private final ScheduledExecutorService executor; + private final WorkflowStreamListener listener; + private final Consumer onFinish; + private final CompletableFuture doneFuture = new CompletableFuture<>(); + + // Mutable poll state, owned by the single outstanding step (see class javadoc). + private long offset; + // The run the most recent poll's update was admitted to. Captured before waiting for the + // update's outcome so that, if that run continues-as-new mid-poll (failing the outcome), we + // still know which run to inspect to tell a rollover apart from a terminal end. + private String polledRunId = ""; + + private volatile boolean closed; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean finished = new AtomicBoolean(); + + public SubscriptionDriver( + WorkflowClient client, + String workflowId, + SubscribeOptions options, + ScheduledExecutorService executor, + WorkflowStreamListener listener, + Consumer onFinish) { + this.client = client; + this.workflowId = workflowId; + this.latestRunStub = client.newUntypedWorkflowStub(workflowId); + this.topics = options.getTopics(); + this.offset = options.getFromOffset(); + this.pollCooldownMs = options.getPollCooldown().toMillis(); + this.executor = executor; + this.listener = listener; + this.onFinish = onFinish; + } + + /** Submits the first poll. Idempotent. */ + public void start() { + if (started.compareAndSet(false, true)) { + hop(this::poll); + } + } + + /** + * Stops the subscription before the next poll; a poll already blocked on the server is not + * interrupted, and its result is discarded. Completes the done future normally without calling + * {@code onCompleted}. Idempotent. + */ + @Override + public void close() { + closed = true; + finishSilent(); + } + + @Override + public CompletableFuture getDoneFuture() { + return doneFuture; + } + + private void poll() { + if (closed) { + finishSilent(); + return; + } + WorkflowUpdateHandle handle; + try { + // Wait only for ACCEPTED so startUpdate returns the handle (and its run id) as soon + // as the update is admitted; getResultAsync then waits for the outcome. With a + // COMPLETED wait stage a mid-poll continue-as-new would fail startUpdate without a + // handle, losing the run id. + UpdateOptions updateOptions = + UpdateOptions.newBuilder(PollResult.class) + .setUpdateName(WorkflowStreamConstants.POLL_UPDATE_NAME) + .setWaitForStage(WorkflowUpdateStage.ACCEPTED) + .build(); + handle = latestRunStub.startUpdate(updateOptions, new PollInput(topics, offset)); + polledRunId = handle.getExecution().getRunId(); + } catch (RuntimeException e) { + handleError(e); + return; + } + // No thread is held while the long poll is in flight; the future completes on a gRPC or + // common-pool thread, so hop back to the executor before touching state or the listener. + handle + .getResultAsync() + .whenComplete( + (result, failure) -> { + if (failure == null) { + hop(() -> deliver(result)); + } else { + Throwable unwrapped = unwrap(failure); + hop(() -> handleError(unwrapped)); + } + }); + } + + private void deliver(PollResult result) { + if (closed) { + finishSilent(); + return; + } + List items = new ArrayList<>(result.items.size()); + for (WireItem item : result.items) { + items.add(new WorkflowStreamItem(item.topic, PayloadWire.decode(item.data), item.offset)); + } + offset = result.nextOffset; + deliverFrom(items, 0, result.moreReady); + } + + private void deliverFrom(List items, int start, boolean moreReady) { + // Iterate (rather than recurse) over items whose stages complete immediately, so a large + // batch of synchronous onNext calls cannot grow the stack. + for (int i = start; ; i++) { + if (closed) { + finishSilent(); + return; + } + if (i == items.size()) { + if (moreReady) { + hop(this::poll); + } else { + schedule(this::poll, pollCooldownMs); + } + return; + } + CompletionStage stage; + try { + stage = listener.onNext(items.get(i)); + } catch (RuntimeException e) { + finishError(e); + return; + } + if (stage == null) { + continue; + } + CompletableFuture future = null; + try { + future = stage.toCompletableFuture(); + } catch (UnsupportedOperationException ignored) { + // Fall through to the generic whenComplete path. + } + if (future != null && future.isDone() && !future.isCompletedExceptionally()) { + continue; + } + int next = i + 1; + stage.whenComplete( + (v, failure) -> { + if (failure == null) { + hop(() -> deliverFrom(items, next, moreReady)); + } else { + Throwable unwrapped = unwrap(failure); + hop(() -> finishError(unwrapped)); + } + }); + return; + } + } + + private void handleError(Throwable e) { + if (closed) { + finishSilent(); + return; + } + ApplicationFailure failure = findApplicationFailure(e); + if (failure != null) { + if (WorkflowStreamConstants.ERROR_TYPE_TRUNCATED_OFFSET.equals(failure.getType())) { + // Fell behind truncation; restart from the beginning of whatever still exists. + offset = 0; + hop(this::poll); + return; + } + if (WorkflowStreamConstants.ERROR_TYPE_STREAM_DRAINING.equals(failure.getType())) { + // The workflow is detaching for continue-as-new. Back off and retry; the poll + // lands on the successor run once the rollover completes (or the chain/terminal + // checks below fire on a genuine end). + schedule(this::poll, pollCooldownMs); + return; + } + } + // The workflow may have continued-as-new or completed between polls. Follow the chain, + // exit cleanly on a terminal state, otherwise surface the error. describe() is a blocking + // call, which is why error handling always runs on the executor. + WorkflowExecutionStatus status = describePolledRun(); + if (status == WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW) { + // Subsequent polls use the latest-run stub, addressing the successor automatically. + hop(this::poll); + return; + } + if (isTerminal(status)) { + finishCompleted(); + return; + } + finishError(e); + } + + /** + * Describes the run the most recent poll was admitted to: a rolled-over run is closed with status + * CONTINUED_AS_NEW, whereas the latest run would report RUNNING, so describing by run id is what + * makes the rollover check fire. The successor run id is not needed — subsequent polls address + * the latest run automatically. A blank run id (no poll has been admitted yet) falls back to + * describing the latest run. + */ + private WorkflowExecutionStatus describePolledRun() { + try { + WorkflowStub stub; + if (polledRunId == null || polledRunId.isEmpty()) { + stub = latestRunStub; + } else { + stub = + client.newUntypedWorkflowStub( + WorkflowTargetOptions.newBuilder() + .setWorkflowId(workflowId) + .setRunId(polledRunId) + .build()); + } + return stub.describe().getStatus(); + } catch (RuntimeException e) { + return WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_UNSPECIFIED; + } + } + + private void finishSilent() { + if (!finished.compareAndSet(false, true)) { + return; + } + doneFuture.complete(null); + onFinish.accept(this); + } + + private void finishCompleted() { + if (!finished.compareAndSet(false, true)) { + return; + } + try { + listener.onCompleted(); + } catch (Throwable t) { + log.error("workflowstreams: listener onCompleted threw", t); + } + doneFuture.complete(null); + onFinish.accept(this); + } + + private void finishError(Throwable e) { + if (!finished.compareAndSet(false, true)) { + return; + } + try { + listener.onError(e); + } catch (Throwable t) { + log.error("workflowstreams: listener onError threw", t); + e.addSuppressed(t); + } + doneFuture.completeExceptionally(e); + onFinish.accept(this); + } + + /** + * Submits the next step, never letting an unexpected step failure escape onto (and be swallowed + * by) a pool thread. + */ + private void hop(Runnable step) { + try { + executor.execute(() -> runStep(step)); + } catch (RejectedExecutionException e) { + handleRejection(e); + } + } + + private void schedule(Runnable step, long delayMs) { + try { + executor.schedule(() -> runStep(step), delayMs, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException e) { + handleRejection(e); + } + } + + private void runStep(Runnable step) { + try { + step.run(); + } catch (Throwable t) { + finishError(t); + } + } + + /** + * A rejection while closed is the normal path during client shutdown; otherwise the caller shut a + * user-supplied executor down early, which ends the subscription. Running the callback inline + * preserves serialization: the rejected task was the only pending step. + */ + private void handleRejection(RejectedExecutionException e) { + if (closed) { + finishSilent(); + } else { + finishError(e); + } + } + + /** Strips {@code CompletionException}/{@code ExecutionException} wrappers added by futures. */ + private static Throwable unwrap(Throwable e) { + while ((e instanceof CompletionException || e instanceof ExecutionException) + && e.getCause() != null) { + e = e.getCause(); + } + return e; + } + + private static boolean isTerminal(WorkflowExecutionStatus status) { + switch (status) { + case WORKFLOW_EXECUTION_STATUS_COMPLETED: + case WORKFLOW_EXECUTION_STATUS_FAILED: + case WORKFLOW_EXECUTION_STATUS_CANCELED: + case WORKFLOW_EXECUTION_STATUS_TERMINATED: + case WORKFLOW_EXECUTION_STATUS_TIMED_OUT: + return true; + default: + return false; + } + } + + private static ApplicationFailure findApplicationFailure(Throwable e) { + for (Throwable t = e; t != null; t = t.getCause()) { + if (t instanceof ApplicationFailure) { + return (ApplicationFailure) t; + } + } + return null; + } +} diff --git a/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/ListenerSubscribeTest.java b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/ListenerSubscribeTest.java new file mode 100644 index 0000000000..97ca6e7b23 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/ListenerSubscribeTest.java @@ -0,0 +1,408 @@ +package io.temporal.workflowstreams; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflowstreams.SubscribeTestWorkflows.SubscribeHostWorkflow; +import io.temporal.workflowstreams.SubscribeTestWorkflows.SubscribeHostWorkflowImpl; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class ListenerSubscribeTest { + private static final DataConverter DC = DefaultDataConverter.STANDARD_INSTANCE; + private static final long TIMEOUT_MS = 15_000; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder().setWorkflowTypes(SubscribeHostWorkflowImpl.class).build(); + + private static final SubscribeOptions FAST_POLL = + SubscribeOptions.newBuilder().setPollCooldown(Duration.ofMillis(50)).build(); + + private WorkflowStub startHostWorkflow() { + SubscribeHostWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(SubscribeHostWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute, null); + return testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(execution.getWorkflowId()); + } + + private WorkflowStreamClient newStreamClient(WorkflowStub stub) { + return WorkflowStreamClient.newInstance( + testWorkflowRule.getWorkflowClient(), + stub.getExecution().getWorkflowId(), + WorkflowStreamClientOptions.newBuilder().setBatchInterval(Duration.ofMillis(100)).build()); + } + + private static String decode(WorkflowStreamItem item) { + return DC.fromPayload(item.getPayload(), String.class, String.class); + } + + /** + * Records callbacks. {@code gates} feeds onNext return stages in delivery order; once drained, + * onNext returns null (proceed immediately). + */ + private static class RecordingListener implements WorkflowStreamListener { + final List items = new CopyOnWriteArrayList<>(); + final Queue> gates = new ConcurrentLinkedQueue<>(); + final CountDownLatch completed = new CountDownLatch(1); + final CountDownLatch failed = new CountDownLatch(1); + final AtomicReference error = new AtomicReference<>(); + + @Override + public CompletionStage onNext(WorkflowStreamItem item) { + items.add(item); + return gates.poll(); + } + + @Override + public void onError(Throwable failure) { + error.set(failure); + failed.countDown(); + } + + @Override + public void onCompleted() { + completed.countDown(); + } + } + + private static void awaitItems(RecordingListener listener, int count) { + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (listener.items.size() < count) { + if (System.currentTimeMillis() > deadline) { + Assert.fail("timed out waiting for " + count + " items, got " + listener.items.size()); + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + Assert.fail("interrupted"); + } + } + } + + private static void await(CountDownLatch latch, String what) throws InterruptedException { + Assert.assertTrue( + "timed out waiting for " + what, latch.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)); + } + + @Test + public void testListenerDeliversItemsAndAdvancesOffset() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + // A workflow-side publish lands on the same log. + stub.signal("publishLocal", "evt", "b"); + + RecordingListener listener = new RecordingListener(); + try (WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(FAST_POLL, listener)) { + awaitItems(listener, 2); + WorkflowStreamItem first = listener.items.get(0); + Assert.assertEquals("evt", first.getTopic()); + Assert.assertEquals("a", decode(first)); + Assert.assertEquals(0, first.getOffset()); + + WorkflowStreamItem second = listener.items.get(1); + Assert.assertEquals("b", decode(second)); + Assert.assertEquals(1, second.getOffset()); + } + + Assert.assertEquals(2, streamClient.getOffset()); + Assert.assertNull(listener.error.get()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testTopicHandleListenerSubscribeFilters() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("a").publish("1"); + streamClient.topic("b").publish("2"); + streamClient.topic("a").publish("3"); + streamClient.flush(); + + RecordingListener listener = new RecordingListener(); + try (WorkflowStreamSubscriptionHandle handle = + streamClient.topic("a").subscribe(0, listener)) { + awaitItems(listener, 2); + Assert.assertEquals("1", decode(listener.items.get(0))); + WorkflowStreamItem second = listener.items.get(1); + Assert.assertEquals("3", decode(second)); + Assert.assertEquals(2, second.getOffset()); + } + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testTerminalCallsOnCompleted() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + + RecordingListener listener = new RecordingListener(); + WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(FAST_POLL, listener); + awaitItems(listener, 1); + + // Complete the workflow, then keep polling: the subscription must end cleanly + // with onCompleted rather than surface an error. + stub.signal("finish"); + stub.getResult(Void.class); + + await(listener.completed, "onCompleted"); + Assert.assertNull(listener.error.get()); + handle.getDoneFuture().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + } + } + + @Test + public void testListenerFollowsContinueAsNew() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + + RecordingListener listener = new RecordingListener(); + try (WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(FAST_POLL, listener)) { + awaitItems(listener, 1); + Assert.assertEquals("a", decode(listener.items.get(0))); + + // Roll the workflow over to a new run. The stream state (including item "a") + // is carried across the continue-as-new boundary. + stub.signal("rollover"); + streamClient.topic("evt").publish("b", true); + streamClient.flush(); + + // The subscription retries through the rollover and picks up on the successor + // run where the prior log — and so the subscriber's offset — is preserved. + awaitItems(listener, 2); + WorkflowStreamItem second = listener.items.get(1); + Assert.assertEquals("b", decode(second)); + Assert.assertEquals(1, second.getOffset()); + Assert.assertNull(listener.error.get()); + } + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testListenerTruncationResetsOffset() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a"); + streamClient.topic("evt").publish("b"); + streamClient.topic("evt").publish("c"); + streamClient.flush(); + // Confirm the batch has been applied before truncating. + long deadline = System.currentTimeMillis() + TIMEOUT_MS; + while (streamClient.getOffset() < 3 && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + Assert.assertEquals(3, streamClient.getOffset()); + + stub.update("truncate", Void.class, 2L); + + // A subscription positioned before the new base offset restarts from the + // beginning of whatever still exists instead of failing. + SubscribeOptions options = + SubscribeOptions.newBuilder() + .setFromOffset(1) + .setPollCooldown(Duration.ofMillis(50)) + .build(); + RecordingListener listener = new RecordingListener(); + try (WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(options, listener)) { + awaitItems(listener, 1); + WorkflowStreamItem item = listener.items.get(0); + Assert.assertEquals("c", decode(item)); + Assert.assertEquals(2, item.getOffset()); + Assert.assertNull(listener.error.get()); + } + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testCloseStopsDeliveryWithoutOnCompleted() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + + RecordingListener listener = new RecordingListener(); + WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(FAST_POLL, listener); + awaitItems(listener, 1); + + handle.close(); + handle.getDoneFuture().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + + streamClient.topic("evt").publish("b", true); + streamClient.flush(); + Thread.sleep(300); + Assert.assertEquals("no items may arrive after close", 1, listener.items.size()); + Assert.assertEquals( + "user-initiated close must not call onCompleted", 1, listener.completed.getCount()); + Assert.assertNull(listener.error.get()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testPendingOnNextStageDefersDelivery() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + RecordingListener listener = new RecordingListener(); + CompletableFuture gate = new CompletableFuture<>(); + listener.gates.add(gate); + + streamClient.topic("evt").publish("a"); + streamClient.topic("evt").publish("b"); + streamClient.flush(); + + try (WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(FAST_POLL, listener)) { + awaitItems(listener, 1); + // The first item's stage is pending, so the second must not be delivered yet. + Thread.sleep(300); + Assert.assertEquals( + "delivery must pause while an onNext stage is pending", 1, listener.items.size()); + + gate.complete(null); + awaitItems(listener, 2); + Assert.assertEquals("b", decode(listener.items.get(1))); + Assert.assertNull(listener.error.get()); + } + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testOnNextThrowingStopsSubscription() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + RuntimeException boom = new RuntimeException("boom"); + RecordingListener listener = + new RecordingListener() { + @Override + public CompletionStage onNext(WorkflowStreamItem item) { + super.onNext(item); + throw boom; + } + }; + + streamClient.topic("evt").publish("a"); + streamClient.topic("evt").publish("b"); + streamClient.flush(); + + streamClient.subscribe(FAST_POLL, listener); + await(listener.failed, "onError"); + Assert.assertSame(boom, listener.error.get()); + Thread.sleep(300); + Assert.assertEquals("no items may follow a failed onNext", 1, listener.items.size()); + Assert.assertEquals(1, listener.completed.getCount()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testFailedOnNextStageStopsSubscription() throws Exception { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + RuntimeException boom = new RuntimeException("stage failed"); + RecordingListener listener = new RecordingListener(); + CompletableFuture gate = new CompletableFuture<>(); + listener.gates.add(gate); + + streamClient.topic("evt").publish("a"); + streamClient.topic("evt").publish("b"); + streamClient.flush(); + + WorkflowStreamSubscriptionHandle handle = streamClient.subscribe(FAST_POLL, listener); + awaitItems(listener, 1); + gate.completeExceptionally(boom); + + await(listener.failed, "onError"); + Assert.assertSame(boom, listener.error.get()); + try { + handle.getDoneFuture().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + Assert.fail("done future must complete exceptionally"); + } catch (ExecutionException e) { + Assert.assertSame(boom, e.getCause()); + } + Thread.sleep(300); + Assert.assertEquals("no items may follow a failed stage", 1, listener.items.size()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testManySubscriptionsShareSmallPool() throws Exception { + WorkflowStub stub = startHostWorkflow(); + // The default pool has 2 threads; 6 concurrent subscriptions only make progress if no + // thread is held while a poll is blocked on the server. + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + List listeners = new ArrayList<>(); + for (int i = 0; i < 6; i++) { + RecordingListener listener = new RecordingListener(); + listeners.add(listener); + streamClient.subscribe(FAST_POLL, listener); + } + + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + for (RecordingListener listener : listeners) { + awaitItems(listener, 1); + } + + stub.signal("finish"); + stub.getResult(Void.class); + for (RecordingListener listener : listeners) { + await(listener.completed, "onCompleted"); + Assert.assertNull(listener.error.get()); + } + } + } + + @Test + public void testClientCloseStopsSubscriptions() throws Exception { + WorkflowStub stub = startHostWorkflow(); + RecordingListener listener = new RecordingListener(); + WorkflowStreamSubscriptionHandle handle; + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + handle = streamClient.subscribe(FAST_POLL, listener); + awaitItems(listener, 1); + } + // Closing the client stops the subscription cleanly. + handle.getDoneFuture().get(TIMEOUT_MS, TimeUnit.MILLISECONDS); + Assert.assertNull(listener.error.get()); + stub.signal("finish"); + stub.getResult(Void.class); + } +} diff --git a/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/PayloadWireTest.java b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/PayloadWireTest.java new file mode 100644 index 0000000000..8115a21bc8 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/PayloadWireTest.java @@ -0,0 +1,75 @@ +package io.temporal.workflowstreams; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.workflowstreams.internal.PayloadWire; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.Assert; +import org.junit.Test; + +public class PayloadWireTest { + private static final DataConverter DC = DefaultDataConverter.STANDARD_INSTANCE; + + @Test + public void testPayloadWireRoundTrip() { + Payload payload = DC.toPayload("hello").get(); + + String wire = PayloadWire.encode(payload); + Payload got = PayloadWire.decode(wire); + Assert.assertEquals(payload, got); + + // The decoded payload still carries its encoding metadata so a consumer can + // decode it back to the original value. + String s = DC.fromPayload(got, String.class, String.class); + Assert.assertEquals("hello", s); + } + + @Test + public void testPayloadWireFormatIsBase64OfProto() throws Exception { + Payload payload = + Payload.newBuilder() + .putMetadata("encoding", ByteString.copyFromUtf8("json/plain")) + .setData(ByteString.copyFromUtf8("\"hi\"")) + .build(); + String wire = PayloadWire.encode(payload); + + // Wire format is base64-of-marshaled-proto; decoding base64 then proto must + // reproduce the payload. This is the contract shared with the Go, Python, and + // TypeScript packages. + byte[] raw = Base64.getDecoder().decode(wire); + Payload decoded = Payload.parseFrom(raw); + Assert.assertEquals(payload, decoded); + } + + @Test + public void testDecodePayloadWireRejectsBadInput() { + try { + PayloadWire.decode("not valid base64!!!"); + Assert.fail("unreachable"); + } catch (IllegalArgumentException expected) { + } + } + + @Test + public void testBinaryPayloadRoundTrip() { + byte[] original = new byte[] {0x00, 0x01, (byte) 0xff}; + Payload payload = DC.toPayload(original).get(); + + String wire = PayloadWire.encode(payload); + Payload got = PayloadWire.decode(wire); + + byte[] b = DC.fromPayload(got, byte[].class, byte[].class); + Assert.assertArrayEquals(original, b); + } + + @Test + public void testWireSize() { + Payload payload = + Payload.newBuilder().setData(ByteString.copyFrom("x", StandardCharsets.UTF_8)).build(); + String wire = PayloadWire.encode(payload); + Assert.assertEquals(wire.length() + "topic".length(), PayloadWire.wireSize(wire, "topic")); + } +} diff --git a/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/StreamPublisherTest.java b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/StreamPublisherTest.java new file mode 100644 index 0000000000..7c8cabb6e0 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/StreamPublisherTest.java @@ -0,0 +1,241 @@ +package io.temporal.workflowstreams; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.converter.ByteArrayPayloadConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.workflowstreams.internal.PayloadWire; +import io.temporal.workflowstreams.internal.StreamPublisher; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Assert; +import org.junit.Test; + +public class StreamPublisherTest { + private static final DataConverter DC = DefaultDataConverter.STANDARD_INSTANCE; + + /** Records sent batches; when {@code failure} is set, sending throws it instead. */ + private static class RecordingSignal implements StreamPublisher.SignalFunction { + final List signals = new ArrayList<>(); + volatile RuntimeException failure; + + @Override + public synchronized void send(PublishInput input) { + if (failure != null) { + throw failure; + } + signals.add(input); + } + + synchronized List recorded() { + return new ArrayList<>(signals); + } + } + + private static StreamPublisher newPublisher( + RecordingSignal signal, Duration batchInterval, int maxBatchSize, Duration maxRetry) { + return new StreamPublisher(signal, DC, batchInterval, maxBatchSize, maxRetry); + } + + private static StreamPublisher newPublisher(RecordingSignal signal) { + return newPublisher( + signal, Duration.ofSeconds(2), 0, WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION); + } + + private static String decodeItem(PublishInput input, int index) { + Payload payload = PayloadWire.decode(input.items.get(index).data); + return DC.fromPayload(payload, String.class, String.class); + } + + private static void eventually(Duration timeout, Runnable assertion) throws InterruptedException { + long deadline = System.nanoTime() + timeout.toNanos(); + while (true) { + try { + assertion.run(); + return; + } catch (AssertionError e) { + if (System.nanoTime() > deadline) { + throw e; + } + Thread.sleep(5); + } + } + } + + @Test + public void testFlushSendsBufferedItems() { + RecordingSignal signal = new RecordingSignal(); + StreamPublisher publisher = newPublisher(signal); + publisher.publish("events", "a", false); + publisher.publish("events", "b", false); + + publisher.flush(); + + List signals = signal.recorded(); + Assert.assertEquals(1, signals.size()); + Assert.assertEquals(2, signals.get(0).items.size()); + Assert.assertEquals(1, signals.get(0).sequence); + Assert.assertFalse(signals.get(0).publisherId.isEmpty()); + Assert.assertEquals("a", decodeItem(signals.get(0), 0)); + Assert.assertEquals("b", decodeItem(signals.get(0), 1)); + + publisher.close(); + } + + /** + * Proves that the configured payload converters (not the default set) serialize each item. With + * only the byte-array converter, a byte[] round-trips but a string has no converter and fails — + * whereas the default set's JSON fallback would have accepted it. + */ + @Test + public void testPayloadConvertersDriveItemConversion() { + RecordingSignal signal = new RecordingSignal(); + StreamPublisher publisher = + new StreamPublisher( + signal, + new DefaultDataConverter(new ByteArrayPayloadConverter()), + Duration.ofSeconds(2), + 0, + WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION); + + publisher.publish("events", "hi".getBytes(StandardCharsets.UTF_8), false); + publisher.flush(); + List signals = signal.recorded(); + Assert.assertEquals(1, signals.size()); + Payload payload = PayloadWire.decode(signals.get(0).items.get(0).data); + Assert.assertEquals( + "item must be serialized by the configured byte-array converter", + "binary/plain", + payload.getMetadataOrThrow(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + publisher.close(); + + // A string is unconvertible under the byte-array-only set, so the publish call itself + // fails — the default set's JSON fallback would have accepted it. Conversion happens at + // publish time so a bad value cannot poison the buffer and wedge the background flush + // loop behind it. + RecordingSignal signal2 = new RecordingSignal(); + StreamPublisher publisher2 = + new StreamPublisher( + signal2, + new DefaultDataConverter(new ByteArrayPayloadConverter()), + Duration.ofSeconds(2), + 0, + WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION); + try { + publisher2.publish("events", "not-bytes", false); + Assert.fail("unreachable"); + } catch (RuntimeException expected) { + } + + // The rejected value must not wedge the publisher: a valid item published afterwards + // still ships. + publisher2.publish("events", "ok".getBytes(StandardCharsets.UTF_8), false); + publisher2.flush(); + Assert.assertEquals(1, signal2.recorded().size()); + publisher2.close(); + } + + @Test + public void testFlushNoopWhenEmpty() { + RecordingSignal signal = new RecordingSignal(); + StreamPublisher publisher = newPublisher(signal); + publisher.flush(); + Assert.assertTrue(signal.recorded().isEmpty()); + } + + @Test + public void testSequenceAdvancesAcrossFlushes() { + RecordingSignal signal = new RecordingSignal(); + StreamPublisher publisher = newPublisher(signal); + + publisher.publish("t", "x", false); + publisher.flush(); + publisher.publish("t", "y", false); + publisher.flush(); + + List signals = signal.recorded(); + Assert.assertEquals(2, signals.size()); + Assert.assertEquals(1, signals.get(0).sequence); + Assert.assertEquals(2, signals.get(1).sequence); + Assert.assertEquals(signals.get(0).publisherId, signals.get(1).publisherId); + + publisher.close(); + } + + @Test + public void testMaxBatchSizeTriggersFlush() throws InterruptedException { + RecordingSignal signal = new RecordingSignal(); + // Long interval so only the size threshold can trigger a flush. + StreamPublisher publisher = + newPublisher( + signal, Duration.ofHours(1), 2, WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION); + + publisher.publish("t", "a", false); + publisher.publish("t", "b", false); // reaches maxBatchSize -> flush + + eventually(Duration.ofSeconds(5), () -> Assert.assertEquals(1, signal.recorded().size())); + + publisher.close(); + } + + @Test + public void testCloseDrainsBuffer() { + RecordingSignal signal = new RecordingSignal(); + StreamPublisher publisher = + newPublisher( + signal, Duration.ofHours(1), 0, WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION); + + publisher.publish("t", "a", false); + publisher.close(); + + List signals = signal.recorded(); + Assert.assertEquals(1, signals.size()); + Assert.assertEquals(1, signals.get(0).items.size()); + } + + @Test + public void testForceFlush() throws InterruptedException { + RecordingSignal signal = new RecordingSignal(); + StreamPublisher publisher = + newPublisher( + signal, Duration.ofHours(1), 0, WorkflowStreamConstants.DEFAULT_MAX_RETRY_DURATION); + + publisher.publish("t", "a", true); // forceFlush + + eventually(Duration.ofSeconds(5), () -> Assert.assertEquals(1, signal.recorded().size())); + + publisher.close(); + } + + @Test + public void testFlushTimeoutAfterMaxRetryDuration() throws InterruptedException { + RecordingSignal signal = new RecordingSignal(); + signal.failure = new RuntimeException("boom"); + StreamPublisher publisher = newPublisher(signal, Duration.ofHours(1), 0, Duration.ofMillis(1)); + + publisher.publish("t", "a", false); + + // The first flush sets pending and fails to send (transient "boom"). + try { + publisher.flush(); + Assert.fail("unreachable"); + } catch (RuntimeException e) { + Assert.assertEquals("boom", e.getMessage()); + } + + // Wait past the retry window with ample margin for coarse OS timer granularity. The + // next flush sees the window exceeded and throws FlushTimeoutException. + Thread.sleep(50); + + try { + publisher.flush(); + Assert.fail("unreachable"); + } catch (FlushTimeoutException expected) { + } + + publisher.close(); + } +} diff --git a/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTest.java b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTest.java new file mode 100644 index 0000000000..88c9270a7a --- /dev/null +++ b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTest.java @@ -0,0 +1,207 @@ +package io.temporal.workflowstreams; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflowstreams.SubscribeTestWorkflows.SubscribeHostWorkflow; +import io.temporal.workflowstreams.SubscribeTestWorkflows.SubscribeHostWorkflowImpl; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class SubscribeTest { + private static final DataConverter DC = DefaultDataConverter.STANDARD_INSTANCE; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder().setWorkflowTypes(SubscribeHostWorkflowImpl.class).build(); + + private static final SubscribeOptions FAST_POLL = + SubscribeOptions.newBuilder().setPollCooldown(Duration.ofMillis(50)).build(); + + private WorkflowStub startHostWorkflow() { + SubscribeHostWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(SubscribeHostWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute, null); + return testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(execution.getWorkflowId()); + } + + private WorkflowStreamClient newStreamClient(WorkflowStub stub) { + return WorkflowStreamClient.newInstance( + testWorkflowRule.getWorkflowClient(), + stub.getExecution().getWorkflowId(), + WorkflowStreamClientOptions.newBuilder().setBatchInterval(Duration.ofMillis(100)).build()); + } + + private static String decode(WorkflowStreamItem item) { + return DC.fromPayload(item.getPayload(), String.class, String.class); + } + + @Test + public void testSubscribeDeliversItemsAndAdvancesOffset() { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + // A workflow-side publish lands on the same log. + stub.signal("publishLocal", "evt", "b"); + + try (WorkflowStreamSubscription subscription = streamClient.subscribe(FAST_POLL)) { + Assert.assertTrue(subscription.hasNext()); + WorkflowStreamItem first = subscription.next(); + Assert.assertEquals("evt", first.getTopic()); + Assert.assertEquals("a", decode(first)); + Assert.assertEquals(0, first.getOffset()); + + Assert.assertTrue(subscription.hasNext()); + WorkflowStreamItem second = subscription.next(); + Assert.assertEquals("b", decode(second)); + Assert.assertEquals(1, second.getOffset()); + } + + Assert.assertEquals(2, streamClient.getOffset()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testTopicHandleSubscribeFilters() { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("a").publish("1"); + streamClient.topic("b").publish("2"); + streamClient.topic("a").publish("3"); + streamClient.flush(); + + try (WorkflowStreamSubscription subscription = streamClient.topic("a").subscribe(0)) { + Assert.assertEquals("1", decode(subscription.next())); + WorkflowStreamItem second = subscription.next(); + Assert.assertEquals("3", decode(second)); + Assert.assertEquals(2, second.getOffset()); + } + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testSubscribeEndsCleanlyOnTerminal() { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + + try (WorkflowStreamSubscription subscription = streamClient.subscribe(FAST_POLL)) { + Assert.assertEquals("a", decode(subscription.next())); + + // Complete the workflow, then keep polling: the subscription must end cleanly + // rather than surface an error. + stub.signal("finish"); + stub.getResult(Void.class); + Assert.assertFalse( + "terminal workflow should end the stream without surfacing an error", + subscription.hasNext()); + } + } + } + + @Test + public void testSubscribeFollowsContinueAsNew() { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a", true); + streamClient.flush(); + + try (WorkflowStreamSubscription subscription = streamClient.subscribe(FAST_POLL)) { + WorkflowStreamItem first = subscription.next(); + Assert.assertEquals("a", decode(first)); + Assert.assertEquals(0, first.getOffset()); + + // Roll the workflow over to a new run. The stream state (including item "a") + // is carried across the continue-as-new boundary. + stub.signal("rollover"); + streamClient.topic("evt").publish("b", true); + streamClient.flush(); + + // The subscription retries through the rollover (draining rejections, polls + // lost to the closing run) and picks up on the successor run where the prior + // log — and so the subscriber's offset — is preserved. + WorkflowStreamItem second = subscription.next(); + Assert.assertEquals("b", decode(second)); + Assert.assertEquals(1, second.getOffset()); + } + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testSubscribeTruncationResetsOffset() { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + streamClient.topic("evt").publish("a"); + streamClient.topic("evt").publish("b"); + streamClient.topic("evt").publish("c"); + streamClient.flush(); + // Confirm the batch has been applied before truncating. + try (WorkflowStreamSubscription warmup = streamClient.subscribe(FAST_POLL)) { + warmup.next(); + } + + stub.update("truncate", Void.class, 2L); + + // A subscription positioned before the new base offset restarts from the + // beginning of whatever still exists instead of failing. + SubscribeOptions options = + SubscribeOptions.newBuilder() + .setFromOffset(1) + .setPollCooldown(Duration.ofMillis(50)) + .build(); + try (WorkflowStreamSubscription subscription = streamClient.subscribe(options)) { + WorkflowStreamItem item = subscription.next(); + Assert.assertEquals("c", decode(item)); + Assert.assertEquals(2, item.getOffset()); + } + + Assert.assertEquals(3, streamClient.getOffset()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testUnrecoverableErrorSurfacesFromHasNext() { + try (WorkflowStreamClient streamClient = + WorkflowStreamClient.newInstance( + testWorkflowRule.getWorkflowClient(), "workflow-that-does-not-exist")) { + try (WorkflowStreamSubscription subscription = streamClient.subscribe(FAST_POLL)) { + try { + subscription.hasNext(); + Assert.fail("expected the poll failure to be rethrown"); + } catch (RuntimeException e) { + // Expected: the workflow does not exist, which is neither a rollover nor a + // terminal end, so the failure surfaces to the consumer. + } + Assert.assertFalse( + "the subscription is over after an unrecoverable error", subscription.hasNext()); + } + } + } + + @Test + public void testCloseStopsIteration() { + WorkflowStub stub = startHostWorkflow(); + try (WorkflowStreamClient streamClient = newStreamClient(stub)) { + WorkflowStreamSubscription subscription = streamClient.subscribe(FAST_POLL); + subscription.close(); + Assert.assertFalse(subscription.hasNext()); + } + stub.signal("finish"); + stub.getResult(Void.class); + } +} diff --git a/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTestWorkflows.java b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTestWorkflows.java new file mode 100644 index 0000000000..74b48bc938 --- /dev/null +++ b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/SubscribeTestWorkflows.java @@ -0,0 +1,74 @@ +package io.temporal.workflowstreams; + +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +/** Host workflow fixture shared by the subscription tests. */ +public final class SubscribeTestWorkflows { + private SubscribeTestWorkflows() {} + + @WorkflowInterface + public interface SubscribeHostWorkflow { + @WorkflowMethod + void execute(WorkflowStreamState priorState); + + @SignalMethod + void finish(); + + @SignalMethod + void rollover(); + + @SignalMethod + void publishLocal(String topic, String value); + + @UpdateMethod + void truncate(long upToOffset); + } + + public static class SubscribeHostWorkflowImpl implements SubscribeHostWorkflow { + private final WorkflowStream stream; + private boolean finished; + private boolean rollover; + + // Construct the stream in @WorkflowInit — the module's recommended pattern — so poll + // updates arriving before the workflow method runs (a real-server race the in-process + // test service never exhibits) are accepted rather than rejected with an unknown-update + // error. + @WorkflowInit + public SubscribeHostWorkflowImpl(WorkflowStreamState priorState) { + stream = WorkflowStream.newInstance(priorState); + } + + @Override + public void execute(WorkflowStreamState priorState) { + Workflow.await(() -> finished || rollover); + if (rollover) { + stream.continueAsNew(state -> new Object[] {state}); + } + } + + @Override + public void finish() { + finished = true; + } + + @Override + public void rollover() { + rollover = true; + } + + @Override + public void publishLocal(String topic, String value) { + stream.topic(topic).publish(value); + } + + @Override + public void truncate(long upToOffset) { + stream.truncate(upToOffset); + } + } +} diff --git a/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/WorkflowStreamTest.java b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/WorkflowStreamTest.java new file mode 100644 index 0000000000..27a6069f1c --- /dev/null +++ b/contrib/temporal-workflowstreams/src/test/java/io/temporal/workflowstreams/WorkflowStreamTest.java @@ -0,0 +1,321 @@ +package io.temporal.workflowstreams; + +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowUpdateException; +import io.temporal.common.converter.ByteArrayPayloadConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DataConverterException; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInit; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflowstreams.internal.PayloadWire; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class WorkflowStreamTest { + private static final DataConverter DC = DefaultDataConverter.STANDARD_INSTANCE; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + StreamHostWorkflowImpl.class, + ByteOnlyPublishWorkflowImpl.class, + InitHostWorkflowImpl.class) + .build(); + + private static PublishInput publishInput(String publisherId, long seq, String... topicValues) { + List items = new ArrayList<>(); + for (int i = 0; i < topicValues.length; i += 2) { + Payload payload = DC.toPayload(topicValues[i + 1]).get(); + items.add(new PublishEntry(topicValues[i], PayloadWire.encode(payload))); + } + return new PublishInput(items, publisherId, seq); + } + + private WorkflowStub startHostWorkflow() { + StreamHostWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(StreamHostWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute, null); + return testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(execution.getWorkflowId()); + } + + @Test + public void testExternalPublishAndOffsetQuery() { + WorkflowStub stub = startHostWorkflow(); + + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, + publishInput("pub1", 1, "events", "a", "events", "b")); + // Poll first so the offset query observes the published items. + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.emptyList(), 0)); + + long offset = stub.query(WorkflowStreamConstants.OFFSET_QUERY_NAME, Long.class); + Assert.assertEquals(2, offset); + + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testPublisherDedup() { + WorkflowStub stub = startHostWorkflow(); + + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, publishInput("pub1", 1, "events", "a")); + // Same publisher + sequence: must be dropped. + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, publishInput("pub1", 1, "events", "dup")); + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, publishInput("pub1", 2, "events", "c")); + + PollResult result = + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.emptyList(), 0)); + Assert.assertEquals("duplicate batch should be dropped", 2, result.items.size()); + Assert.assertEquals(2, result.nextOffset); + Assert.assertEquals( + "a", + DC.fromPayload(PayloadWire.decode(result.items.get(0).data), String.class, String.class)); + Assert.assertEquals( + "c", + DC.fromPayload(PayloadWire.decode(result.items.get(1).data), String.class, String.class)); + + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testPollReturnsItemsWithTopicFilter() { + WorkflowStub stub = startHostWorkflow(); + + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, + publishInput("pub1", 1, "a", "1", "b", "2", "a", "3")); + + PollResult result = + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.singletonList("a"), 0)); + + // Only topic "a" items, with global offsets 0 and 2. + Assert.assertEquals(2, result.items.size()); + Assert.assertEquals("a", result.items.get(0).topic); + Assert.assertEquals(0, result.items.get(0).offset); + Assert.assertEquals("a", result.items.get(1).topic); + Assert.assertEquals(2, result.items.get(1).offset); + Assert.assertEquals(3, result.nextOffset); + Assert.assertFalse(result.moreReady); + + Payload payload = PayloadWire.decode(result.items.get(1).data); + Assert.assertEquals("3", DC.fromPayload(payload, String.class, String.class)); + + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testTruncate() { + WorkflowStub stub = startHostWorkflow(); + + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, + publishInput("pub1", 1, "events", "a", "events", "b", "events", "c")); + // Ensure the batch has been applied before truncating. + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.emptyList(), 0)); + + stub.update("truncate", Void.class, 2L); + + // Offset 0 means "from the beginning of whatever still exists". + PollResult fromStart = + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.emptyList(), 0)); + Assert.assertEquals(1, fromStart.items.size()); + Assert.assertEquals(2, fromStart.items.get(0).offset); + + // A poll positioned before the new base offset fails with TruncatedOffset. + try { + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.emptyList(), 1)); + Assert.fail("unreachable"); + } catch (WorkflowUpdateException e) { + ApplicationFailure failure = (ApplicationFailure) e.getCause(); + Assert.assertEquals(WorkflowStreamConstants.ERROR_TYPE_TRUNCATED_OFFSET, failure.getType()); + } + + // Truncating past the end of the log fails with TruncateOutOfRange. + try { + stub.update("truncate", Void.class, 10L); + Assert.fail("unreachable"); + } catch (WorkflowUpdateException e) { + ApplicationFailure failure = (ApplicationFailure) e.getCause(); + Assert.assertEquals( + WorkflowStreamConstants.ERROR_TYPE_TRUNCATE_OUT_OF_RANGE, failure.getType()); + } + + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testStreamConstructedInWorkflowInit() { + InitHostWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(InitHostWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute, null); + WorkflowStub stub = + testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(execution.getWorkflowId()); + + stub.signal( + WorkflowStreamConstants.PUBLISH_SIGNAL_NAME, publishInput("pub1", 1, "events", "a")); + PollResult result = + stub.update( + WorkflowStreamConstants.POLL_UPDATE_NAME, + PollResult.class, + new PollInput(Collections.emptyList(), 0)); + Assert.assertEquals(1, result.items.size()); + Assert.assertEquals( + "a", + DC.fromPayload(PayloadWire.decode(result.items.get(0).data), String.class, String.class)); + Assert.assertEquals( + 1L, (long) stub.query(WorkflowStreamConstants.OFFSET_QUERY_NAME, Long.class)); + + stub.signal("finish"); + stub.getResult(Void.class); + } + + @Test + public void testWorkflowPublishUsesConfiguredConverters() { + ByteOnlyPublishWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(ByteOnlyPublishWorkflow.class); + Assert.assertTrue( + "a string is unconvertible under the byte-array-only set, proving setPayloadConverters" + + " drives conversion", + workflow.execute()); + } + + @WorkflowInterface + public interface StreamHostWorkflow { + @WorkflowMethod + void execute(WorkflowStreamState priorState); + + @SignalMethod + void finish(); + + @UpdateMethod + void truncate(long upToOffset); + } + + public static class StreamHostWorkflowImpl implements StreamHostWorkflow { + private final WorkflowStream stream; + private boolean finished; + + // @WorkflowInit so poll updates arriving before the workflow method runs (a real-server + // race the in-process test service never exhibits) are accepted rather than rejected. + @WorkflowInit + public StreamHostWorkflowImpl(WorkflowStreamState priorState) { + stream = WorkflowStream.newInstance(priorState); + } + + @Override + public void execute(WorkflowStreamState priorState) { + Workflow.await(() -> finished); + } + + @Override + public void finish() { + finished = true; + } + + @Override + public void truncate(long upToOffset) { + stream.truncate(upToOffset); + } + } + + @WorkflowInterface + public interface InitHostWorkflow { + @WorkflowMethod + void execute(WorkflowStreamState priorState); + + @SignalMethod + void finish(); + } + + /** Hosts the stream from a {@code @WorkflowInit} constructor, the recommended pattern. */ + public static class InitHostWorkflowImpl implements InitHostWorkflow { + private final WorkflowStream stream; + private boolean finished; + + @WorkflowInit + public InitHostWorkflowImpl(WorkflowStreamState priorState) { + stream = WorkflowStream.newInstance(priorState); + } + + @Override + public void execute(WorkflowStreamState priorState) { + Workflow.await(() -> finished); + } + + @Override + public void finish() { + finished = true; + } + } + + @WorkflowInterface + public interface ByteOnlyPublishWorkflow { + @WorkflowMethod + boolean execute(); + } + + /** + * Restricts the stream to the byte-array converter and returns whether publishing a string failed + * — it should, since that set has no converter for strings, whereas the default set's JSON + * fallback would accept it. A byte[] must still publish cleanly. + */ + public static class ByteOnlyPublishWorkflowImpl implements ByteOnlyPublishWorkflow { + @Override + public boolean execute() { + WorkflowStream stream = + WorkflowStream.newInstance( + null, + WorkflowStreamOptions.newBuilder() + .setPayloadConverters(new ByteArrayPayloadConverter()) + .build()); + stream.topic("events").publish("hi".getBytes(StandardCharsets.UTF_8)); + try { + stream.topic("events").publish("not-bytes"); + return false; + } catch (DataConverterException e) { + return true; + } + } + } +} diff --git a/settings.gradle b/settings.gradle index 969ce7f93c..3699ff1508 100644 --- a/settings.gradle +++ b/settings.gradle @@ -11,6 +11,8 @@ project(':temporal-opentelemetry').projectDir = file('contrib/temporal-opentelem include 'temporal-kotlin' include 'temporal-spring-ai' project(':temporal-spring-ai').projectDir = file('contrib/temporal-spring-ai') +include 'temporal-workflowstreams' +project(':temporal-workflowstreams').projectDir = file('contrib/temporal-workflowstreams') include 'temporal-aws-lambda' project(':temporal-aws-lambda').projectDir = file('contrib/temporal-aws-lambda') include 'temporal-spring-boot-autoconfigure' diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index 3d9771704a..031633473e 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -20,5 +20,6 @@ dependencies { api project(':temporal-test-server') api project(':temporal-testing') api project(':temporal-envconfig') + api project(':temporal-workflowstreams') } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java index 0886802de9..72987e39c2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java @@ -25,7 +25,8 @@ /** Utility functions shared by the implementation code. */ public final class InternalUtils { - public static String TEMPORAL_RESERVED_PREFIX = "__temporal_"; + public static final String TEMPORAL_RESERVED_PREFIX = "__temporal_"; + public static final String WORKFLOW_STREAM_RESERVED_PREFIX = "__temporal_workflow_stream_"; private static String QUERY_TYPE_STACK_TRACE = "__stack_trace"; private static String ENHANCED_QUERY_TYPE_STACK_TRACE = "__enhanced_stack_trace"; @@ -147,9 +148,21 @@ public static NexusWorkflowStarter createNexusBoundStub( return new NexusWorkflowStarter(stub.newInstance(nexusWorkflowOptions.build()), operationToken); } + /** + * Returns true if the given name is in the {@code __temporal_workflow_stream_} sub-namespace, + * which is reserved for the workflow streams contrib module and permitted for signal, update, and + * query handler registration. + */ + public static boolean isWorkflowStreamReservedName(String name) { + return name.startsWith(WORKFLOW_STREAM_RESERVED_PREFIX); + } + /** Check the method name for reserved prefixes or names. */ public static void checkMethodName(POJOWorkflowMethodMetadata methodMetadata) { - if (methodMetadata.getName().startsWith(TEMPORAL_RESERVED_PREFIX)) { + boolean workflowStreamExempt = + !methodMetadata.getType().equals(WorkflowMethodType.WORKFLOW) + && isWorkflowStreamReservedName(methodMetadata.getName()); + if (methodMetadata.getName().startsWith(TEMPORAL_RESERVED_PREFIX) && !workflowStreamExempt) { throw new IllegalArgumentException( methodMetadata.getType().toString().toLowerCase() + " name \"" diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/QueryDispatcher.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/QueryDispatcher.java index b32fe08ff4..b92ac3b282 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/QueryDispatcher.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/QueryDispatcher.java @@ -1,6 +1,7 @@ package io.temporal.internal.sync; import static io.temporal.internal.common.InternalUtils.TEMPORAL_RESERVED_PREFIX; +import static io.temporal.internal.common.InternalUtils.isWorkflowStreamReservedName; import io.temporal.api.common.v1.Payloads; import io.temporal.api.sdk.v1.WorkflowInteractionDefinition; @@ -80,7 +81,8 @@ public Optional handleQuery( Optional input) { WorkflowOutboundCallsInterceptor.RegisterQueryInput handler = queryCallbacks.get(queryName); Object[] args; - if (queryName.startsWith(TEMPORAL_RESERVED_PREFIX)) { + if (queryName.startsWith(TEMPORAL_RESERVED_PREFIX) + && !isWorkflowStreamReservedName(queryName)) { throw new IllegalArgumentException( "Unknown query type: " + queryName + ", knownTypes=" + queryCallbacks.keySet()); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SignalDispatcher.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SignalDispatcher.java index d85b868150..9bc6bdfa7b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SignalDispatcher.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SignalDispatcher.java @@ -1,6 +1,7 @@ package io.temporal.internal.sync; import static io.temporal.internal.common.InternalUtils.TEMPORAL_RESERVED_PREFIX; +import static io.temporal.internal.common.InternalUtils.isWorkflowStreamReservedName; import io.temporal.api.common.v1.Payloads; import io.temporal.api.sdk.v1.WorkflowInteractionDefinition; @@ -68,7 +69,8 @@ public void handleSignal( signalCallbacks.get(signalName); Object[] args; HandlerUnfinishedPolicy policy; - if (signalName.startsWith(TEMPORAL_RESERVED_PREFIX)) { + if (signalName.startsWith(TEMPORAL_RESERVED_PREFIX) + && !isWorkflowStreamReservedName(signalName)) { // Ignore internal signals return; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/UpdateDispatcher.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/UpdateDispatcher.java index 1122989c85..8ce53e87a5 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/UpdateDispatcher.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/UpdateDispatcher.java @@ -1,6 +1,7 @@ package io.temporal.internal.sync; import static io.temporal.internal.common.InternalUtils.TEMPORAL_RESERVED_PREFIX; +import static io.temporal.internal.common.InternalUtils.isWorkflowStreamReservedName; import io.temporal.api.common.v1.Payloads; import io.temporal.api.sdk.v1.WorkflowInteractionDefinition; @@ -43,7 +44,8 @@ public void handleValidateUpdate( updateCallbacks.get(updateName); Object[] args; HandlerUnfinishedPolicy policy; - if (updateName.startsWith(TEMPORAL_RESERVED_PREFIX)) { + if (updateName.startsWith(TEMPORAL_RESERVED_PREFIX) + && !isWorkflowStreamReservedName(updateName)) { throw new IllegalArgumentException( "Unknown update name: " + updateName + ", knownTypes=" + updateCallbacks.keySet()); } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowStreamReservedNameTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowStreamReservedNameTest.java new file mode 100644 index 0000000000..373defd6db --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowStreamReservedNameTest.java @@ -0,0 +1,123 @@ +package io.temporal.workflow; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowQueryException; +import io.temporal.client.WorkflowStub; +import io.temporal.internal.common.InternalUtils; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import java.util.ArrayList; +import java.util.List; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies that the {@code __temporal_workflow_stream_} sub-namespace is permitted for signal, + * update, and query handlers (used by the workflow streams contrib module) while other {@code + * __temporal_} names remain reserved. + */ +public class WorkflowStreamReservedNameTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestStreamReservedNameWorkflowImpl.class) + .build(); + + @Test + public void testIsWorkflowStreamReservedName() { + Assert.assertTrue( + InternalUtils.isWorkflowStreamReservedName("__temporal_workflow_stream_publish")); + Assert.assertTrue( + InternalUtils.isWorkflowStreamReservedName("__temporal_workflow_stream_poll")); + Assert.assertTrue( + InternalUtils.isWorkflowStreamReservedName("__temporal_workflow_stream_offset")); + Assert.assertFalse(InternalUtils.isWorkflowStreamReservedName("__temporal_")); + Assert.assertFalse(InternalUtils.isWorkflowStreamReservedName("__temporal_foo")); + Assert.assertFalse(InternalUtils.isWorkflowStreamReservedName("__internal")); + Assert.assertFalse(InternalUtils.isWorkflowStreamReservedName("events")); + } + + @Test + public void testWorkflowStreamReservedNamesAreHandled() { + TestStreamReservedNameWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestStreamReservedNameWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute); + + WorkflowStub stub = + testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(execution.getWorkflowId()); + + stub.signal("__temporal_workflow_stream_publish", "a"); + stub.signal("__temporal_workflow_stream_publish", "b"); + String updateResult = stub.update("__temporal_workflow_stream_poll", String.class, "c"); + Assert.assertEquals("polled:c", updateResult); + Long offset = stub.query("__temporal_workflow_stream_offset", Long.class); + Assert.assertEquals(Long.valueOf(3), offset); + + // Other __temporal_ names remain reserved. + try { + stub.query("__temporal_other", Long.class); + Assert.fail("unreachable"); + } catch (WorkflowQueryException e) { + Assert.assertTrue(e.getCause().getMessage().contains("Unknown query type")); + } + + stub.signal("finish"); + stub.getResult(String.class); + } + + public interface StreamHandlersListener { + @SignalMethod(name = "__temporal_workflow_stream_publish") + void publish(String value); + + @UpdateMethod(name = "__temporal_workflow_stream_poll") + String poll(String value); + + @QueryMethod(name = "__temporal_workflow_stream_offset") + long offset(); + } + + @WorkflowInterface + public interface TestStreamReservedNameWorkflow { + @WorkflowMethod + String execute(); + + @SignalMethod + void finish(); + } + + public static class TestStreamReservedNameWorkflowImpl implements TestStreamReservedNameWorkflow { + private final List values = new ArrayList<>(); + private boolean finished; + + @Override + public String execute() { + Workflow.registerListener( + new StreamHandlersListener() { + @Override + public void publish(String value) { + values.add(value); + } + + @Override + public String poll(String value) { + values.add(value); + return "polled:" + value; + } + + @Override + public long offset() { + return values.size(); + } + }); + Workflow.await(() -> finished); + return String.join(",", values); + } + + @Override + public void finish() { + finished = true; + } + } +} From 0192576591f32dd78c8ae57ae8f98da3301ab61c Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Mon, 13 Jul 2026 12:44:43 -0400 Subject: [PATCH 035/107] Stabilize Worker options to enable virtual threads (#2947) --- .../java/io/temporal/worker/WorkerFactoryOptions.java | 2 -- .../src/main/java/io/temporal/worker/WorkerOptions.java | 9 --------- 2 files changed, 11 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactoryOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactoryOptions.java index 95f8ebc751..dae83334de 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactoryOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactoryOptions.java @@ -136,7 +136,6 @@ public Builder setEnableLoggingInReplay(boolean enableLoggingInReplay) { * *

Default is false */ - @Experimental public Builder setUsingVirtualWorkflowThreads(boolean usingVirtualWorkflowThreads) { this.usingVirtualWorkflowThreads = usingVirtualWorkflowThreads; return this; @@ -307,7 +306,6 @@ public boolean isEnableLoggingInReplay() { return enableLoggingInReplay; } - @Experimental public boolean isUsingVirtualWorkflowThreads() { return usingVirtualWorkflowThreads; } diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index 2f69517b6c..4e351cb74d 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -437,7 +437,6 @@ public Builder setWorkerTuner(WorkerTuner workerTuner) { * supported for JDK >= 21. Individual options for different types of workers can be set using * the respective methods. */ - @Experimental public Builder setUsingVirtualThreads(boolean enable) { this.usingVirtualThreadsOnWorkflowWorker = enable; this.usingVirtualThreadsOnLocalActivityWorker = enable; @@ -450,7 +449,6 @@ public Builder setUsingVirtualThreads(boolean enable) { * Use Virtual Threads for the Workflow task executors created by this worker. This option is * only supported for JDK >= 21. */ - @Experimental public Builder setUsingVirtualThreadsOnWorkflowWorker(boolean enable) { this.usingVirtualThreadsOnWorkflowWorker = enable; return this; @@ -460,7 +458,6 @@ public Builder setUsingVirtualThreadsOnWorkflowWorker(boolean enable) { * Use Virtual Threads for the Local Activity task executors created by this worker. This option * is only supported for JDK >= 21. */ - @Experimental public Builder setUsingVirtualThreadsOnLocalActivityWorker(boolean enable) { this.usingVirtualThreadsOnLocalActivityWorker = enable; return this; @@ -470,7 +467,6 @@ public Builder setUsingVirtualThreadsOnLocalActivityWorker(boolean enable) { * Use Virtual Threads for the Activity task executors created by this worker. This option is * only supported for JDK >= 21. */ - @Experimental public Builder setUsingVirtualThreadsOnActivityWorker(boolean enable) { this.usingVirtualThreadsOnActivityWorker = enable; return this; @@ -480,7 +476,6 @@ public Builder setUsingVirtualThreadsOnActivityWorker(boolean enable) { * Use Virtual Threads for the Nexus task executors created by this worker. This option is only * supported for JDK >= 21. */ - @Experimental public Builder setUsingVirtualThreadsOnNexusWorker(boolean enable) { this.usingVirtualThreadsOnNexusWorker = enable; return this; @@ -924,22 +919,18 @@ public String getIdentity() { return identity; } - @Experimental public boolean isUsingVirtualThreadsOnWorkflowWorker() { return usingVirtualThreadsOnActivityWorker; } - @Experimental public boolean isUsingVirtualThreadsOnActivityWorker() { return usingVirtualThreadsOnActivityWorker; } - @Experimental public boolean isUsingVirtualThreadsOnLocalActivityWorker() { return usingVirtualThreadsOnLocalActivityWorker; } - @Experimental public boolean isUsingVirtualThreadsOnNexusWorker() { return usingVirtualThreadsOnNexusWorker; } From ff980d39105c0ea3955215efb749685aaee578cd Mon Sep 17 00:00:00 2001 From: Aditya Aggarwal Date: Mon, 13 Jul 2026 22:48:37 +0530 Subject: [PATCH 036/107] Propagate memo on continue-as-new in the test server (#2943) The in-memory test server carried over search attributes and header on continue-as-new but dropped the memo, so a workflow that sets a memo on continue-as-new could not read it back on the new run under the test environment even though it works against a real server. Set the memo on the ContinuedAsNew event attributes and on the new run start request, mirroring the existing search attribute handling, and extend ContinueAsNewTest to assert the memo is visible after continue-as-new. Closes #2863 --- .../test/java/io/temporal/workflow/ContinueAsNewTest.java | 7 +++++++ .../io/temporal/internal/testservice/StateMachines.java | 3 +++ .../temporal/internal/testservice/TestWorkflowService.java | 3 +++ 3 files changed, 13 insertions(+) diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/ContinueAsNewTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/ContinueAsNewTest.java index defc616ea1..83bf8838d1 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/ContinueAsNewTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/ContinueAsNewTest.java @@ -77,6 +77,13 @@ public int execute(int count, String continueAsNewTaskQueue) { assertEquals(5, Workflow.getInfo().getRetryOptions().getMaximumAttempts()); assertEquals("foo1", Workflow.getTypedSearchAttributes().get(CUSTOM_KEYWORD_SA)); } + // The memo is set on every continue-as-new below, so every run after the + // first one should observe it once the test server propagates it. + if (count <= INITIAL_COUNT - 2) { + assertEquals("MyValue", Workflow.getMemo("myKey", String.class)); + } else { + Assert.assertNull(Workflow.getMemo("myKey", String.class)); + } if (count == 0) { assertEquals(continueAsNewTaskQueue, taskQueue); return 111; diff --git a/temporal-test-server/src/main/java/io/temporal/internal/testservice/StateMachines.java b/temporal-test-server/src/main/java/io/temporal/internal/testservice/StateMachines.java index c62d6f84a7..fce9d3ae01 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/StateMachines.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/StateMachines.java @@ -1467,6 +1467,9 @@ private static void continueAsNewWorkflow( if (d.hasSearchAttributes()) { a.setSearchAttributes(d.getSearchAttributes()); } + if (d.hasMemo()) { + a.setMemo(d.getMemo()); + } a.setNewExecutionRunId(UUID.randomUUID().toString()); HistoryEvent event = HistoryEvent.newBuilder() diff --git a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java index d852201775..b62f021a3c 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java @@ -1757,6 +1757,9 @@ public String continueAsNew( if (ea.hasSearchAttributes()) { startRequestBuilder.setSearchAttributes(ea.getSearchAttributes()); } + if (ea.hasMemo()) { + startRequestBuilder.setMemo(ea.getMemo()); + } StartWorkflowExecutionRequest startRequest = startRequestBuilder.build(); lock.lock(); Optional lastFail = From ed6a3f7b60b13b6eb14f20b9410478dc201d5914 Mon Sep 17 00:00:00 2001 From: Vikas Pandey <144092552+vikas0686@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:48:43 +0530 Subject: [PATCH 037/107] fix: add @TemporalDsl receiver annotation to setRetryOptions extensions (#2915) --- .../temporal/activity/ActivityOptionsExt.kt | 2 +- .../io/temporal/client/WorkflowOptionsExt.kt | 2 +- .../workflow/ChildWorkflowOptionsExt.kt | 2 +- .../activity/ActivityOptionsExtTest.kt | 21 +++++++++++++++++++ .../temporal/client/WorkflowOptionsExtTest.kt | 21 +++++++++++++++++++ .../workflow/ChildWorkflowOptionsExtTest.kt | 21 +++++++++++++++++++ 6 files changed, 66 insertions(+), 3 deletions(-) diff --git a/temporal-kotlin/src/main/kotlin/io/temporal/activity/ActivityOptionsExt.kt b/temporal-kotlin/src/main/kotlin/io/temporal/activity/ActivityOptionsExt.kt index 30e3de2df9..91094906c9 100644 --- a/temporal-kotlin/src/main/kotlin/io/temporal/activity/ActivityOptionsExt.kt +++ b/temporal-kotlin/src/main/kotlin/io/temporal/activity/ActivityOptionsExt.kt @@ -28,7 +28,7 @@ inline fun ActivityOptions.copy( * @see ActivityOptions.Builder.setRetryOptions * @see ActivityOptions.getRetryOptions */ -inline fun ActivityOptions.Builder.setRetryOptions( +inline fun @TemporalDsl ActivityOptions.Builder.setRetryOptions( retryOptions: @TemporalDsl RetryOptions.Builder.() -> Unit ) { setRetryOptions(RetryOptions(retryOptions)) diff --git a/temporal-kotlin/src/main/kotlin/io/temporal/client/WorkflowOptionsExt.kt b/temporal-kotlin/src/main/kotlin/io/temporal/client/WorkflowOptionsExt.kt index 7560e798d7..e5cbb3ed5e 100644 --- a/temporal-kotlin/src/main/kotlin/io/temporal/client/WorkflowOptionsExt.kt +++ b/temporal-kotlin/src/main/kotlin/io/temporal/client/WorkflowOptionsExt.kt @@ -26,7 +26,7 @@ inline fun WorkflowOptions.copy( * @see WorkflowOptions.Builder.setRetryOptions * @see WorkflowOptions.getRetryOptions */ -inline fun WorkflowOptions.Builder.setRetryOptions( +inline fun @TemporalDsl WorkflowOptions.Builder.setRetryOptions( retryOptions: @TemporalDsl RetryOptions.Builder.() -> Unit ) { setRetryOptions(RetryOptions(retryOptions)) diff --git a/temporal-kotlin/src/main/kotlin/io/temporal/workflow/ChildWorkflowOptionsExt.kt b/temporal-kotlin/src/main/kotlin/io/temporal/workflow/ChildWorkflowOptionsExt.kt index 09473172c0..31aa20a955 100644 --- a/temporal-kotlin/src/main/kotlin/io/temporal/workflow/ChildWorkflowOptionsExt.kt +++ b/temporal-kotlin/src/main/kotlin/io/temporal/workflow/ChildWorkflowOptionsExt.kt @@ -26,7 +26,7 @@ inline fun ChildWorkflowOptions.copy( * @see ChildWorkflowOptions.Builder.setRetryOptions * @see ChildWorkflowOptions.getRetryOptions */ -inline fun ChildWorkflowOptions.Builder.setRetryOptions( +inline fun @TemporalDsl ChildWorkflowOptions.Builder.setRetryOptions( retryOptions: @TemporalDsl RetryOptions.Builder.() -> Unit ) { setRetryOptions(RetryOptions(retryOptions)) diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/activity/ActivityOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/activity/ActivityOptionsExtTest.kt index 52210817c9..713405f304 100644 --- a/temporal-kotlin/src/test/kotlin/io/temporal/activity/ActivityOptionsExtTest.kt +++ b/temporal-kotlin/src/test/kotlin/io/temporal/activity/ActivityOptionsExtTest.kt @@ -37,6 +37,27 @@ class ActivityOptionsExtTest { assertEquals(builderActivityOptions, dslActivityOptions) } + @Test + fun `setRetryOptions DSL extension should work on ActivityOptions builder directly`() { + val builder = ActivityOptions.newBuilder().setTaskQueue("TestQueue") + builder.setRetryOptions { + setInitialInterval(Duration.ofMillis(50)) + setMaximumAttempts(5) + } + + val expected = ActivityOptions.newBuilder() + .setTaskQueue("TestQueue") + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(50)) + .setMaximumAttempts(5) + .build() + ) + .build() + + assertEquals(expected, builder.build()) + } + @Test fun `ActivityOptions copy() DSL should merge override options`() { val sourceOptions = ActivityOptions { diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowOptionsExtTest.kt index 1dbc24d2db..2bdaf78015 100644 --- a/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowOptionsExtTest.kt +++ b/temporal-kotlin/src/test/kotlin/io/temporal/client/WorkflowOptionsExtTest.kt @@ -38,6 +38,27 @@ class WorkflowOptionsExtTest { assertEquals(builderOptions, dslOptions) } + @Test + fun `setRetryOptions DSL extension should work on WorkflowOptions builder directly`() { + val builder = WorkflowOptions.newBuilder().setTaskQueue("TestQueue") + builder.setRetryOptions { + setInitialInterval(Duration.ofMillis(50)) + setMaximumAttempts(5) + } + + val expected = WorkflowOptions.newBuilder() + .setTaskQueue("TestQueue") + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(50)) + .setMaximumAttempts(5) + .build() + ) + .build() + + assertEquals(expected, builder.build()) + } + @Test fun `WorkflowOptions copy() DSL should merge override options`() { val sourceOptions = WorkflowOptions { diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt index 52070395f7..019fd8cc23 100644 --- a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt +++ b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/ChildWorkflowOptionsExtTest.kt @@ -32,6 +32,27 @@ class ChildWorkflowOptionsExtTest { assertEquals(builderOptions, dslOptions) } + @Test + fun `setRetryOptions DSL extension should work on ChildWorkflowOptions builder directly`() { + val builder = ChildWorkflowOptions.newBuilder().setTaskQueue("TestQueue") + builder.setRetryOptions { + setInitialInterval(Duration.ofMillis(50)) + setMaximumAttempts(3) + } + + val expected = ChildWorkflowOptions.newBuilder() + .setTaskQueue("TestQueue") + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(50)) + .setMaximumAttempts(3) + .build() + ) + .build() + + assertEquals(expected, builder.build()) + } + @Test fun `ChildWorkflowOptions copy() DSL should merge override options`() { val sourceOptions = ChildWorkflowOptions { From 8b577944ebf95123f83dd8268bd7621cd84803fd Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Mon, 13 Jul 2026 17:35:48 -0400 Subject: [PATCH 038/107] Release Java SDK v1.37.0 (#2949) --- releases/v1.37.0 | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 releases/v1.37.0 diff --git a/releases/v1.37.0 b/releases/v1.37.0 new file mode 100644 index 0000000000..c8a583da6c --- /dev/null +++ b/releases/v1.37.0 @@ -0,0 +1,37 @@ +# **Highlights** + +## Virtual Threads are stable + +Support for virtual threads is now stable. The feature requires JVM version of 21 or higher. +Virtual threads can be used inside workflows by enabling `WorkerFactoryOptions.setUsingVirtualWorkflowThreads`. +Users can also use virtual threads for task processing in a worker by enabling `WorkerOptions.setUsingVirtualThreads`. + +## Workflow Streams (Public Preview) + +This release adds Workflow Streams, a public preview contrib library that gives a workflow a durable, offset-addressed event channel +for keeping outside observers updated on workflow and activity progress. It's built on Temporal's existing signals, updates, and queries, +with batching, exactly-once deduplication, topic filtering, and continue-as-new helpers layered on top. +Intended uses are streaming UIs for long-running AI agents, status for in-flight payment or order processing, and progress for data pipelines. +It is not for not ultra-low-latency cases like real-time voice. + +## AWS Lambda Serverless Workers (experimental) + +This release adds the `temporal-aws-lambda` contrib module for running Java Workers with Temporal Serverless Workers on AWS Lambda. +See [online documentation](https://docs.temporal.io/serverless-workers) and the module's README for more information. + +## Workflow preferred version provider (experimental) + +Added `worker.Options.PreferredVersionProvider`, which can select the version recorded by a newly encountered `workflow.GetVersion` call. +This supports gradual rollout of a new `GetVersion` call before activating its new behavior. + +# What's Changed + +2026-07-02 - ac9c7ddb - Change references to `master` branch to `main` (#2938) +2026-07-06 - f86c7664 - fix flakey WorkflowUpdateTest.duplicateRejectedUpdate logic where ADMITTED was reported for updates that were actually COMPLETED. (#2940) +2026-07-09 - 23590a1c - AWS Lambda (Java) (#2901) +2026-07-09 - b8ace300 - Add preferred version provider to worker options (#2942) +2026-07-10 - 2c5e662d - Fix misleading docstring in preferred version PR (#2944) +2026-07-13 - 01925765 - Stabilize Worker options to enable virtual threads (#2947) +2026-07-13 - c0c91323 - Add temporal-workflowstreams contrib module (#2912) +2026-07-13 - ed6a3f7b - fix: add @TemporalDsl receiver annotation to setRetryOptions extensions (#2915) +2026-07-13 - ff980d39 - Propagate memo on continue-as-new in the test server (#2943) From 6d09a341c14daeddf9b3795cd43721de770fec2c Mon Sep 17 00:00:00 2001 From: Veeral Patel Date: Tue, 21 Jul 2026 16:32:55 -0500 Subject: [PATCH 039/107] Auto-enroll pollers into autoscaling on PollerAutoscalingAutoEnroll (Java SDK) (#2953) * Auto-enroll pollers into autoscaling on PollerAutoscalingAutoEnroll When a namespace advertises the PollerAutoscalingAutoEnroll capability, automatically switch a poller type to poller autoscaling if the user left it at its default (set neither MaxConcurrentTaskPollers nor a TaskPollerBehavior). Explicitly configured pollers are untouched. Auto-enroll implies full autoscaling support, so it also enables serverSupportsAutoscaling (scale-down). Applies to workflow, activity, and nexus pollers. The decision is made at worker start(), after namespace capabilities are loaded, by rebuilding each dormant worker's PollerOptions before its poller is created. Per-poller-type eligibility is captured at Worker construction from the raw (pre-defaulting) WorkerOptions and threaded through PollerOptions. Also bumps the temporal/api proto submodule to pick up the poller_autoscaling_auto_enroll namespace capability (api #803). * Address review: track poller-setter intent for auto-enroll eligibility - Determine auto-enroll eligibility from whether the user actually called a poller setter (MaxConcurrentTaskPollers or TaskPollerBehavior), tracked on WorkerOptions.Builder and carried through build/validate/copy, instead of inferring from the resolved option values. This fixes the case where options from getDefaultInstance()/validateAndBuildWithDefaults() carry the numeric default (5) and would wrongly look explicitly configured. - Worker reads the new WorkerOptions eligibility getters instead of the raw pre-defaulting options. - Reword the NamespaceCapabilities comment to explain why auto-enroll also enables pollerAutoscaling (serverSupportsAutoscaling in PollScaleReportHandle), and fix the mangled comment in Worker. - Expand tests: eligibility from getDefaultInstance()/validateAndBuildWithDefaults() and copies stays eligible; an explicit count equal to the numeric default is ineligible. Add a full start-path E2E test asserting workflow/activity/nexus pollers become autoscaling when the namespace advertises the capability. * Address review: don't couple auto-enroll to the pollerAutoscaling flag The auto-enroll capability no longer forces NamespaceCapabilities.pollerAutoscaling on. The server advertises pollerAutoscaling independently (and unconditionally), so the pre-existing getPollerAutoscaling() read already enables scale-down for auto-enrolled pollers; the extra coupling was redundant and conflated the scale-down flag with the enrollment decision. setFromCapabilities reverts to reading each capability separately. The auto-enroll field/getter stay, since the enrollment decision still needs them and is independent of isPollerAutoscaling(). Replaced the obsolete implication test with one asserting the two capabilities are independent. * Fix awkward comment wrapping in WorkerPollerAutoEnrollEligibilityTest --- .../internal/worker/ActivityWorker.java | 7 +- .../worker/NamespaceCapabilities.java | 8 + .../temporal/internal/worker/NexusWorker.java | 7 +- .../internal/worker/PollerOptions.java | 46 ++++- .../internal/worker/WorkflowWorker.java | 7 +- .../main/java/io/temporal/worker/Worker.java | 30 ++- .../io/temporal/worker/WorkerOptions.java | 101 +++++++++- .../PollerAutoscalingAutoEnrollTest.java | 114 +++++++++++ ...WorkerPollerAutoEnrollEligibilityTest.java | 185 ++++++++++++++++++ .../WorkerPollerAutoEnrollStartupTest.java | 157 +++++++++++++++ temporal-serviceclient/src/main/proto | 2 +- 11 files changed, 647 insertions(+), 17 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/PollerAutoscalingAutoEnrollTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java index 6c86fc4472..ff528d46b3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java @@ -43,7 +43,7 @@ final class ActivityWorker implements SuspendableWorker { private final String taskQueue; private final SingleWorkerOptions options; private final double taskQueueActivitiesPerSecond; - private final PollerOptions pollerOptions; + private PollerOptions pollerOptions; private final Scope workerMetricsScope; private final GrpcRetryer grpcRetryer; private final GrpcRetryer.GrpcRetryerOptions replyGrpcRetryerOptions; @@ -83,6 +83,11 @@ public ActivityWorker( @Override public boolean start() { if (handler.isAnyTypeSupported()) { + // Auto-enroll into poller autoscaling if the namespace advertises the capability and this + // poller type was left at its default. Resolved here (after namespace capabilities are known) + // so the poller built below reflects the effective behavior. + this.pollerOptions = + PollerOptions.maybeEnrollInPollerAutoscaling(pollerOptions, namespaceCapabilities); this.pollTaskExecutor = new PollTaskExecutor<>( namespace, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java index ed4ac3935f..4bddd45d9e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java @@ -10,11 +10,15 @@ */ public final class NamespaceCapabilities { private final AtomicBoolean pollerAutoscaling = new AtomicBoolean(false); + private final AtomicBoolean pollerAutoscalingAutoEnroll = new AtomicBoolean(false); private final AtomicBoolean gracefulPollShutdown = new AtomicBoolean(false); private final AtomicBoolean workerHeartbeats = new AtomicBoolean(false); private final AtomicBoolean workerCommands = new AtomicBoolean(false); public void setFromCapabilities(Capabilities capabilities) { + if (capabilities.getPollerAutoscalingAutoEnroll()) { + pollerAutoscalingAutoEnroll.set(true); + } if (capabilities.getPollerAutoscaling()) { pollerAutoscaling.set(true); } @@ -33,6 +37,10 @@ public boolean isPollerAutoscaling() { return pollerAutoscaling.get(); } + public boolean isPollerAutoscalingAutoEnroll() { + return pollerAutoscalingAutoEnroll.get(); + } + public boolean isGracefulPollShutdown() { return gracefulPollShutdown.get(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java index 1fd9cf9148..33416a807b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java @@ -46,7 +46,7 @@ final class NexusWorker implements SuspendableWorker { private final String namespace; private final String taskQueue; private final SingleWorkerOptions options; - private final PollerOptions pollerOptions; + private PollerOptions pollerOptions; private final Scope workerMetricsScope; private final DataConverter dataConverter; private final GrpcRetryer grpcRetryer; @@ -114,6 +114,11 @@ public NexusWorker( @Override public boolean start() { if (handler.start()) { + // Auto-enroll into poller autoscaling if the namespace advertises the capability and this + // poller type was left at its default. Resolved here (after namespace capabilities are known) + // so the poller built below reflects the effective behavior. + this.pollerOptions = + PollerOptions.maybeEnrollInPollerAutoscaling(pollerOptions, namespaceCapabilities); this.pollTaskExecutor = new PollTaskExecutor<>( namespace, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/PollerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/PollerOptions.java index 1765c5d1cd..1245907dda 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/PollerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/PollerOptions.java @@ -3,6 +3,7 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.temporal.worker.tuning.PollerBehavior; +import io.temporal.worker.tuning.PollerBehaviorAutoscaling; import java.time.Duration; import java.util.concurrent.ExecutorService; import org.slf4j.Logger; @@ -26,6 +27,27 @@ public static PollerOptions getDefaultInstance() { return DEFAULT_INSTANCE; } + /** + * If the given options are eligible for poller-autoscaling auto-enrollment (the user left this + * poller type at its default) and the namespace advertises the auto-enroll capability, returns a + * copy of the options with a default {@link PollerBehaviorAutoscaling} behavior. Otherwise + * returns the options unchanged. + * + *

Must only be called before the worker's poller is created (i.e. at worker start), so the + * resolved behavior is picked up when the poller is built. + */ + public static PollerOptions maybeEnrollInPollerAutoscaling( + PollerOptions options, NamespaceCapabilities namespaceCapabilities) { + if (options.isAutoscalingAutoEnrollEligible() + && namespaceCapabilities.isPollerAutoscalingAutoEnroll() + && !(options.getPollerBehavior() instanceof PollerBehaviorAutoscaling)) { + return PollerOptions.newBuilder(options) + .setPollerBehavior(new PollerBehaviorAutoscaling()) + .build(); + } + return options; + } + private static final PollerOptions DEFAULT_INSTANCE; static { @@ -46,6 +68,7 @@ public static final class Builder { private Thread.UncaughtExceptionHandler uncaughtExceptionHandler; private boolean usingVirtualThreads; private ExecutorService pollerTaskExecutorOverride; + private boolean autoscalingAutoEnrollEligible; private Builder() {} @@ -65,6 +88,7 @@ private Builder(PollerOptions options) { this.uncaughtExceptionHandler = options.getUncaughtExceptionHandler(); this.usingVirtualThreads = options.isUsingVirtualThreads(); this.pollerTaskExecutorOverride = options.getPollerTaskExecutorOverride(); + this.autoscalingAutoEnrollEligible = options.isAutoscalingAutoEnrollEligible(); } /** Defines interval for measuring poll rate. Larger the interval more spiky can be the load. */ @@ -152,6 +176,16 @@ public Builder setPollerTaskExecutorOverride(ExecutorService overrideTaskExecuto return this; } + /** + * Marks whether this poller type was left at its default (the user set neither a fixed poller + * count nor a poller behavior) and is therefore eligible for poller-autoscaling auto-enrollment + * when the namespace advertises the capability. + */ + public Builder setAutoscalingAutoEnrollEligible(boolean autoscalingAutoEnrollEligible) { + this.autoscalingAutoEnrollEligible = autoscalingAutoEnrollEligible; + return this; + } + public PollerOptions build() { if (uncaughtExceptionHandler == null) { uncaughtExceptionHandler = @@ -180,7 +214,8 @@ public PollerOptions build() { uncaughtExceptionHandler, pollThreadNamePrefix, usingVirtualThreads, - pollerTaskExecutorOverride); + pollerTaskExecutorOverride, + autoscalingAutoEnrollEligible); } } @@ -198,6 +233,7 @@ public PollerOptions build() { private final boolean usingVirtualThreads; private final ExecutorService pollerTaskExecutorOverride; private final PollerBehavior pollerBehavior; + private final boolean autoscalingAutoEnrollEligible; private PollerOptions( int maximumPollRateIntervalMilliseconds, @@ -211,7 +247,8 @@ private PollerOptions( Thread.UncaughtExceptionHandler uncaughtExceptionHandler, String pollThreadNamePrefix, boolean usingVirtualThreads, - ExecutorService pollerTaskExecutorOverride) { + ExecutorService pollerTaskExecutorOverride, + boolean autoscalingAutoEnrollEligible) { this.maximumPollRateIntervalMilliseconds = maximumPollRateIntervalMilliseconds; this.maximumPollRatePerSecond = maximumPollRatePerSecond; this.backoffCoefficient = backoffCoefficient; @@ -224,6 +261,7 @@ private PollerOptions( this.pollThreadNamePrefix = pollThreadNamePrefix; this.usingVirtualThreads = usingVirtualThreads; this.pollerTaskExecutorOverride = pollerTaskExecutorOverride; + this.autoscalingAutoEnrollEligible = autoscalingAutoEnrollEligible; } public int getMaximumPollRateIntervalMilliseconds() { @@ -274,6 +312,10 @@ public ExecutorService getPollerTaskExecutorOverride() { return pollerTaskExecutorOverride; } + public boolean isAutoscalingAutoEnrollEligible() { + return autoscalingAutoEnrollEligible; + } + @Override public String toString() { return "PollerOptions{" diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index 0de3ffaf6b..b86dfea6df 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -48,7 +48,7 @@ final class WorkflowWorker implements SuspendableWorker { private final WorkflowExecutorCache cache; private final WorkflowTaskHandler handler; private final String stickyTaskQueueName; - private final PollerOptions pollerOptions; + private PollerOptions pollerOptions; private final Scope workerMetricsScope; private final GrpcRetryer grpcRetryer; private final EagerActivityDispatcher eagerActivityDispatcher; @@ -99,6 +99,11 @@ public WorkflowWorker( @Override public boolean start() { if (handler.isAnyTypeSupported()) { + // Auto-enroll into poller autoscaling if the namespace advertises the capability and this + // poller type was left at its default. Resolved here (after namespace capabilities are known) + // so the poller built below reflects the effective behavior. + this.pollerOptions = + PollerOptions.maybeEnrollInPollerAutoscaling(pollerOptions, namespaceCapabilities); pollTaskExecutor = new PollTaskExecutor<>( namespace, diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index 5ba7613865..93a26def2b 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -132,6 +132,15 @@ private static final class TaskSnapshot { Map tags = new ImmutableMap.Builder(1).put(MetricsTag.TASK_QUEUE, taskQueue).build(); Scope taggedScope = metricsScope.tagged(tags); + + // Poller types the user left at their default are auto-enrolled into poller autoscaling at + // start() when the namespace advertises the PollerAutoscalingAutoEnroll capability. Eligibility + // tracks whether the user called a poller setter (recorded on WorkerOptions.Builder), not the + // resolved value, so a defaulted count of 5 is not mistaken for an explicit choice. + boolean workflowTaskAutoEnrollEligible = this.options.isWorkflowTaskPollerAutoEnrollEligible(); + boolean activityTaskAutoEnrollEligible = this.options.isActivityTaskPollerAutoEnrollEligible(); + boolean nexusTaskAutoEnrollEligible = this.options.isNexusTaskPollerAutoEnrollEligible(); + SingleWorkerOptions activityOptions = toActivityOptions( factoryOptions, @@ -140,7 +149,8 @@ private static final class TaskSnapshot { contextPropagators, taggedScope, workerInstanceKey, - workerControlTaskQueue); + workerControlTaskQueue, + activityTaskAutoEnrollEligible); if (this.options.isLocalActivityWorkerOnly()) { activityWorker = null; } else { @@ -174,7 +184,8 @@ private static final class TaskSnapshot { contextPropagators, taggedScope, workerInstanceKey, - workerControlTaskQueue); + workerControlTaskQueue, + nexusTaskAutoEnrollEligible); SlotSupplier nexusSlotSupplier = this.options.getWorkerTuner() == null ? new FixedSizeSlotSupplier<>(this.options.getMaxConcurrentNexusExecutionSize()) @@ -194,7 +205,8 @@ private static final class TaskSnapshot { contextPropagators, taggedScope, workerInstanceKey, - workerControlTaskQueue); + workerControlTaskQueue, + workflowTaskAutoEnrollEligible); SingleWorkerOptions localActivityOptions = toLocalActivityOptions( factoryOptions, @@ -901,7 +913,8 @@ private static SingleWorkerOptions toActivityOptions( List contextPropagators, Scope metricsScope, String workerInstanceKey, - String workerControlTaskQueue) { + String workerControlTaskQueue, + boolean autoEnrollEligible) { return toSingleWorkerOptions( factoryOptions, options, @@ -920,6 +933,7 @@ private static SingleWorkerOptions toActivityOptions( : new PollerBehaviorSimpleMaximum( options.getMaxConcurrentActivityTaskPollers())) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnActivityWorker()) + .setAutoscalingAutoEnrollEligible(autoEnrollEligible) .build()) .setMetricsScope(metricsScope) .build(); @@ -932,7 +946,8 @@ private static SingleWorkerOptions toNexusOptions( List contextPropagators, Scope metricsScope, String workerInstanceKey, - String workerControlTaskQueue) { + String workerControlTaskQueue, + boolean autoEnrollEligible) { return toSingleWorkerOptions( factoryOptions, options, @@ -948,6 +963,7 @@ private static SingleWorkerOptions toNexusOptions( : new PollerBehaviorSimpleMaximum( options.getMaxConcurrentNexusTaskPollers())) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnNexusWorker()) + .setAutoscalingAutoEnrollEligible(autoEnrollEligible) .build()) .setMetricsScope(metricsScope) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnNexusWorker()) @@ -962,7 +978,8 @@ private static SingleWorkerOptions toWorkflowWorkerOptions( List contextPropagators, Scope metricsScope, String workerInstanceKey, - String workerControlTaskQueue) { + String workerControlTaskQueue, + boolean autoEnrollEligible) { Map tags = new ImmutableMap.Builder(1).put(MetricsTag.TASK_QUEUE, taskQueue).build(); @@ -1005,6 +1022,7 @@ private static SingleWorkerOptions toWorkflowWorkerOptions( ? pollerBehavior : new PollerBehaviorSimpleMaximum(maxConcurrentWorkflowTaskPollers)) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnWorkflowWorker()) + .setAutoscalingAutoEnrollEligible(autoEnrollEligible) .build()) .setStickyQueueScheduleToStartTimeout(stickyQueueScheduleToStartTimeout) .setStickyTaskQueueDrainTimeout(options.getStickyTaskQueueDrainTimeout()) diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index 4e351cb74d..99a596514b 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -79,6 +79,13 @@ public static final class Builder { private PollerBehavior nexusTaskPollersBehavior; private boolean allowActivityHeartbeatDuringShutdown; private PreferredVersionProvider preferredVersionProvider; + // Track whether the user explicitly configured the pollers for a task type (called either the + // max-concurrent-pollers or the poller-behavior setter). A type left unconfigured is eligible + // for poller-autoscaling auto-enrollment. This must reflect the user's intent, not the resolved + // value, since defaulting fills in a non-zero count that would otherwise look explicit. + private boolean workflowTaskPollersConfigured; + private boolean activityTaskPollersConfigured; + private boolean nexusTaskPollersConfigured; private Builder() {} @@ -116,6 +123,9 @@ private Builder(WorkerOptions o) { this.nexusTaskPollersBehavior = o.nexusTaskPollersBehavior; this.allowActivityHeartbeatDuringShutdown = o.allowActivityHeartbeatDuringShutdown; this.preferredVersionProvider = o.preferredVersionProvider; + this.workflowTaskPollersConfigured = o.workflowTaskPollersConfigured; + this.activityTaskPollersConfigured = o.activityTaskPollersConfigured; + this.nexusTaskPollersConfigured = o.nexusTaskPollersConfigured; } /** @@ -229,9 +239,14 @@ public Builder setMaxTaskQueueActivitiesPerSecond(double maxTaskQueueActivitiesP * value cannot be 1 and will be adjusted to 2 if set to that value. * *

Default is 5, which is chosen if set to zero. + * + *

NOTE: If neither this nor {@link #setWorkflowTaskPollersBehavior} is set and the worker's + * namespace is configured to auto-enroll workers into poller autoscaling, the worker will + * automatically use poller autoscaling for workflow tasks instead of a fixed number of pollers. */ public Builder setMaxConcurrentWorkflowTaskPollers(int maxConcurrentWorkflowTaskPollers) { this.maxConcurrentWorkflowTaskPollers = maxConcurrentWorkflowTaskPollers; + this.workflowTaskPollersConfigured = true; return this; } @@ -241,10 +256,15 @@ public Builder setMaxConcurrentWorkflowTaskPollers(int maxConcurrentWorkflowTask * tasks from a task queue. * *

Default is 5, which is chosen if set to zero. + * + *

NOTE: If neither this nor {@link #setNexusTaskPollersBehavior} is set and the worker's + * namespace is configured to auto-enroll workers into poller autoscaling, the worker will + * automatically use poller autoscaling for nexus tasks instead of a fixed number of pollers. */ @Experimental public Builder setMaxConcurrentNexusTaskPollers(int maxConcurrentNexusTaskPollers) { this.maxConcurrentNexusTaskPollers = maxConcurrentNexusTaskPollers; + this.nexusTaskPollersConfigured = true; return this; } @@ -268,9 +288,14 @@ public Builder setWorkflowPollThreadCount(int workflowPollThreadCount) { * `MaxConcurrentActivityExecutionSize` options and still cannot keep up with the request rate. * *

Default is 5, which is chosen if set to zero. + * + *

NOTE: If neither this nor {@link #setActivityTaskPollersBehavior} is set and the worker's + * namespace is configured to auto-enroll workers into poller autoscaling, the worker will + * automatically use poller autoscaling for activity tasks instead of a fixed number of pollers. */ public Builder setMaxConcurrentActivityTaskPollers(int maxConcurrentActivityTaskPollers) { this.maxConcurrentActivityTaskPollers = maxConcurrentActivityTaskPollers; + this.activityTaskPollersConfigured = true; return this; } @@ -505,21 +530,43 @@ public Builder setDeploymentOptions(WorkerDeploymentOptions deploymentOptions) { * *

If the sticky queue is enabled, the poller behavior will be used for the sticky queue as * well. + * + *

NOTE: If neither this nor {@link #setMaxConcurrentWorkflowTaskPollers} is set and the + * worker's namespace is configured to auto-enroll workers into poller autoscaling, the worker + * will automatically use poller autoscaling for workflow tasks instead of a fixed number of + * pollers. */ public Builder setWorkflowTaskPollersBehavior(PollerBehavior pollerBehavior) { this.workflowTaskPollersBehavior = pollerBehavior; + this.workflowTaskPollersConfigured = true; return this; } - /** Set the poller behavior for activity task pollers. */ + /** + * Set the poller behavior for activity task pollers. + * + *

NOTE: If neither this nor {@link #setMaxConcurrentActivityTaskPollers} is set and the + * worker's namespace is configured to auto-enroll workers into poller autoscaling, the worker + * will automatically use poller autoscaling for activity tasks instead of a fixed number of + * pollers. + */ public Builder setActivityTaskPollersBehavior(PollerBehavior pollerBehavior) { this.activityTaskPollersBehavior = pollerBehavior; + this.activityTaskPollersConfigured = true; return this; } - /** Set the poller behavior for nexus task pollers. */ + /** + * Set the poller behavior for nexus task pollers. + * + *

NOTE: If neither this nor {@link #setMaxConcurrentNexusTaskPollers} is set and the + * worker's namespace is configured to auto-enroll workers into poller autoscaling, the worker + * will automatically use poller autoscaling for nexus tasks instead of a fixed number of + * pollers. + */ public Builder setNexusTaskPollersBehavior(PollerBehavior pollerBehavior) { this.nexusTaskPollersBehavior = pollerBehavior; + this.nexusTaskPollersConfigured = true; return this; } @@ -589,7 +636,10 @@ public WorkerOptions build() { activityTaskPollersBehavior, nexusTaskPollersBehavior, allowActivityHeartbeatDuringShutdown, - preferredVersionProvider); + preferredVersionProvider, + workflowTaskPollersConfigured, + activityTaskPollersConfigured, + nexusTaskPollersConfigured); } public WorkerOptions validateAndBuildWithDefaults() { @@ -723,7 +773,10 @@ public WorkerOptions validateAndBuildWithDefaults() { activityTaskPollersBehavior, nexusTaskPollersBehavior, allowActivityHeartbeatDuringShutdown, - preferredVersionProvider); + preferredVersionProvider, + workflowTaskPollersConfigured, + activityTaskPollersConfigured, + nexusTaskPollersConfigured); } } @@ -757,6 +810,9 @@ public WorkerOptions validateAndBuildWithDefaults() { private final PollerBehavior nexusTaskPollersBehavior; private final boolean allowActivityHeartbeatDuringShutdown; private final PreferredVersionProvider preferredVersionProvider; + private final boolean workflowTaskPollersConfigured; + private final boolean activityTaskPollersConfigured; + private final boolean nexusTaskPollersConfigured; private WorkerOptions( double maxWorkerActivitiesPerSecond, @@ -788,7 +844,10 @@ private WorkerOptions( PollerBehavior activityTaskPollersBehavior, PollerBehavior nexusTaskPollersBehavior, boolean allowActivityHeartbeatDuringShutdown, - PreferredVersionProvider preferredVersionProvider) { + PreferredVersionProvider preferredVersionProvider, + boolean workflowTaskPollersConfigured, + boolean activityTaskPollersConfigured, + boolean nexusTaskPollersConfigured) { this.maxWorkerActivitiesPerSecond = maxWorkerActivitiesPerSecond; this.maxConcurrentActivityExecutionSize = maxConcurrentActivityExecutionSize; this.maxConcurrentWorkflowTaskExecutionSize = maxConcurrentWorkflowTaskExecutionSize; @@ -819,6 +878,38 @@ private WorkerOptions( this.nexusTaskPollersBehavior = nexusTaskPollersBehavior; this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; this.preferredVersionProvider = preferredVersionProvider; + this.workflowTaskPollersConfigured = workflowTaskPollersConfigured; + this.activityTaskPollersConfigured = activityTaskPollersConfigured; + this.nexusTaskPollersConfigured = nexusTaskPollersConfigured; + } + + /** + * Whether the workflow task pollers were left at their default (the user called neither {@link + * Builder#setMaxConcurrentWorkflowTaskPollers} nor {@link + * Builder#setWorkflowTaskPollersBehavior}), which makes them eligible for poller-autoscaling + * auto-enrollment. + */ + boolean isWorkflowTaskPollerAutoEnrollEligible() { + return !workflowTaskPollersConfigured; + } + + /** + * Whether the activity task pollers were left at their default (the user called neither {@link + * Builder#setMaxConcurrentActivityTaskPollers} nor {@link + * Builder#setActivityTaskPollersBehavior}), which makes them eligible for poller-autoscaling + * auto-enrollment. + */ + boolean isActivityTaskPollerAutoEnrollEligible() { + return !activityTaskPollersConfigured; + } + + /** + * Whether the nexus task pollers were left at their default (the user called neither {@link + * Builder#setMaxConcurrentNexusTaskPollers} nor {@link Builder#setNexusTaskPollersBehavior}), + * which makes them eligible for poller-autoscaling auto-enrollment. + */ + boolean isNexusTaskPollerAutoEnrollEligible() { + return !nexusTaskPollersConfigured; } public double getMaxWorkerActivitiesPerSecond() { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/PollerAutoscalingAutoEnrollTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/PollerAutoscalingAutoEnrollTest.java new file mode 100644 index 0000000000..5d0df9cc7b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/PollerAutoscalingAutoEnrollTest.java @@ -0,0 +1,114 @@ +package io.temporal.internal.worker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import io.temporal.api.namespace.v1.NamespaceInfo.Capabilities; +import io.temporal.worker.tuning.PollerBehaviorAutoscaling; +import io.temporal.worker.tuning.PollerBehaviorSimpleMaximum; +import org.junit.Test; + +/** + * Tests for poller-autoscaling auto-enrollment: when a namespace advertises the + * PollerAutoscalingAutoEnroll capability, poller types left at their default are switched to poller + * autoscaling, while explicitly-configured poller types are left untouched. + */ +public class PollerAutoscalingAutoEnrollTest { + + private static NamespaceCapabilities capabilities(boolean autoEnroll, boolean pollerAutoscaling) { + NamespaceCapabilities caps = new NamespaceCapabilities(); + caps.setFromCapabilities( + Capabilities.newBuilder() + .setPollerAutoscalingAutoEnroll(autoEnroll) + .setPollerAutoscaling(pollerAutoscaling) + .build()); + return caps; + } + + private static PollerOptions eligibleFixedPollerOptions() { + return PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(5)) + .setAutoscalingAutoEnrollEligible(true) + .build(); + } + + @Test + public void autoEnrollAndPollerAutoscalingAreIndependent() { + // Auto-enroll drives only the enrollment decision; it does not by itself enable the separate + // pollerAutoscaling (scale-down) capability. The server advertises pollerAutoscaling on its + // own. + NamespaceCapabilities caps = capabilities(true, false); + assertTrue(caps.isPollerAutoscalingAutoEnroll()); + assertFalse(caps.isPollerAutoscaling()); + } + + @Test + public void pollerAutoscalingWithoutAutoEnrollDoesNotImplyAutoEnroll() { + NamespaceCapabilities caps = capabilities(false, true); + assertFalse(caps.isPollerAutoscalingAutoEnroll()); + assertTrue(caps.isPollerAutoscaling()); + } + + @Test + public void noCapabilitiesLeavesEverythingDisabled() { + NamespaceCapabilities caps = capabilities(false, false); + assertFalse(caps.isPollerAutoscalingAutoEnroll()); + assertFalse(caps.isPollerAutoscaling()); + } + + @Test + public void eligibleDefaultIsEnrolledWhenCapabilityAdvertised() { + PollerOptions resolved = + PollerOptions.maybeEnrollInPollerAutoscaling( + eligibleFixedPollerOptions(), capabilities(true, false)); + assertTrue( + "defaulted poller type should switch to autoscaling", + resolved.getPollerBehavior() instanceof PollerBehaviorAutoscaling); + // The enrolled behavior uses the PollerBehaviorAutoscaling defaults. + assertEquals(new PollerBehaviorAutoscaling(), resolved.getPollerBehavior()); + } + + @Test + public void eligibleDefaultIsNotEnrolledWhenCapabilityAbsent() { + PollerOptions options = eligibleFixedPollerOptions(); + PollerOptions resolved = + PollerOptions.maybeEnrollInPollerAutoscaling(options, capabilities(false, false)); + assertSame("without the capability the options are unchanged", options, resolved); + assertTrue(resolved.getPollerBehavior() instanceof PollerBehaviorSimpleMaximum); + } + + @Test + public void explicitlyConfiguredPollerIsNotEnrolled() { + // A poller type the user configured explicitly is not eligible and must not be switched, even + // when the namespace advertises auto-enroll. + PollerOptions options = + PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(3)) + .setAutoscalingAutoEnrollEligible(false) + .build(); + PollerOptions resolved = + PollerOptions.maybeEnrollInPollerAutoscaling(options, capabilities(true, false)); + assertSame("explicitly configured poller is untouched", options, resolved); + assertEquals( + 3, + ((PollerBehaviorSimpleMaximum) resolved.getPollerBehavior()).getMaxConcurrentTaskPollers()); + } + + @Test + public void alreadyAutoscalingPollerIsLeftUnchanged() { + // An eligible poller that already uses autoscaling (e.g. a user-set autoscaling behavior on a + // type otherwise treated as eligible) is returned unchanged rather than rebuilt. + PollerBehaviorAutoscaling behavior = new PollerBehaviorAutoscaling(2, 20, 4); + PollerOptions options = + PollerOptions.newBuilder() + .setPollerBehavior(behavior) + .setAutoscalingAutoEnrollEligible(true) + .build(); + PollerOptions resolved = + PollerOptions.maybeEnrollInPollerAutoscaling(options, capabilities(true, false)); + assertSame(options, resolved); + assertSame(behavior, resolved.getPollerBehavior()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java new file mode 100644 index 0000000000..46ae4d37de --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java @@ -0,0 +1,185 @@ +package io.temporal.worker; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.uber.m3.tally.NoopScope; +import com.uber.m3.tally.Scope; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.internal.sync.WorkflowThreadExecutor; +import io.temporal.internal.worker.NamespaceCapabilities; +import io.temporal.internal.worker.WorkflowExecutorCache; +import io.temporal.internal.worker.WorkflowRunLockManager; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.tuning.PollerBehaviorSimpleMaximum; +import java.util.Collections; +import org.junit.Test; + +/** + * Verifies that {@link Worker} derives per-poller-type auto-enroll eligibility from whether the + * user called a poller setter (tracked on {@link WorkerOptions.Builder}) and threads it into each + * internal worker's poller options. + * + *

This guards the subtlest part of poller-autoscaling auto-enrollment: eligibility must reflect + * the user's intent, not the resolved value. Options from {@code getDefaultInstance()} or {@code + * validateAndBuildWithDefaults()} carry the numeric default poller count (5) yet must stay + * eligible, while an explicit count of 5 must not. Value-based inference cannot tell these apart. + */ +public class WorkerPollerAutoEnrollEligibilityTest { + + private Worker buildWorker(WorkerOptions options) { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(service.blockingStub()).thenReturn(blockingStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + + WorkflowClient client = mock(WorkflowClient.class); + when(client.getWorkflowServiceStubs()).thenReturn(service); + when(client.getOptions()) + .thenReturn( + WorkflowClientOptions.newBuilder() + .setNamespace("test-ns") + .setIdentity("test-worker") + .validateAndBuildWithDefaults()); + + Scope metricsScope = new NoopScope(); + WorkflowRunLockManager runLocks = new WorkflowRunLockManager(); + WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLocks, metricsScope); + WorkflowThreadExecutor wfThreadExecutor = mock(WorkflowThreadExecutor.class); + + return new Worker( + client, + "test-task-queue", + WorkerFactoryOptions.newBuilder().build(), + options, + metricsScope, + runLocks, + cache, + true, + wfThreadExecutor, + Collections.emptyList(), + Collections.emptyList(), + "test-worker-group", + new NamespaceCapabilities()); + } + + private boolean workflowEligible(Worker worker) { + return worker.workflowWorker.getWorkflowPollerOptions().isAutoscalingAutoEnrollEligible(); + } + + private boolean activityEligible(Worker worker) { + return worker.activityWorker.getPollerOptions().isAutoscalingAutoEnrollEligible(); + } + + private boolean nexusEligible(Worker worker) { + return worker.nexusWorker.getPollerOptions().isAutoscalingAutoEnrollEligible(); + } + + @Test + public void defaultOptionsMakeEveryPollerTypeEligible() { + Worker worker = buildWorker(WorkerOptions.newBuilder().build()); + assertTrue(workflowEligible(worker)); + assertTrue(activityEligible(worker)); + assertTrue(nexusEligible(worker)); + } + + @Test + public void nullOptionsMakeEveryPollerTypeEligible() { + // WorkerFactory.newWorker(taskQueue) passes null options through to the Worker constructor. + Worker worker = buildWorker(null); + assertTrue(workflowEligible(worker)); + assertTrue(activityEligible(worker)); + assertTrue(nexusEligible(worker)); + } + + @Test + public void explicitMaxConcurrentPollersMakeAllTypesIneligible() { + Worker worker = + buildWorker( + WorkerOptions.newBuilder() + .setMaxConcurrentWorkflowTaskPollers(4) + .setMaxConcurrentActivityTaskPollers(3) + .setMaxConcurrentNexusTaskPollers(2) + .build()); + assertFalse(workflowEligible(worker)); + assertFalse(activityEligible(worker)); + assertFalse(nexusEligible(worker)); + } + + @Test + public void explicitMaxConcurrentPollersOnOneTypeLeavesOthersEligible() { + Worker worker = + buildWorker(WorkerOptions.newBuilder().setMaxConcurrentWorkflowTaskPollers(4).build()); + assertFalse(workflowEligible(worker)); + assertTrue(activityEligible(worker)); + assertTrue(nexusEligible(worker)); + } + + @Test + public void defaultInstanceMakesEveryPollerTypeEligible() { + // getDefaultInstance() carries the numeric default poller count (5) but no setter was called, + // so all types remain eligible. + Worker worker = buildWorker(WorkerOptions.getDefaultInstance()); + assertTrue(workflowEligible(worker)); + assertTrue(activityEligible(worker)); + assertTrue(nexusEligible(worker)); + } + + @Test + public void validateAndBuildWithDefaultsMakesEveryPollerTypeEligible() { + Worker worker = buildWorker(WorkerOptions.newBuilder().validateAndBuildWithDefaults()); + assertTrue(workflowEligible(worker)); + assertTrue(activityEligible(worker)); + assertTrue(nexusEligible(worker)); + } + + @Test + public void copyOfDefaultInstanceIsEligible() { + // Provenance must survive newBuilder(options), for both build paths. + Worker fromBuild = + buildWorker(WorkerOptions.newBuilder(WorkerOptions.getDefaultInstance()).build()); + assertTrue(workflowEligible(fromBuild)); + assertTrue(activityEligible(fromBuild)); + assertTrue(nexusEligible(fromBuild)); + + Worker fromValidate = + buildWorker( + WorkerOptions.newBuilder(WorkerOptions.getDefaultInstance()) + .validateAndBuildWithDefaults()); + assertTrue(workflowEligible(fromValidate)); + assertTrue(activityEligible(fromValidate)); + assertTrue(nexusEligible(fromValidate)); + } + + @Test + public void explicitCountEqualToNumericDefaultIsIneligible() { + // Explicitly setting the count to its numeric default (5) still counts as "configured". + Worker worker = + buildWorker(WorkerOptions.newBuilder().setMaxConcurrentWorkflowTaskPollers(5).build()); + assertFalse(workflowEligible(worker)); + assertTrue(activityEligible(worker)); + assertTrue(nexusEligible(worker)); + } + + @Test + public void explicitPollerBehaviorMakesOnlyThatTypeIneligible() { + Worker worker = + buildWorker( + WorkerOptions.newBuilder() + .setActivityTaskPollersBehavior(new PollerBehaviorSimpleMaximum(3)) + .build()); + // Only the activity poller was configured explicitly; workflow and nexus stay eligible. + assertFalse(activityEligible(worker)); + assertTrue(workflowEligible(worker)); + assertTrue(nexusEligible(worker)); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java new file mode 100644 index 0000000000..00ea0d69be --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java @@ -0,0 +1,157 @@ +package io.temporal.worker; + +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.SettableFuture; +import com.uber.m3.tally.NoopScope; +import com.uber.m3.tally.Scope; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.namespace.v1.NamespaceInfo.Capabilities; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.api.workflowservice.v1.ShutdownWorkerRequest; +import io.temporal.api.workflowservice.v1.ShutdownWorkerResponse; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.internal.sync.WorkflowThreadExecutor; +import io.temporal.internal.worker.NamespaceCapabilities; +import io.temporal.internal.worker.ShutdownManager; +import io.temporal.internal.worker.WorkflowExecutorCache; +import io.temporal.internal.worker.WorkflowRunLockManager; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.tuning.PollerBehaviorAutoscaling; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.TestNexusServices; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import org.junit.Test; + +/** + * Full start-path wiring test for poller-autoscaling auto-enrollment: when the namespace advertises + * the capability, starting a worker with default options switches the workflow, activity, and nexus + * pollers' effective behavior to {@link PollerBehaviorAutoscaling}. + */ +public class WorkerPollerAutoEnrollStartupTest { + + @WorkflowInterface + public interface DemoWorkflow { + @WorkflowMethod + void run(); + } + + public static class DemoWorkflowImpl implements DemoWorkflow { + @Override + public void run() {} + } + + @ActivityInterface + public interface DemoActivity { + @ActivityMethod + void doThing(); + } + + public static class DemoActivityImpl implements DemoActivity { + @Override + public void doThing() {} + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class DemoNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync((ctx, details, name) -> "Hello " + name); + } + } + + @Test + public void autoEnrollAtStartupSwitchesPollersToAutoscaling() throws Exception { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + + // Async pollers poll via futureStub().withOption(...).pollXxxTaskQueue(...). Return futures + // that + // never complete so the started poller threads park harmlessly until shutdown cancels them. + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + when(service.futureStub()).thenReturn(futureStub); + when(futureStub.withOption(any(), any())).thenReturn(futureStub); + when(futureStub.pollWorkflowTaskQueue(any())).thenReturn(SettableFuture.create()); + when(futureStub.pollActivityTaskQueue(any())).thenReturn(SettableFuture.create()); + when(futureStub.pollNexusTaskQueue(any())).thenReturn(SettableFuture.create()); + when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) + .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); + + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(service.blockingStub()).thenReturn(blockingStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + + WorkflowClient client = mock(WorkflowClient.class); + when(client.getWorkflowServiceStubs()).thenReturn(service); + when(client.getOptions()) + .thenReturn( + WorkflowClientOptions.newBuilder() + .setNamespace("test-ns") + .setIdentity("test-worker") + .validateAndBuildWithDefaults()); + + // Namespace advertises the auto-enroll capability. + NamespaceCapabilities capabilities = new NamespaceCapabilities(); + capabilities.setFromCapabilities( + Capabilities.newBuilder().setPollerAutoscalingAutoEnroll(true).build()); + + Scope metricsScope = new NoopScope(); + WorkflowRunLockManager runLocks = new WorkflowRunLockManager(); + WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLocks, metricsScope); + WorkflowThreadExecutor wfThreadExecutor = mock(WorkflowThreadExecutor.class); + + Worker worker = + new Worker( + client, + "test-task-queue", + WorkerFactoryOptions.newBuilder().build(), + WorkerOptions.newBuilder().build(), + metricsScope, + runLocks, + cache, + true, + wfThreadExecutor, + Collections.emptyList(), + Collections.emptyList(), + "test-worker-group", + capabilities); + + // Register all three task types so each worker starts its poller. + worker.registerWorkflowImplementationTypes(DemoWorkflowImpl.class); + worker.registerActivitiesImplementations(new DemoActivityImpl()); + worker.registerNexusServiceImplementation(new DemoNexusServiceImpl()); + + worker.start(); + try { + assertTrue( + "workflow pollers should be autoscaling", + worker.workflowWorker.getWorkflowPollerOptions().getPollerBehavior() + instanceof PollerBehaviorAutoscaling); + assertTrue( + "activity pollers should be autoscaling", + worker.activityWorker.getPollerOptions().getPollerBehavior() + instanceof PollerBehaviorAutoscaling); + assertTrue( + "nexus pollers should be autoscaling", + worker.nexusWorker.getPollerOptions().getPollerBehavior() + instanceof PollerBehaviorAutoscaling); + } finally { + worker.shutdown(new ShutdownManager(), true).get(5, TimeUnit.SECONDS); + } + } +} diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index d2fc34ab84..852ee3b339 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit d2fc34ab844603f50e41365f46c7fb82bdedffe6 +Subproject commit 852ee3b339f9f1efe3503fe96f0351d361396daa From 26ca1a4af8e40d561dfc5bdf22c34c7810a93e03 Mon Sep 17 00:00:00 2001 From: Tim Conley Date: Wed, 22 Jul 2026 15:03:46 -0700 Subject: [PATCH 040/107] docs: update contributing guide (#2948) --- CONTRIBUTING.md | 153 +++++++++++++++++++++++++++++------------------- README.md | 88 ++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 61 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f6e107687..fcc672866f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,91 +1,122 @@ -# Developing sdk-java +# Contributing to Temporal SDKs -This doc is intended for contributors to `sdk-java` (hopefully that's you!) +Thanks for your interest in contributing to Temporal SDKs. -**Note:** All contributors also need to fill out the -[Temporal Contributor License Agreement](https://gist.github.com/samarabbas/7dcd41eb1d847e12263cc961ccfdb197) -before we can merge in any of your changes +This guide describes expectations that apply across Temporal SDK repositories. Each +repository may have additional local conventions, but the guidance below should help +you open issues and pull requests that maintainers can evaluate efficiently. -## Development Environment +## Before You Open an Issue -- **Java 21+** is required to run Gradle, compile the project, and run all tests locally. -- Some optional tests also require the [Temporal CLI](https://docs.temporal.io/cli#installation). +Search the existing issues first. If you find an issue that describes the same bug, +feature request, or design topic, add any relevant details there instead of opening a +duplicate. Use an upvote on the issue to show that it affects you too. -If you're using Apple Silicon, see the [note on Rosetta](#note-on-rosetta). +Issues are assigned to people when they are actively working on them. Before taking +on an issue, check whether it is already assigned so you do not duplicate someone +else's work. +Use GitHub issues for actionable bugs and feature work. For usage questions, help +debugging an application, or general discussion, join the relevant +language-specific channel in the +[Temporal community Slack](https://temporal.io/slack) or use the support channel +available to you. -## Build +## Bug Reports -``` -./gradlew clean build -``` +When reporting a bug, include enough detail for someone else to reproduce or +understand the problem: -## Code Formatting +* A short summary of the problem. +* A minimal reproduction, preferably as code that can be copied into a small + project or test. +* What you expected to happen and what actually happened. +* The SDK version. +* The language runtime version. +* The operating system and architecture. +* Temporal Server or Temporal Cloud details, if the issue depends on service + behavior. +* Logs, stack traces, workflow histories, or other diagnostics that show the + failure. +* Whether the behavior is a regression, and the last version where it worked if + known. -Code autoformatting is applied automatically during a full gradle build. Build the project before submitting a PR. -Code is formatted using `spotless` plugin with `google-java-format` tool. +## Feature Requests and Design Changes -## Commit Messages +Open or join a GitHub issue before starting substantial feature work, behavior +changes, or API design changes. This gives maintainers and other SDK users a chance +to discuss the approach before you invest in a larger implementation. -Overcommit adds some requirements to your commit messages. We follow the -[Chris Beams](http://chris.beams.io/posts/git-commit/) guide to writing git -commit messages. Read it, follow it, learn it, love it. +The relevant language-specific channel in Temporal community Slack is also a good +place for early discussion, but important decisions should still be captured in a +GitHub issue so they are visible and searchable. -## Running features tests in CI +Small bug fixes, documentation fixes, and narrowly scoped maintenance changes can go +straight to a pull request. -For each PR we run the java tests from the [features repo](https://github.com/temporalio/features/). This requires -your branch to have tags. Without tags, the features tests in CI will fail with a message like +## Pull Requests -``` -> Configure project :sdk-java -fatal: No names found, cannot describe anything. -``` +Good pull requests are focused and easy to review: -This can be done resolved by running `git fetch --tags` on your branch. Note, make sure your fork has tags copied from -the main repo. +* Keep each pull request scoped to one logical change. +* Include tests for behavior changes. +* Update public API documentation or doc comments when public behavior changes. +* Add a high-level changelog entry for user-facing changes according to the + repository's local changelog convention. +* Describe what changed, why it changed, and what validation you ran. -## Testing +Run the relevant local checks when practical. CI must pass before a pull request can +be merged. -Run tests: +## Things to Avoid -```bash -./gradlew test -``` +Avoid changes that make review harder without improving the contribution: -Run a single test or group of tests: +* Unrelated refactors mixed into a behavior change. +* Style-only churn. +* Large feature pull requests that were not discussed first. +* License, copyright, or other legal changes without maintainer discussion. -```bash -./gradlew :temporal-sdk:test --offline --tests "io.temporal.activity.ActivityPauseTest" -./gradlew :temporal-sdk:test --offline --tests "io.temporal.workflow.*" -``` +## AI-Generated Contributions -By default, integration tests run against the built-in time-skipping test server. Some tests require features that the built-in server doesn't support; those tests will be skipped. To run the skipped tests: +Using AI tools while contributing is acceptable. You are responsible for the +correctness, quality, and maintainability of everything you submit. -1. Install the [temporal CLI](https://docs.temporal.io/cli#installation), which comes with a built-in dev server. -2. Find the flags that the dev server will need to run the tests by grepping for `temporal server` in [./github/workflows/ci.yml](./github/workflows/ci.yml). -3. Start the server: -```bash -temporal server start-dev --YOUR-FLAGS-HERE -``` -4. Set the `USE_EXTERNAL_SERVICE` environment variable and run the tests: -```bash -USE_EXTERNAL_SERVICE=true ./gradlew test -``` +Thoroughly self-review AI-generated code and documentation before opening a pull +request. Make sure it is correct, tested where appropriate, and consistent with the +style and patterns of the codebase. -## Note on Rosetta +Keep AI-assisted changes concise and scoped. Avoid verbose generated prose, +unnecessary comments, or broad rewrites that make the change harder to review. -Newer Apple Silicon macs do not ship with Rosetta by default, and the version of `protoc-gen-rpc-java` we use (1.34.1) does not ship Apple Silicon binaries. +## Contributor License Agreement -So Gradle is set to hardcode the download of the x86_64 binaries on MacOS, but this depends on Rosetta to function. Make sure Rosetta is installed with +All contributors must complete the Temporal Contributor License Agreement (CLA) +before changes can be merged. A link to the CLA will be posted in the pull request. -```bash -/usr/bin/pgrep oahd -``` +## Security Issues -which should return a PID of the Rosetta process. If it doesn't, you'll need to run +Do not open public GitHub issues for suspected security vulnerabilities. Report them +to security@temporal.io instead. -```bash -softwareupdate --install-rosetta -``` +## Review and CI -for builds to complete successfully. +Maintainers review pull requests for correctness, compatibility, test coverage, +documentation, and long-term maintainability. Review may require changes before a +pull request can be merged, and it may take maintainers some time to review a +contribution. + +CI is the final validation gate. If CI fails, update the pull request or ask for help +if the failure appears unrelated to your change. Some CI gates may wait for a +maintainer to approve or run them. + +## Inactive Pull Requests + +Maintainers may close inactive pull requests after follow-up if they are no longer +moving forward. If that happens, you are welcome to reopen the pull request or open a +new one when you are ready to continue. + +## Community Conduct + +Keep discussions respectful, constructive, and focused on the work. Clear context, +specific examples, and patience with review feedback help everyone move faster. diff --git a/README.md b/README.md index a1ee72ce12..65388903db 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,94 @@ If you cannot use protobuf-java 3.25 >=, you can try `temporal-shaded` which inc We'd love your help in improving the Temporal Java SDK. Please review our [contribution guidelines](CONTRIBUTING.md). +## Development + +### Development Environment + +- **Java 21+** is required to run Gradle, compile the project, and run all tests locally. +- Some optional tests also require the [Temporal CLI](https://docs.temporal.io/cli#installation). + +If you're using Apple Silicon, see the [note on Rosetta](#note-on-rosetta). + +### Build + +```bash +./gradlew clean build +``` + +### Code Formatting + +Code autoformatting is applied automatically during a full Gradle build. Build the project before submitting a PR. +Code is formatted using the `spotless` plugin with the `google-java-format` tool. + +### Commit Messages + +Overcommit adds some requirements to your commit messages. We follow the +[Chris Beams](http://chris.beams.io/posts/git-commit/) guide to writing git +commit messages. Read it, follow it, learn it, love it. + +### Running features tests in CI + +For each PR we run the Java tests from the [features repo](https://github.com/temporalio/features/). This requires +your branch to have tags. Without tags, the features tests in CI will fail with a message like: + +```text +> Configure project :sdk-java +fatal: No names found, cannot describe anything. +``` + +This can be resolved by running `git fetch --tags` on your branch. Make sure your fork has tags copied from +the main repo. + +### Testing + +Run tests: + +```bash +./gradlew test +``` + +Run a single test or group of tests: + +```bash +./gradlew :temporal-sdk:test --offline --tests "io.temporal.activity.ActivityPauseTest" +./gradlew :temporal-sdk:test --offline --tests "io.temporal.workflow.*" +``` + +By default, integration tests run against the built-in time-skipping test server. Some tests require features that the built-in server doesn't support; those tests will be skipped. To run the skipped tests: + +1. Install the [Temporal CLI](https://docs.temporal.io/cli#installation), which comes with a built-in dev server. +2. Find the flags that the dev server will need to run the tests by grepping for `temporal server` in [.github/workflows/ci.yml](.github/workflows/ci.yml). +3. Start the server: + +```bash +temporal server start-dev --YOUR-FLAGS-HERE +``` + +4. Set the `USE_EXTERNAL_SERVICE` environment variable and run the tests: + +```bash +USE_EXTERNAL_SERVICE=true ./gradlew test +``` + +### Note on Rosetta + +Newer Apple Silicon Macs do not ship with Rosetta by default, and the version of `protoc-gen-rpc-java` we use (1.34.1) does not ship Apple Silicon binaries. + +Gradle is set to hardcode the download of the x86_64 binaries on macOS, but this depends on Rosetta to function. Make sure Rosetta is installed with: + +```bash +/usr/bin/pgrep oahd +``` + +which should return a PID of the Rosetta process. If it doesn't, you'll need to run: + +```bash +softwareupdate --install-rosetta +``` + +for builds to complete successfully. + ## Snapshot release We also publish snapshot releases during SDK development often under the version `1.x.0-SNAPSHOT` where `x` is the next minor release. This allows users to test out new SDK features before an official SDK release. From b0f197e746a40aed1370d4a1e1a4474cfe4825af Mon Sep 17 00:00:00 2001 From: Kent Gruber Date: Thu, 23 Jul 2026 11:37:46 -0400 Subject: [PATCH 041/107] VLN-1609: fix checkout-below-v7 (#2933) Co-authored-by: picatz <14850816+picatz@users.noreply.github.com> --- .github/workflows/build-native-image.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/coverage.yml | 2 +- .github/workflows/gradle-wrapper-validation.yml | 2 +- .github/workflows/prepare-release.yml | 4 ++-- .github/workflows/publish-snapshot.yml | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-native-image.yml b/.github/workflows/build-native-image.yml index f928951d83..33515cde6c 100644 --- a/.github/workflows/build-native-image.yml +++ b/.github/workflows/build-native-image.yml @@ -62,7 +62,7 @@ jobs: runs-on: ${{ matrix.runner }} steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 598d8d02ef..e473036051 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -65,7 +65,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -161,7 +161,7 @@ jobs: timeout-minutes: 30 steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -200,7 +200,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive @@ -224,7 +224,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 submodules: recursive diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 8270d45496..ee48ae8188 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest-16-cores steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 diff --git a/.github/workflows/gradle-wrapper-validation.yml b/.github/workflows/gradle-wrapper-validation.yml index 07d8b1a78b..52e117af88 100644 --- a/.github/workflows/gradle-wrapper-validation.yml +++ b/.github/workflows/gradle-wrapper-validation.yml @@ -9,5 +9,5 @@ jobs: name: "Gradle wrapper validation" runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: gradle/actions/wrapper-validation@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 585f162499..b43ce62c88 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -54,7 +54,7 @@ jobs: - name: Checkout repo if: steps.check_release.outputs.already_exists == 'false' - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ env.INPUT_REF }} @@ -78,7 +78,7 @@ jobs: needs: create_draft_release steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: ref: ${{ env.INPUT_REF }} diff --git a/.github/workflows/publish-snapshot.yml b/.github/workflows/publish-snapshot.yml index c511246862..8628e4b274 100644 --- a/.github/workflows/publish-snapshot.yml +++ b/.github/workflows/publish-snapshot.yml @@ -30,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repo - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 From 4804646f87ca5fb5e9af4d4676592cc77d7118fc Mon Sep 17 00:00:00 2001 From: geraldw-ai Date: Thu, 23 Jul 2026 11:17:09 -0700 Subject: [PATCH 042/107] Fix boolean returned by isUsingVirtualThreadsOnWorkflowWorker (#2957) Co-authored-by: Dan Plyukhin --- .../io/temporal/worker/WorkerOptions.java | 2 +- .../io/temporal/worker/WorkerOptionsTest.java | 40 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index 99a596514b..3da57ef81c 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -1011,7 +1011,7 @@ public String getIdentity() { } public boolean isUsingVirtualThreadsOnWorkflowWorker() { - return usingVirtualThreadsOnActivityWorker; + return usingVirtualThreadsOnWorkflowWorker; } public boolean isUsingVirtualThreadsOnActivityWorker() { diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java index 877f6fdce8..f21345a30f 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java @@ -181,6 +181,46 @@ public void validateAndBuildWithDefaultsIsIdempotentWithPollerBehavior() { assertNotNull(second.getWorkflowTaskPollersBehavior()); } + @Test + public void setUsingVirtualThreadsEnablesAllWorkers() { + WorkerOptions options = WorkerOptions.newBuilder().setUsingVirtualThreads(true).build(); + assertTrue(options.isUsingVirtualThreadsOnWorkflowWorker()); + assertTrue(options.isUsingVirtualThreadsOnActivityWorker()); + assertTrue(options.isUsingVirtualThreadsOnLocalActivityWorker()); + assertTrue(options.isUsingVirtualThreadsOnNexusWorker()); + } + + @Test + public void perWorkerVirtualThreadOptionsAreIndependent() { + WorkerOptions workflowOnly = + WorkerOptions.newBuilder().setUsingVirtualThreadsOnWorkflowWorker(true).build(); + assertTrue(workflowOnly.isUsingVirtualThreadsOnWorkflowWorker()); + assertFalse(workflowOnly.isUsingVirtualThreadsOnActivityWorker()); + assertFalse(workflowOnly.isUsingVirtualThreadsOnLocalActivityWorker()); + assertFalse(workflowOnly.isUsingVirtualThreadsOnNexusWorker()); + + WorkerOptions activityOnly = + WorkerOptions.newBuilder().setUsingVirtualThreadsOnActivityWorker(true).build(); + assertFalse(activityOnly.isUsingVirtualThreadsOnWorkflowWorker()); + assertTrue(activityOnly.isUsingVirtualThreadsOnActivityWorker()); + assertFalse(activityOnly.isUsingVirtualThreadsOnLocalActivityWorker()); + assertFalse(activityOnly.isUsingVirtualThreadsOnNexusWorker()); + + WorkerOptions localActivityOnly = + WorkerOptions.newBuilder().setUsingVirtualThreadsOnLocalActivityWorker(true).build(); + assertFalse(localActivityOnly.isUsingVirtualThreadsOnWorkflowWorker()); + assertFalse(localActivityOnly.isUsingVirtualThreadsOnActivityWorker()); + assertTrue(localActivityOnly.isUsingVirtualThreadsOnLocalActivityWorker()); + assertFalse(localActivityOnly.isUsingVirtualThreadsOnNexusWorker()); + + WorkerOptions nexusOnly = + WorkerOptions.newBuilder().setUsingVirtualThreadsOnNexusWorker(true).build(); + assertFalse(nexusOnly.isUsingVirtualThreadsOnWorkflowWorker()); + assertFalse(nexusOnly.isUsingVirtualThreadsOnActivityWorker()); + assertFalse(nexusOnly.isUsingVirtualThreadsOnLocalActivityWorker()); + assertTrue(nexusOnly.isUsingVirtualThreadsOnNexusWorker()); + } + @Test public void verifyMaxTaskQueuePerSecondsDisablesEagerExecution() { // Verify that by default eager execution is enabled From e8da68baf181ed5dfdee98b8ec1537d117ad00d3 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:12:32 -0700 Subject: [PATCH 043/107] Integrate Temporal API 1.63.4 (#2969) --- temporal-serviceclient/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index 852ee3b339..f53963d448 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit 852ee3b339f9f1efe3503fe96f0351d361396daa +Subproject commit f53963d4489c8a73aa30bd7091fe758f9896c08c From 4c0e493f4d29050c571028d11104f12bc01fbc34 Mon Sep 17 00:00:00 2001 From: Dan Plyukhin Date: Fri, 24 Jul 2026 15:14:38 -0400 Subject: [PATCH 044/107] Bump jacoco and simplify code coverage CI (#2963) --- .github/workflows/coverage.yml | 11 ++--------- gradle/jacoco.gradle | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ee48ae8188..dbdcdbd69d 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -25,14 +25,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - - name: Run Tests - run: ./gradlew test -x spotlessCheck -x spotlessApply -Pjacoco -PtestJavaVersion=23 - continue-on-error: true - - - name: Run Test Coverage - run: ./gradlew testCodeCoverageReport -Pjacoco - - - name: Publish Coverage + - name: Run and Publish Test Coverage env: COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} - run: ./gradlew coverallsJacoco -Pjacoco + run: ./gradlew coverallsJacoco -x spotlessCheck -x spotlessApply -Pjacoco -PtestJavaVersion=23 diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 43bf9075a8..6f71d5e4df 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -3,6 +3,12 @@ apply plugin: 'jacoco-report-aggregation' apply plugin: 'com.github.nbaztec.coveralls-jacoco' +def jacocoToolVersion = "0.8.15" + +jacoco { + toolVersion = jacocoToolVersion +} + dependencies { jacocoAggregation project(':temporal-kotlin') jacocoAggregation project(':temporal-opentracing') @@ -39,7 +45,11 @@ testCodeCoverageReport { } getClassDirectories().setFrom(files( - jacocoSubprojects.collect {it.fileTree(dir: "${it.buildDir}/classes", exclude: jacocoExclusions)} + jacocoSubprojects.collect { + it.sourceSets.main.output.classesDirs.asFileTree.matching { + exclude jacocoExclusions + } + } )) } @@ -57,7 +67,7 @@ subprojects { apply plugin: 'jacoco' jacoco { - toolVersion = "0.8.9" + toolVersion = jacocoToolVersion } jacocoTestReport { From fd8b29a2fe0c74eb7837e246c53a97346fddc9c8 Mon Sep 17 00:00:00 2001 From: Dan Plyukhin Date: Fri, 24 Jul 2026 15:15:46 -0400 Subject: [PATCH 045/107] Update PotentialDeadlockException to account for scheduling delay (#2964) * Add tests to reproduce thread starvation * Update docs for deadlock detection timeout --- .../internal/sync/DeterministicRunner.java | 4 +-- .../sync/PotentialDeadlockException.java | 15 ++++++++ .../internal/sync/WorkflowThread.java | 4 +-- .../internal/sync/WorkflowThreadContext.java | 17 +++------ .../io/temporal/worker/WorkerOptions.java | 6 ++-- .../sync/DeterministicRunnerTest.java | 35 +++++++++++++++++++ 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/DeterministicRunner.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/DeterministicRunner.java index efafe230af..cebe4babd7 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/DeterministicRunner.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/DeterministicRunner.java @@ -52,8 +52,8 @@ static DeterministicRunner newRunner( * completed or blocked. * * @throws Throwable if one of the threads didn't handle an exception. - * @param deadlockDetectionTimeout the maximum time in milliseconds a thread can run without - * calling yield. + * @param deadlockDetectionTimeout the maximum time in milliseconds after a thread is scheduled + * until it yields or completes. */ void runUntilAllBlocked(long deadlockDetectionTimeout); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java index 4f907783b0..2e952d504b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/PotentialDeadlockException.java @@ -43,6 +43,21 @@ public class PotentialDeadlockException extends RuntimeException { this.detectionTimestamp = detectionTimestamp; } + /** + * @param workflowThreadContext context of the thread that is in a potential deadlock state + * @param detectionTimestamp a timestamp the deadlock was detected + * @param timeoutMillis configured deadlock detection timeout in milliseconds + */ + PotentialDeadlockException( + WorkflowThreadContext workflowThreadContext, long detectionTimestamp, long timeoutMillis) { + super( + "[TMPRL1101] Potential deadlock detected. Workflow thread could not start executing within " + + formatTimeout(timeoutMillis) + + "."); + this.workflowThreadContext = workflowThreadContext; + this.detectionTimestamp = detectionTimestamp; + } + private static String formatTimeout(long timeoutMillis) { if (timeoutMillis % 1000 == 0) { return timeoutMillis / 1000 + "s"; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThread.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThread.java index fecd98e74f..c79318d559 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThread.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThread.java @@ -65,8 +65,8 @@ static WorkflowThread newThread(Runnable runnable, boolean detached, String name SyncWorkflowContext getWorkflowContext(); /** - * @param deadlockDetectionTimeoutMs maximum time in milliseconds the thread can run before - * calling yield. + * @param deadlockDetectionTimeoutMs the maximum time in milliseconds after a thread is scheduled + * until it yields or completes. * @return true if coroutine made some progress. */ boolean runUntilBlocked(long deadlockDetectionTimeoutMs); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java index 49dd9f9468..446db368c9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowThreadContext.java @@ -10,12 +10,8 @@ import java.util.concurrent.locks.Lock; import java.util.function.Supplier; import javax.annotation.Nullable; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; class WorkflowThreadContext { - private static final Logger log = LoggerFactory.getLogger(WorkflowThreadContext.class); - // Shared runner lock private final Lock runnerLock; private final WorkflowThreadScheduler scheduler; @@ -214,8 +210,8 @@ public String getYieldReason() { } /** - * @param deadlockDetectionTimeoutMs maximum time in milliseconds the thread can run before - * calling yield. Discarded if {@code TEMPORAL_DEBUG} env variable is set. + * @param deadlockDetectionTimeoutMs the maximum time in milliseconds after a thread is scheduled + * until it yields or completes. Discarded if {@code TEMPORAL_DEBUG} env variable is set. * @return true if thread made some progress. Which is await was unblocked and some code after it * * was executed. */ @@ -241,13 +237,10 @@ public boolean runUntilBlocked(long deadlockDetectionTimeoutMs) { throw new PotentialDeadlockException( currentThread.getName(), this, detectionTimestamp, deadlockDetectionTimeoutMs); } else { - // This should never happen. - // We clear currentThread only after setting the status to DONE. - // And we check for it by the status condition check after waking up on the condition - // and acquiring the lock back - log.warn("Illegal State: WorkflowThreadContext has no currentThread in {} state", status); + // We clear currentThread only after setting the status to DONE, so this case should + // only happen if the WorkflowThread is starving. throw new PotentialDeadlockException( - "UnknownThread", this, detectionTimestamp, deadlockDetectionTimeoutMs); + this, detectionTimestamp, deadlockDetectionTimeoutMs); } } Preconditions.checkState( diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index 3da57ef81c..aa61c6fa4c 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -327,9 +327,9 @@ public Builder setLocalActivityWorkerOnly(boolean localActivityWorkerOnly) { /** * @param defaultDeadlockDetectionTimeoutMs time period in ms that will be used to detect * workflows deadlock. Default is 1000ms, which is chosen if set to zero. - *

Specifies an amount of time in milliseconds that workflow tasks are allowed to execute - * without interruption. If workflow task runs longer than specified interval without - * yielding (like calling an Activity), it will fail automatically. + *

Specifies a time interval in milliseconds within which a workflow task must yield + * (like calling an Activity) or complete. If a workflow task runs longer than the specified + * interval or takes too long to begin running, it will fail automatically. * @return {@code this} * @see io.temporal.internal.sync.PotentialDeadlockException */ diff --git a/temporal-sdk/src/test/java/io/temporal/internal/sync/DeterministicRunnerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/sync/DeterministicRunnerTest.java index ba3a0eb332..a2d9cadc66 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/sync/DeterministicRunnerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/sync/DeterministicRunnerTest.java @@ -34,7 +34,9 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -820,6 +822,39 @@ public void testRejectedExecutionError() { } } + @Test + public void testThreadStarvationBeforeWorkflowThreadStarts() throws InterruptedException { + ExecutorService executor = Executors.newSingleThreadExecutor(); + CountDownLatch executorThreadOccupied = new CountDownLatch(1); + CountDownLatch releaseExecutorThread = new CountDownLatch(1); + executor.submit( + () -> { + executorThreadOccupied.countDown(); + releaseExecutorThread.await(); + return null; + }); + executorThreadOccupied.await(); + + DeterministicRunner runner = + new DeterministicRunnerImpl( + executor::submit, + DummySyncWorkflowContext.newDummySyncWorkflowContext(), + () -> fail("workflow code should not start while the executor thread is occupied")); + + try { + // Try starting the root workflow thread, but it won't run because the executor is tied up. + // We should detect this and throw a PotentialDeadlockException. + PotentialDeadlockException e = + Assert.assertThrows( + PotentialDeadlockException.class, () -> runner.runUntilAllBlocked(10)); + assertTrue(e.getMessage().contains("could not start executing within 10ms")); + } finally { + executor.shutdownNow(); + releaseExecutorThread.countDown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + } + } + @Test public void testCloseBlockedUntilDone() throws InterruptedException { final int THREAD_SLEEP_MS = 2000; From f68c9bc714c93b3ff8c4c7135e58089811ecfaec Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 24 Jul 2026 16:20:35 -0700 Subject: [PATCH 046/107] Make eager activity reservation limit configurable (#2970) --- .../worker/EagerActivitySlotsReservation.java | 8 +- .../internal/worker/SyncWorkflowWorker.java | 2 + .../internal/worker/WorkflowWorker.java | 6 +- .../main/java/io/temporal/worker/Worker.java | 1 + .../io/temporal/worker/WorkerOptions.java | 36 ++++++++ .../EagerActivitySlotsReservationTest.java | 60 +++++++++++++ .../internal/worker/WorkflowWorkerTest.java | 3 + .../io/temporal/worker/WorkerOptionsTest.java | 22 +++++ .../EagerActivityDispatchingTest.java | 87 ++++++++++++++++++- 9 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/EagerActivitySlotsReservationTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/EagerActivitySlotsReservation.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/EagerActivitySlotsReservation.java index 29c50bb47c..9f84db4886 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/EagerActivitySlotsReservation.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/EagerActivitySlotsReservation.java @@ -7,7 +7,6 @@ import io.temporal.api.workflowservice.v1.PollActivityTaskQueueResponse; import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedResponse; -import io.temporal.internal.Config; import io.temporal.worker.tuning.SlotPermit; import java.io.Closeable; import java.util.ArrayList; @@ -19,10 +18,13 @@ @NotThreadSafe class EagerActivitySlotsReservation implements Closeable { private final EagerActivityDispatcher eagerActivityDispatcher; + private final int maxReservations; private final List reservedSlots = new ArrayList<>(); - EagerActivitySlotsReservation(EagerActivityDispatcher eagerActivityDispatcher) { + EagerActivitySlotsReservation( + EagerActivityDispatcher eagerActivityDispatcher, int maxReservations) { this.eagerActivityDispatcher = eagerActivityDispatcher; + this.maxReservations = maxReservations; } public void applyToRequest(RespondWorkflowTaskCompletedRequest.Builder mutableRequest) { @@ -33,7 +35,7 @@ public void applyToRequest(RespondWorkflowTaskCompletedRequest.Builder mutableRe ScheduleActivityTaskCommandAttributes commandAttributes = command.getScheduleActivityTaskCommandAttributes(); if (!commandAttributes.getRequestEagerExecution()) continue; - boolean atLimit = this.reservedSlots.size() >= Config.EAGER_ACTIVITIES_LIMIT; + boolean atLimit = this.reservedSlots.size() >= this.maxReservations; Optional permit = Optional.empty(); if (!atLimit) { permit = this.eagerActivityDispatcher.tryReserveActivitySlot(commandAttributes); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java index 18cf7fd4a5..be128a5e62 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java @@ -67,6 +67,7 @@ public SyncWorkflowWorker( String stickyTaskQueueName, @Nonnull WorkflowThreadExecutor workflowThreadExecutor, @Nonnull EagerActivityDispatcher eagerActivityDispatcher, + int maxEagerActivityReservationsPerWorkflowTask, @Nonnull SlotSupplier slotSupplier, @Nonnull SlotSupplier laSlotSupplier, @Nonnull NamespaceCapabilities namespaceCapabilities) { @@ -123,6 +124,7 @@ public SyncWorkflowWorker( cache, taskHandler, eagerActivityDispatcher, + maxEagerActivityReservationsPerWorkflowTask, slotSupplier, namespaceCapabilities); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index b86dfea6df..3eed1099d3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -52,6 +52,7 @@ final class WorkflowWorker implements SuspendableWorker { private final Scope workerMetricsScope; private final GrpcRetryer grpcRetryer; private final EagerActivityDispatcher eagerActivityDispatcher; + private final int maxEagerActivityReservationsPerWorkflowTask; private final TrackingSlotSupplier slotSupplier; private final TaskCounter taskCounter = new TaskCounter(); @@ -77,6 +78,7 @@ public WorkflowWorker( @Nonnull WorkflowExecutorCache cache, @Nonnull WorkflowTaskHandler handler, @Nonnull EagerActivityDispatcher eagerActivityDispatcher, + int maxEagerActivityReservationsPerWorkflowTask, @Nonnull SlotSupplier slotSupplier, @Nonnull NamespaceCapabilities namespaceCapabilities) { this.service = Objects.requireNonNull(service); @@ -92,6 +94,7 @@ public WorkflowWorker( this.handler = Objects.requireNonNull(handler); this.grpcRetryer = new GrpcRetryer(service.getServerCapabilities()); this.eagerActivityDispatcher = eagerActivityDispatcher; + this.maxEagerActivityReservationsPerWorkflowTask = maxEagerActivityReservationsPerWorkflowTask; this.slotSupplier = new TrackingSlotSupplier<>(slotSupplier, this.workerMetricsScope); this.namespaceCapabilities = namespaceCapabilities; } @@ -478,7 +481,8 @@ public void handle(WorkflowTask task) throws Exception { RespondWorkflowTaskCompletedRequest.Builder requestBuilder = taskCompleted.toBuilder(); try (EagerActivitySlotsReservation activitySlotsReservation = - new EagerActivitySlotsReservation(eagerActivityDispatcher)) { + new EagerActivitySlotsReservation( + eagerActivityDispatcher, maxEagerActivityReservationsPerWorkflowTask)) { activitySlotsReservation.applyToRequest(requestBuilder); RespondWorkflowTaskCompletedResponse response = sendTaskCompleted( diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index 93a26def2b..b755134448 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -244,6 +244,7 @@ private static final class TaskSnapshot { stickyTaskQueueName, workflowThreadExecutor, eagerActivityDispatcher, + this.options.getMaxEagerActivityReservationsPerWorkflowTask(), workflowSlotSupplier, localActivitySlotSupplier, namespaceCapabilities); diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java index aa61c6fa4c..3346b53142 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerOptions.java @@ -4,6 +4,7 @@ import com.google.common.base.Preconditions; import io.temporal.common.Experimental; +import io.temporal.internal.Config; import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.worker.tuning.*; import java.time.Duration; @@ -64,6 +65,7 @@ public static final class Builder { private Duration defaultHeartbeatThrottleInterval; private Duration stickyQueueScheduleToStartTimeout; private boolean disableEagerExecution; + private int maxEagerActivityReservationsPerWorkflowTask = Config.EAGER_ACTIVITIES_LIMIT; private String buildId; private boolean useBuildIdForVersioning; private Duration stickyTaskQueueDrainTimeout; @@ -109,6 +111,8 @@ private Builder(WorkerOptions o) { this.defaultHeartbeatThrottleInterval = o.defaultHeartbeatThrottleInterval; this.stickyQueueScheduleToStartTimeout = o.stickyQueueScheduleToStartTimeout; this.disableEagerExecution = o.disableEagerExecution; + this.maxEagerActivityReservationsPerWorkflowTask = + o.maxEagerActivityReservationsPerWorkflowTask; this.useBuildIdForVersioning = o.useBuildIdForVersioning; this.buildId = o.buildId; this.stickyTaskQueueDrainTimeout = o.stickyTaskQueueDrainTimeout; @@ -400,6 +404,20 @@ public Builder setDisableEagerExecution(boolean disableEagerExecution) { return this; } + /** + * Sets the maximum number of activity slots that may be reserved for eager execution when + * completing a workflow task. + * + *

The default is 3. The value must be positive. To disable eager activity execution, use + * {@link #setDisableEagerExecution(boolean)}. + */ + public Builder setMaxEagerActivityReservationsPerWorkflowTask( + int maxEagerActivityReservationsPerWorkflowTask) { + this.maxEagerActivityReservationsPerWorkflowTask = + maxEagerActivityReservationsPerWorkflowTask; + return this; + } + /** * Opts the worker in to the Build-ID-based versioning feature. This ensures that the worker * will only receive tasks which it is compatible with. @@ -623,6 +641,7 @@ public WorkerOptions build() { defaultHeartbeatThrottleInterval, stickyQueueScheduleToStartTimeout, disableEagerExecution, + maxEagerActivityReservationsPerWorkflowTask, useBuildIdForVersioning, buildId, stickyTaskQueueDrainTimeout, @@ -647,6 +666,10 @@ public WorkerOptions validateAndBuildWithDefaults() { maxWorkerActivitiesPerSecond >= 0, "negative maxActivitiesPerSecond"); Preconditions.checkState( maxConcurrentActivityExecutionSize >= 0, "negative maxConcurrentActivityExecutionSize"); + Preconditions.checkState( + maxEagerActivityReservationsPerWorkflowTask > 0, + "maxEagerActivityReservationsPerWorkflowTask must be positive; use " + + "setDisableEagerExecution(true) to disable eager activity execution"); Preconditions.checkState( maxConcurrentWorkflowTaskExecutionSize >= 0, "negative maxConcurrentWorkflowTaskExecutionSize"); @@ -758,6 +781,7 @@ public WorkerOptions validateAndBuildWithDefaults() { ? DEFAULT_STICKY_SCHEDULE_TO_START_TIMEOUT : stickyQueueScheduleToStartTimeout, disableEagerExecution, + maxEagerActivityReservationsPerWorkflowTask, useBuildIdForVersioning, buildId, stickyTaskQueueDrainTimeout == null @@ -796,6 +820,7 @@ public WorkerOptions validateAndBuildWithDefaults() { private final Duration defaultHeartbeatThrottleInterval; private final @Nonnull Duration stickyQueueScheduleToStartTimeout; private final boolean disableEagerExecution; + private final int maxEagerActivityReservationsPerWorkflowTask; private final boolean useBuildIdForVersioning; private final String buildId; private final Duration stickyTaskQueueDrainTimeout; @@ -831,6 +856,7 @@ private WorkerOptions( Duration defaultHeartbeatThrottleInterval, @Nonnull Duration stickyQueueScheduleToStartTimeout, boolean disableEagerExecution, + int maxEagerActivityReservationsPerWorkflowTask, boolean useBuildIdForVersioning, String buildId, Duration stickyTaskQueueDrainTimeout, @@ -864,6 +890,7 @@ private WorkerOptions( this.defaultHeartbeatThrottleInterval = defaultHeartbeatThrottleInterval; this.stickyQueueScheduleToStartTimeout = stickyQueueScheduleToStartTimeout; this.disableEagerExecution = maxTaskQueueActivitiesPerSecond > 0 ? true : disableEagerExecution; + this.maxEagerActivityReservationsPerWorkflowTask = maxEagerActivityReservationsPerWorkflowTask; this.useBuildIdForVersioning = useBuildIdForVersioning; this.buildId = buildId; this.stickyTaskQueueDrainTimeout = stickyTaskQueueDrainTimeout; @@ -989,6 +1016,10 @@ public boolean isEagerExecutionDisabled() { return disableEagerExecution; } + public int getMaxEagerActivityReservationsPerWorkflowTask() { + return maxEagerActivityReservationsPerWorkflowTask; + } + public boolean isUsingBuildIdForVersioning() { return useBuildIdForVersioning; } @@ -1070,6 +1101,8 @@ && compare(maxTaskQueueActivitiesPerSecond, that.maxTaskQueueActivitiesPerSecond && localActivityWorkerOnly == that.localActivityWorkerOnly && defaultDeadlockDetectionTimeout == that.defaultDeadlockDetectionTimeout && disableEagerExecution == that.disableEagerExecution + && maxEagerActivityReservationsPerWorkflowTask + == that.maxEagerActivityReservationsPerWorkflowTask && useBuildIdForVersioning == that.useBuildIdForVersioning && Objects.equals(workerTuner, that.workerTuner) && Objects.equals(maxHeartbeatThrottleInterval, that.maxHeartbeatThrottleInterval) @@ -1109,6 +1142,7 @@ public int hashCode() { defaultHeartbeatThrottleInterval, stickyQueueScheduleToStartTimeout, disableEagerExecution, + maxEagerActivityReservationsPerWorkflowTask, useBuildIdForVersioning, buildId, stickyTaskQueueDrainTimeout, @@ -1160,6 +1194,8 @@ public String toString() { + stickyQueueScheduleToStartTimeout + ", disableEagerExecution=" + disableEagerExecution + + ", maxEagerActivityReservationsPerWorkflowTask=" + + maxEagerActivityReservationsPerWorkflowTask + ", useBuildIdForVersioning=" + useBuildIdForVersioning + ", buildId='" diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/EagerActivitySlotsReservationTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/EagerActivitySlotsReservationTest.java new file mode 100644 index 0000000000..7dff989c70 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/EagerActivitySlotsReservationTest.java @@ -0,0 +1,60 @@ +package io.temporal.internal.worker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.enums.v1.CommandType; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import io.temporal.worker.tuning.SlotPermit; +import java.util.Optional; +import org.junit.Test; + +public class EagerActivitySlotsReservationTest { + @Test + public void limitsReservationsPerWorkflowTask() { + EagerActivityDispatcher dispatcher = mock(EagerActivityDispatcher.class); + when(dispatcher.tryReserveActivitySlot(any())).thenReturn(Optional.of(mock(SlotPermit.class))); + RespondWorkflowTaskCompletedRequest.Builder request = + RespondWorkflowTaskCompletedRequest.newBuilder(); + for (int i = 0; i < 5; i++) { + request.addCommands( + Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_SCHEDULE_ACTIVITY_TASK) + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setRequestEagerExecution(true))); + } + + try (EagerActivitySlotsReservation reservation = + new EagerActivitySlotsReservation(dispatcher, 2)) { + reservation.applyToRequest(request); + assertEquals(5, request.getCommandsCount()); + assertTrue( + request + .getCommands(0) + .getScheduleActivityTaskCommandAttributes() + .getRequestEagerExecution()); + assertTrue( + request + .getCommands(1) + .getScheduleActivityTaskCommandAttributes() + .getRequestEagerExecution()); + for (int i = 2; i < 5; i++) { + assertFalse( + request + .getCommands(i) + .getScheduleActivityTaskCommandAttributes() + .getRequestEagerExecution()); + } + } + verify(dispatcher, times(2)).tryReserveActivitySlot(any()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java index d4f1824c26..5cd1fc8d3e 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java @@ -84,6 +84,7 @@ public void concurrentPollRequestLockTest() throws Exception { cache, taskHandler, eagerActivityDispatcher, + 3, slotSupplier, new NamespaceCapabilities()); @@ -255,6 +256,7 @@ public void respondWorkflowTaskFailureMetricTest() throws Exception { cache, taskHandler, eagerActivityDispatcher, + 3, slotSupplier, new NamespaceCapabilities()); @@ -399,6 +401,7 @@ public boolean isAnyTypeSupported() { cache, taskHandler, eagerActivityDispatcher, + 3, slotSupplier, new NamespaceCapabilities()); diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java index f21345a30f..1dd61df1d2 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerOptionsTest.java @@ -23,6 +23,7 @@ public void build() { private void verifyBuild(WorkerOptions options) { assertEquals(10, options.getMaxConcurrentActivityExecutionSize()); assertEquals(11, options.getMaxConcurrentLocalActivityExecutionSize()); + assertEquals(3, options.getMaxEagerActivityReservationsPerWorkflowTask()); assertNotNull(options.getPreferredVersionProvider()); } @@ -55,6 +56,7 @@ public void verifyNewBuilderFromExistingWorkerOptions() { .setDefaultHeartbeatThrottleInterval(Duration.ofSeconds(7)) .setStickyQueueScheduleToStartTimeout(Duration.ofSeconds(60)) .setDisableEagerExecution(false) + .setMaxEagerActivityReservationsPerWorkflowTask(17) .setUseBuildIdForVersioning(false) .setBuildId("build-id") .setStickyTaskQueueDrainTimeout(Duration.ofSeconds(15)) @@ -90,6 +92,9 @@ public void verifyNewBuilderFromExistingWorkerOptions() { assertEquals( w1.getStickyQueueScheduleToStartTimeout(), w2.getStickyQueueScheduleToStartTimeout()); assertEquals(w1.isEagerExecutionDisabled(), w2.isEagerExecutionDisabled()); + assertEquals( + w1.getMaxEagerActivityReservationsPerWorkflowTask(), + w2.getMaxEagerActivityReservationsPerWorkflowTask()); assertEquals(w1.isUsingBuildIdForVersioning(), w2.isUsingBuildIdForVersioning()); assertEquals(w1.getBuildId(), w2.getBuildId()); assertEquals(w1.getStickyTaskQueueDrainTimeout(), w2.getStickyTaskQueueDrainTimeout()); @@ -230,4 +235,21 @@ public void verifyMaxTaskQueuePerSecondsDisablesEagerExecution() { WorkerOptions w2 = WorkerOptions.newBuilder().setMaxTaskQueueActivitiesPerSecond(2.0).build(); assertTrue(w2.isEagerExecutionDisabled()); } + + @Test + public void rejectsNonPositiveMaxEagerActivityReservationsPerWorkflowTask() { + for (int value : new int[] {0, -1}) { + IllegalStateException exception = + assertThrows( + IllegalStateException.class, + () -> + WorkerOptions.newBuilder() + .setMaxEagerActivityReservationsPerWorkflowTask(value) + .validateAndBuildWithDefaults()); + assertEquals( + "maxEagerActivityReservationsPerWorkflowTask must be positive; use " + + "setDisableEagerExecution(true) to disable eager activity execution", + exception.getMessage()); + } + } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java index 3baf855fb9..8353b40c37 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java @@ -3,13 +3,22 @@ import static org.junit.Assert.*; import static org.junit.Assume.*; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.MethodDescriptor; import io.temporal.activity.ActivityOptions; import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; import io.temporal.client.WorkflowStub; import io.temporal.common.WorkflowExecutionHistory; import io.temporal.internal.Config; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.testUtils.CountingSlotSupplier; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.testing.internal.ExternalServiceTestConfigurator; @@ -23,8 +32,10 @@ import io.temporal.workflow.shared.TestActivities.TestActivitiesImpl; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; import org.junit.*; @@ -32,6 +43,8 @@ public class EagerActivityDispatchingTest { private static final String TASK_QUEUE = "test-eager-activity-dispatch"; private TestWorkflowEnvironment env; private ArrayList workerFactories; + private final EagerActivityRequestInterceptor eagerActivityRequestInterceptor = + new EagerActivityRequestInterceptor(); private final TestActivitiesImpl activitiesImpl = new TestActivitiesImpl(); CountingSlotSupplier workflowTaskSlotSupplier = new CountingSlotSupplier<>(100); @@ -42,9 +55,16 @@ public class EagerActivityDispatchingTest { @Before public void setUp() throws Exception { + eagerActivityRequestInterceptor.reset(); this.env = TestWorkflowEnvironment.newInstance( - ExternalServiceTestConfigurator.configuredTestEnvironmentOptions().build()); + ExternalServiceTestConfigurator.configuredTestEnvironmentOptions() + .setWorkflowServiceStubsOptions( + WorkflowServiceStubsOptions.newBuilder() + .setGrpcClientInterceptors( + Collections.singletonList(eagerActivityRequestInterceptor)) + .build()) + .build()); this.workerFactories = new ArrayList<>(); } @@ -125,6 +145,25 @@ public void testEagerActivities() { assertFalse(activityTaskStartedEventIdentity.contains("worker2")); } + @Test + public void testMaxEagerActivityReservationsPerWorkflowTask() { + setupWorker( + "worker1", + WorkerOptions.newBuilder() + .setMaxEagerActivityReservationsPerWorkflowTask(2) + .setDisableEagerExecution(false), + true); + + EagerActivityTestWorkflow workflowStub = + env.getWorkflowClient() + .newWorkflowStub( + EagerActivityTestWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(TASK_QUEUE).build()); + workflowStub.execute(true); + + assertEquals(2, eagerActivityRequestInterceptor.getEagerActivityRequestCount()); + } + @Test public void testNoEagerActivitiesIfDisabledOnWorker() { assumeTrue( @@ -222,4 +261,50 @@ public void execute(boolean enableEagerActivityDispatch) { Promise.allOf(promises).get(); } } + + private static class EagerActivityRequestInterceptor implements ClientInterceptor { + private final AtomicInteger eagerActivityRequestCount = new AtomicInteger(-1); + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + if (method == WorkflowServiceGrpc.getRespondWorkflowTaskCompletedMethod()) { + return new ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void sendMessage(ReqT message) { + RespondWorkflowTaskCompletedRequest request = + (RespondWorkflowTaskCompletedRequest) message; + long activityCommandCount = + request.getCommandsList().stream() + .filter(command -> command.hasScheduleActivityTaskCommandAttributes()) + .count(); + if (activityCommandCount > 0) { + int eagerRequestCount = + (int) + request.getCommandsList().stream() + .filter(command -> command.hasScheduleActivityTaskCommandAttributes()) + .filter( + command -> + command + .getScheduleActivityTaskCommandAttributes() + .getRequestEagerExecution()) + .count(); + eagerActivityRequestCount.compareAndSet(-1, eagerRequestCount); + } + super.sendMessage(message); + } + }; + } + return next.newCall(method, callOptions); + } + + int getEagerActivityRequestCount() { + return eagerActivityRequestCount.get(); + } + + void reset() { + eagerActivityRequestCount.set(-1); + } + } } From d664c37ce84814a14eb2f8a4d9c3b6a247751888 Mon Sep 17 00:00:00 2001 From: hungrytech <74886812+hungrytech@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:30:22 +0900 Subject: [PATCH 047/107] Update ScheduleRange validation for negative end (#2971) --- .../io/temporal/client/schedules/ScheduleRange.java | 10 +++++++--- .../temporal/client/schedules/ScheduleRangeTest.java | 12 ++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleRangeTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/schedules/ScheduleRange.java b/temporal-sdk/src/main/java/io/temporal/client/schedules/ScheduleRange.java index 4d79d50d4e..29bd596c56 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/schedules/ScheduleRange.java +++ b/temporal-sdk/src/main/java/io/temporal/client/schedules/ScheduleRange.java @@ -22,7 +22,9 @@ public ScheduleRange(int start) { * Create a inclusive range for a schedule match value. * * @param start The inclusive start of the range - * @param end The inclusive end of the range. Default if unset or less than start is start. + * @param end The inclusive end of the range. Must be non-negative. Default if unset or less than + * start is start. + * @throws IllegalStateException if start or end is negative */ public ScheduleRange(int start, int end) { this(start, end, 0); @@ -32,11 +34,13 @@ public ScheduleRange(int start, int end) { * Create a inclusive range for a schedule match value. * * @param start The inclusive start of the range - * @param end The inclusive end of the range. Default if unset or less than start is start. + * @param end The inclusive end of the range. Must be non-negative. Default if unset or less than + * start is start. * @param step The step to take between each value. Default if unset or 0, is 1. + * @throws IllegalStateException if start, end, or step is negative */ public ScheduleRange(int start, int end, int step) { - Preconditions.checkState(start >= 0 && step >= 0 && step >= 0); + Preconditions.checkState(start >= 0 && end >= 0 && step >= 0); this.start = start; this.end = end; this.step = step; diff --git a/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleRangeTest.java b/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleRangeTest.java new file mode 100644 index 0000000000..f179dac18d --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleRangeTest.java @@ -0,0 +1,12 @@ +package io.temporal.client.schedules; + +import org.junit.Assert; +import org.junit.Test; + +public class ScheduleRangeTest { + @Test + public void rejectsNegativeEnd() { + Assert.assertThrows(IllegalStateException.class, () -> new ScheduleRange(0, -1)); + Assert.assertThrows(IllegalStateException.class, () -> new ScheduleRange(0, -1, 0)); + } +} From 5d6feeb3f9d09ba418fc48cfc8f182908f1799f8 Mon Sep 17 00:00:00 2001 From: Vathsala Ragireddy <34386917+vathsalaR@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:57:49 -0700 Subject: [PATCH 048/107] Fix: Propagate WorkflowOptions.priority through signalWithStart (#2966) --- .../client/WorkflowClientRequestFactory.java | 3 ++ .../temporal/workflow/PriorityInfoTest.java | 53 ++++++++++++++++++- .../testservice/TestWorkflowService.java | 3 ++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientRequestFactory.java b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientRequestFactory.java index c8d9a3cca2..dbab7074a5 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientRequestFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientRequestFactory.java @@ -210,6 +210,9 @@ SignalWithStartWorkflowExecutionRequest.Builder newSignalWithStartWorkflowExecut request.setVersioningOverride(startParameters.getVersioningOverride()); } + if (startParameters.hasPriority()) { + request.setPriority(startParameters.getPriority()); + } return request; } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/PriorityInfoTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/PriorityInfoTest.java index 339c5f1034..1779295e9f 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/PriorityInfoTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/PriorityInfoTest.java @@ -6,11 +6,13 @@ import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityOptions; import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; import io.temporal.common.Priority; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.TestWorkflows; import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; import java.time.Duration; +import java.util.UUID; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; @@ -20,7 +22,10 @@ public class PriorityInfoTest { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setWorkflowTypes(TestPriority.class, TestPriorityChildWorkflow.class) + .setWorkflowTypes( + TestPriority.class, + TestPriorityChildWorkflow.class, + TestSignalWithStartPriorityWorkflowImpl.class) .setActivityImplementations(new PriorityActivitiesImpl()) .build(); @@ -44,6 +49,24 @@ public void testPriority() { assertEquals("5:tenant-123:2.5", result); } + @Test + public void testPriorityWithSignalWithStart() { + WorkflowStub stub = + testWorkflowRule + .getWorkflowClient() + .newUntypedWorkflowStub( + "TestSignalWithStartPriorityWorkflow", + WorkflowOptions.newBuilder() + .setWorkflowId("test-signal-with-start-priority-" + UUID.randomUUID()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setPriority(Priority.newBuilder().setPriorityKey(2).build()) + .build()); + stub.signalWithStart( + "mySignal", new Object[] {"signalArg"}, new Object[] {testWorkflowRule.getTaskQueue()}); + String result = stub.getResult(String.class); + assertEquals("2:null:0.0", result); + } + @ActivityInterface public interface PriorityActivities { String activity1(String a1); @@ -131,4 +154,32 @@ public String execute(String taskQueue) { + workflowPriority.getFairnessWeight(); } } + + @WorkflowInterface + public interface TestSignalWithStartPriorityWorkflow { + @WorkflowMethod + String execute(String taskQueue); + + @SignalMethod + void mySignal(String arg); + } + + public static class TestSignalWithStartPriorityWorkflowImpl + implements TestSignalWithStartPriorityWorkflow { + + private boolean received = false; + + @Override + public String execute(String taskQueue) { + Workflow.await(() -> received); + Priority priority = Workflow.getInfo().getPriority(); + String key = priority.getFairnessKey() != null ? priority.getFairnessKey() : "null"; + return priority.getPriorityKey() + ":" + key + ":" + priority.getFairnessWeight(); + } + + @Override + public void mySignal(String arg) { + received = true; + } + } } diff --git a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java index b62f021a3c..4e4bbf336b 100644 --- a/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java +++ b/temporal-test-server/src/main/java/io/temporal/internal/testservice/TestWorkflowService.java @@ -1657,6 +1657,9 @@ public void signalWithStartWorkflowExecution( if (!r.getLinksList().isEmpty()) { startRequest.addAllLinks(r.getLinksList()); } + if (r.hasPriority()) { + startRequest.setPriority(r.getPriority()); + } StartWorkflowExecutionResponse startResult = startWorkflowExecutionImpl( From 2f6dcac6d2da32152bcc48777d13b7ac25163f78 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Tue, 28 Jul 2026 14:38:16 -0400 Subject: [PATCH 049/107] Add structured concurrency wrapper for CompletableFuture (#2939) * add minimal, non-blocking, structured concurrency wrapper for CompletableFuture. * narrow ListUtils types, add tests, comments. * formatting fixes. * Replace ActivityCancellationToken with CancellationToken. --- .../activity/ActivityCancellationToken.java | 51 ---- .../activity/ActivityExecutionContext.java | 4 +- .../io/temporal/common/CancellationToken.java | 77 ++++++ .../ActivityExecutionContextBase.java | 5 +- .../ActivityCancellationTokenImpl.java | 51 ---- .../ActivityExecutionContextImpl.java | 5 +- .../internal/activity/HeartbeatContext.java | 5 +- .../activity/HeartbeatContextImpl.java | 15 +- .../LocalActivityExecutionContextImpl.java | 7 +- .../temporal/internal/common/ListUtils.java | 18 ++ .../concurrent/structured/AsyncTask.java | 63 +++++ .../concurrent/structured/CancelSource.java | 117 +++++++++ .../structured/DefaultAsyncTask.java | 184 +++++++++++++++ .../structured/DefaultTaskScope.java | 223 ++++++++++++++++++ .../internal/concurrent/structured/README.md | 163 +++++++++++++ .../concurrent/structured/Result.java | 105 +++++++++ .../concurrent/structured/TaskChain.java | 27 +++ .../concurrent/structured/TaskScope.java | 85 +++++++ .../activity/HeartbeatContextImplTest.java | 4 +- .../internal/common/ListUtilsTest.java | 75 ++++++ .../concurrent/structured/AsyncTaskTest.java | 81 +++++++ .../structured/CancelSourceTest.java | 112 +++++++++ .../structured/TaskScopeAndResultTest.java | 183 ++++++++++++++ .../concurrent/structured/TaskScopeTest.java | 165 +++++++++++++ 24 files changed, 1704 insertions(+), 121 deletions(-) delete mode 100644 temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java create mode 100644 temporal-sdk/src/main/java/io/temporal/common/CancellationToken.java delete mode 100644 temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/common/ListUtils.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/AsyncTask.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/CancelSource.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultAsyncTask.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultTaskScope.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/README.md create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/Result.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskChain.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskScope.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/common/ListUtilsTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/AsyncTaskTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/CancelSourceTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeAndResultTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java b/temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java deleted file mode 100644 index 612aeaae27..0000000000 --- a/temporal-sdk/src/main/java/io/temporal/activity/ActivityCancellationToken.java +++ /dev/null @@ -1,51 +0,0 @@ -package io.temporal.activity; - -import io.temporal.client.ActivityCanceledException; -import io.temporal.common.Experimental; -import java.util.concurrent.CompletableFuture; - -/** Token that allows an Activity implementation to observe cancellation requests. */ -@Experimental -public interface ActivityCancellationToken { - - ActivityCancellationToken NONE = - new ActivityCancellationToken() { - @Override - public boolean isCancellationRequested() { - return false; - } - - @Override - public void throwIfCancellationRequested() throws ActivityCanceledException {} - - @Override - public CompletableFuture getCancellationFuture() { - return new CompletableFuture<>(); - } - }; - - /** - * Returns true after cancellation has been requested for this Activity Execution. - * - *

If this method returns true, the Activity implementation should stop its work and usually - * call {@link #throwIfCancellationRequested()} to report successful cancellation to Temporal. - */ - boolean isCancellationRequested(); - - /** - * Throws {@link ActivityCanceledException} if cancellation has been requested for this Activity - * Execution. - * - *

Rethrowing this exception from Activity code reports successful cancellation to Temporal. - */ - void throwIfCancellationRequested() throws ActivityCanceledException; - - /** - * Future that completes exceptionally with {@link ActivityCanceledException} when cancellation - * has been requested for this Activity Execution. - * - *

Activity code should still call {@link #throwIfCancellationRequested()} or otherwise report - * cancellation if it wants the Activity Execution to complete as canceled. - */ - CompletableFuture getCancellationFuture(); -} diff --git a/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java b/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java index 0d8c1be793..ac656dfa13 100644 --- a/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java +++ b/temporal-sdk/src/main/java/io/temporal/activity/ActivityExecutionContext.java @@ -1,8 +1,10 @@ package io.temporal.activity; import com.uber.m3.tally.Scope; +import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; import io.temporal.client.WorkflowClient; +import io.temporal.common.CancellationToken; import io.temporal.common.Experimental; import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.worker.WorkerOptions; @@ -95,7 +97,7 @@ public interface ActivityExecutionContext { * recording Heartbeats. */ @Experimental - ActivityCancellationToken getCancellationToken(); + CancellationToken getCancellationToken(); /** * If this method is called during an Activity Execution then the Activity Execution is not going diff --git a/temporal-sdk/src/main/java/io/temporal/common/CancellationToken.java b/temporal-sdk/src/main/java/io/temporal/common/CancellationToken.java new file mode 100644 index 0000000000..0c07c0a92f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/CancellationToken.java @@ -0,0 +1,77 @@ +package io.temporal.common; + +import java.util.concurrent.CompletableFuture; + +/** + * Token that allows asynchronous code to observe cancellation requests. + * + * @param the exception type surfaced by {@link #throwIfCancellationRequested()} and {@link + * #getCancellationFuture()} when cancellation is requested + */ +@Experimental +public interface CancellationToken { + + /** Returns true after cancellation has been requested. */ + boolean isCancellationRequested(); + + /** Throws {@code E} if cancellation has been requested. */ + void throwIfCancellationRequested() throws E; + + /** + * Future that completes exceptionally with {@code E} when cancellation has been requested. + * + *

Code waiting on external work can chain off this future to abort in-flight requests when + * cancellation is requested. + */ + default CompletableFuture getCancellationFuture() { + CompletableFuture result = new CompletableFuture<>(); + Registration registration = + onCancel( + () -> { + try { + throwIfCancellationRequested(); + } catch (RuntimeException e) { + result.completeExceptionally(e); + } + }); + result.whenComplete((ignored, error) -> registration.close()); + return result; + } + + /** + * Registers a callback to run when cancellation is requested, or immediately if already + * cancelled. + * + * @return a handle that removes the callback if cancellation has not happened yet. + */ + Registration onCancel(Runnable callback); + + /** Handle for removing a previously registered cancellation callback. */ + interface Registration extends AutoCloseable { + @Override + void close(); + } + + /** A token that is never cancelled. */ + static CancellationToken none() { + return new CancellationToken() { + @Override + public boolean isCancellationRequested() { + return false; + } + + @Override + public void throwIfCancellationRequested() {} + + @Override + public CompletableFuture getCancellationFuture() { + return new CompletableFuture<>(); + } + + @Override + public Registration onCancel(Runnable callback) { + return () -> {}; + } + }; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java index 3ce86cec38..adf2e83531 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityExecutionContextBase.java @@ -1,12 +1,13 @@ package io.temporal.common.interceptors; import com.uber.m3.tally.Scope; -import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInfo; import io.temporal.activity.ManualActivityCompletionClient; +import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; import io.temporal.client.WorkflowClient; +import io.temporal.common.CancellationToken; import java.lang.reflect.Type; import java.util.Optional; @@ -54,7 +55,7 @@ public byte[] getTaskToken() { } @Override - public ActivityCancellationToken getCancellationToken() { + public CancellationToken getCancellationToken() { return next.getCancellationToken(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java deleted file mode 100644 index b78fcdafae..0000000000 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityCancellationTokenImpl.java +++ /dev/null @@ -1,51 +0,0 @@ -package io.temporal.internal.activity; - -import io.temporal.activity.ActivityCancellationToken; -import io.temporal.client.ActivityCanceledException; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; - -final class ActivityCancellationTokenImpl implements ActivityCancellationToken { - private final CompletableFuture cancellationFuture = new CompletableFuture<>(); - private volatile ActivityCanceledException cancellationException; - - @Override - public boolean isCancellationRequested() { - return cancellationException != null; - } - - @Override - public void throwIfCancellationRequested() throws ActivityCanceledException { - ActivityCanceledException exception = cancellationException; - if (exception != null) { - throw exception; - } - } - - @Override - public CompletableFuture getCancellationFuture() { - CompletableFuture result = new CompletableFuture<>(); - cancellationFuture.whenComplete( - (ignored, exception) -> { - if (exception == null) { - result.complete(null); - } else { - result.completeExceptionally(unwrapCompletionException(exception)); - } - }); - return result; - } - - synchronized void requestCancel(ActivityCanceledException exception) { - if (cancellationException == null) { - cancellationException = exception; - cancellationFuture.completeExceptionally(exception); - } - } - - private static Throwable unwrapCompletionException(Throwable exception) { - return exception instanceof CompletionException && exception.getCause() != null - ? exception.getCause() - : exception; - } -} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java index db8943138c..40fe45c326 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java @@ -1,12 +1,13 @@ package io.temporal.internal.activity; import com.uber.m3.tally.Scope; -import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInfo; import io.temporal.activity.ManualActivityCompletionClient; +import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; import io.temporal.client.WorkflowClient; +import io.temporal.common.CancellationToken; import io.temporal.common.converter.DataConverter; import io.temporal.internal.client.external.ManualActivityCompletionClientFactory; import io.temporal.payload.context.ActivitySerializationContext; @@ -110,7 +111,7 @@ public byte[] getTaskToken() { } @Override - public ActivityCancellationToken getCancellationToken() { + public CancellationToken getCancellationToken() { return heartbeatContext.getCancellationToken(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java index c960f70ba9..faef0d7950 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContext.java @@ -1,7 +1,8 @@ package io.temporal.internal.activity; -import io.temporal.activity.ActivityCancellationToken; +import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; +import io.temporal.common.CancellationToken; import java.lang.reflect.Type; import java.util.Optional; @@ -24,7 +25,7 @@ interface HeartbeatContext { Object getLatestHeartbeatDetails(); - ActivityCancellationToken getCancellationToken(); + CancellationToken getCancellationToken(); /** Mark this activity as canceled by an external worker command. */ void cancelFromWorkerCommand(); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java index 477780a975..91da94ab0a 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java @@ -3,16 +3,17 @@ import com.uber.m3.tally.Scope; import io.grpc.Status; import io.grpc.StatusRuntimeException; -import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInfo; import io.temporal.api.common.v1.Payloads; import io.temporal.api.enums.v1.TimeoutType; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatResponse; import io.temporal.client.*; +import io.temporal.common.CancellationToken; import io.temporal.common.converter.DataConverter; import io.temporal.failure.TimeoutFailure; import io.temporal.internal.client.ActivityClientHelper; +import io.temporal.internal.concurrent.structured.CancelSource; import io.temporal.payload.context.ActivitySerializationContext; import io.temporal.serviceclient.WorkflowServiceStubs; import java.lang.reflect.Type; @@ -76,8 +77,8 @@ static long getLocalHeartbeatTimeoutBufferMillis() { private boolean rejectNewHeartbeats; private ActivityCompletionException lastException; - private final ActivityCancellationTokenImpl cancellationToken = - new ActivityCancellationTokenImpl(); + private final CancelSource cancellationSource = + new CancelSource<>(ActivityCanceledException::new); public HeartbeatContextImpl( WorkflowServiceStubs service, @@ -166,7 +167,7 @@ public void heartbeat(V details) throws ActivityCompletionException { if (lastException != null) { throw lastException; } - cancellationToken.throwIfCancellationRequested(); + cancellationSource.token().throwIfCancellationRequested(); } finally { lock.unlock(); } @@ -257,8 +258,8 @@ public void asyncCompletionStarted() { } @Override - public ActivityCancellationToken getCancellationToken() { - return cancellationToken; + public CancellationToken getCancellationToken() { + return cancellationSource.token(); } private void doHeartBeatLocked(Object details) { @@ -363,7 +364,7 @@ private void sendHeartbeatRequest(Object details) { private void requestCancelLocked() { ActivityCanceledException exception = new ActivityCanceledException(info); lastException = exception; - cancellationToken.requestCancel(exception); + cancellationSource.cancel(exception); } private static long getHeartbeatIntervalMs( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java index 0f66364248..0e8282c580 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/LocalActivityExecutionContextImpl.java @@ -1,11 +1,12 @@ package io.temporal.internal.activity; import com.uber.m3.tally.Scope; -import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityInfo; import io.temporal.activity.ManualActivityCompletionClient; +import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; import io.temporal.client.WorkflowClient; +import io.temporal.common.CancellationToken; import java.lang.reflect.Type; import java.util.Optional; @@ -59,8 +60,8 @@ public byte[] getTaskToken() { } @Override - public ActivityCancellationToken getCancellationToken() { - return ActivityCancellationToken.NONE; + public CancellationToken getCancellationToken() { + return CancellationToken.none(); } @Override diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/ListUtils.java b/temporal-sdk/src/main/java/io/temporal/internal/common/ListUtils.java new file mode 100644 index 0000000000..ae70a08abc --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/ListUtils.java @@ -0,0 +1,18 @@ +package io.temporal.internal.common; + +import java.util.ArrayList; +import java.util.List; + +public final class ListUtils { + + private ListUtils() {} + + /** Concatenates a list of lists into a single list, preserving order. */ + public static List flatten(List> lists) { + List result = new ArrayList<>(); + for (List list : lists) { + result.addAll(list); + } + return result; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/AsyncTask.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/AsyncTask.java new file mode 100644 index 0000000000..230eeb1477 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/AsyncTask.java @@ -0,0 +1,63 @@ +package io.temporal.internal.concurrent.structured; + +import io.temporal.common.CancellationToken; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * Internal task handle over a {@link CompletableFuture} that manages cancellation and tracks + * derived tasks. + * + *

Cancellation is downstream by default. {@link #cancel()} settles this task + * and every task derived from it (its {@code map}/{@code recover} children). It does not + * cancel the task this one was derived from. + * + * @param the result type + */ +interface AsyncTask extends TaskChain { + + @Override + AsyncTask map(Function fn); + + @Override + AsyncTask recover(Function fn); + + @Override + default AsyncTask thenAccept(Consumer fn) { + return map( + value -> { + fn.accept(value); + return null; + }); + } + + /** + * Cancels this task and everything derived from it. + * + * @return {@code true} if this call initiated cancellation (the task had not already settled). + */ + boolean cancel(); + + boolean isDone(); + + boolean isCancelled(); + + /** + * Blocks for the value; throws on failure, or {@link java.util.concurrent.CancellationException} + * on cancel. + */ + T join(); + + /** Blocks until settled and returns the outcome as a {@link Result}; never throws. */ + Result joinSettled(); + + /** + * @return the read-only cancellation token for this task. + */ + CancellationToken token(); + + /** Escape hatch to the underlying future for interop with existing APIs. */ + CompletableFuture toCompletableFuture(); +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/CancelSource.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/CancelSource.java new file mode 100644 index 0000000000..7cfd5e2777 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/CancelSource.java @@ -0,0 +1,117 @@ +package io.temporal.internal.concurrent.structured; + +import io.temporal.common.CancellationToken; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; + +/** + * The write side of cancellation. Whoever holds the {@code CancelSource} can request + * cancellation while everyone else observes using {@link #token()}. + * + *

Sources can be linked: a source created via {@link #linkedTo} is cancelled + * automatically when any of its parent tokens is cancelled, without giving the child the power to + * cancel the parent. + * + * @param the exception surfaced by the token when cancellation is requested + */ +public final class CancelSource { + + private final Object lock = new Object(); + private final Supplier defaultException; + private volatile boolean cancelled = false; + private volatile E cancellationException; + + /** Pending callbacks. Set to {@code null} by {@link #cancel} once it takes ownership. */ + private List callbacks = new ArrayList<>(); + + public CancelSource(Supplier defaultException) { + this.defaultException = defaultException; + } + + private final CancellationToken token = + new CancellationToken() { + @Override + public boolean isCancellationRequested() { + return cancelled; + } + + @Override + public void throwIfCancellationRequested() { + if (cancelled) { + throw cancellationException; + } + } + + @Override + public Registration onCancel(Runnable cb) { + synchronized (lock) { + if (!cancelled) { + callbacks.add(cb); + return () -> { + synchronized (lock) { + if (callbacks != null) { + callbacks.remove(cb); + } + } + }; + } + } + runSafely(cb); + return () -> {}; + } + }; + + /** The read-only token to hand to code that observes cancellation. */ + public CancellationToken token() { + return token; + } + + public boolean isCancelled() { + return cancelled; + } + + /** Requests cancellation with a freshly created exception. */ + public void cancel() { + cancel(defaultException.get()); + } + + /** Requests cancellation, surfacing {@code exception} from the token. Idempotent. */ + public void cancel(E exception) { + List toRun; + synchronized (lock) { + if (cancelled) { + return; + } + cancellationException = exception; + cancelled = true; + toRun = callbacks; + callbacks = null; + } + for (Runnable cb : toRun) { + runSafely(cb); + } + } + + private static void runSafely(Runnable cb) { + try { + cb.run(); + } catch (Throwable ignored) { + /* a bad callback must not block others */ + } + } + + /** + * Creates a source whose token is cancelled (via {@link #cancel()}) whenever any {@code parent} + * token is cancelled. + */ + @SafeVarargs + public static CancelSource linkedTo( + Supplier defaultException, CancellationToken... parents) { + CancelSource s = new CancelSource<>(defaultException); + for (CancellationToken p : parents) { + p.onCancel(s::cancel); + } + return s; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultAsyncTask.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultAsyncTask.java new file mode 100644 index 0000000000..f48576343b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultAsyncTask.java @@ -0,0 +1,184 @@ +package io.temporal.internal.concurrent.structured; + +import io.temporal.common.CancellationToken; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.ExecutionException; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; + +/** Reference implementation of {@link AsyncTask}. */ +final class DefaultAsyncTask implements AsyncTask { + + final CompletableFuture cf; + final CancelSource source; + private final CompletableFuture terminated; + private final Runnable cancellationHook; + private final Consumer> taskRegistrar; + private final BiConsumer, DefaultAsyncTask> resultRegistrar; + private final List> children = new CopyOnWriteArrayList<>(); + + DefaultAsyncTask( + CompletableFuture cf, + CancelSource source, + Runnable cancellationHook, + Consumer> taskRegistrar, + BiConsumer, DefaultAsyncTask> resultRegistrar) { + this.cf = cf; + this.source = source; + this.cancellationHook = cancellationHook; + this.taskRegistrar = taskRegistrar; + this.resultRegistrar = resultRegistrar; + this.terminated = cf.handle((v, e) -> (Void) null); + source + .token() + .onCancel( + () -> { + if (cancellationHook != null) { + cancellationHook.run(); + } + cf.completeExceptionally(new CancellationException()); + }); + } + + @Override + public AsyncTask map(Function fn) { + CompletableFuture next = new CompletableFuture<>(); + cf.whenComplete( + (value, error) -> { + if (error != null) { + next.completeExceptionally(error); + return; + } + if (source.token().isCancellationRequested()) { + next.completeExceptionally(new CancellationException()); + return; + } + try { + next.complete(fn.apply(value)); + } catch (Throwable t) { + next.completeExceptionally(t); + } + }); + return derive(next); + } + + @Override + public AsyncTask recover(Function fn) { + CompletableFuture next = new CompletableFuture<>(); + cf.whenComplete( + (value, error) -> { + if (error == null) { + if (source.token().isCancellationRequested()) { + next.completeExceptionally(new CancellationException()); + } else { + next.complete(value); + } + return; + } + + Throwable unwrapped = unwrap(error); + if (unwrapped instanceof CancellationException) { + next.completeExceptionally(unwrapped); + return; + } + + try { + next.complete(fn.apply(unwrapped)); + } catch (Throwable t) { + next.completeExceptionally(t); + } + }); + return derive(next); + } + + private DefaultAsyncTask derive(CompletableFuture next) { + DefaultAsyncTask child = + new DefaultAsyncTask<>( + next, + CancelSource.linkedTo(CancellationException::new, source.token()), + null, + taskRegistrar, + resultRegistrar); + children.add(child); + taskRegistrar.accept(child); + resultRegistrar.accept(this, child); + return child; + } + + @Override + public boolean cancel() { + boolean first = !cf.isDone(); + source.cancel(); + cf.completeExceptionally(new CancellationException()); + for (DefaultAsyncTask c : children) c.cancel(); + return first; + } + + @Override + public boolean isDone() { + return cf.isDone(); + } + + @Override + public boolean isCancelled() { + if (!cf.isDone()) { + return source.isCancelled(); + } + return joinSettled().isCancelled(); + } + + @Override + public T join() { + try { + return cf.join(); + } catch (CompletionException e) { + throw rethrow(unwrap(e)); + } + } + + @Override + public Result joinSettled() { + try { + return Result.success(cf.join()); + } catch (CancellationException e) { + return Result.cancelled(); + } catch (CompletionException e) { + Throwable c = unwrap(e); + return (c instanceof CancellationException) ? Result.cancelled() : Result.failure(c); + } + } + + @Override + public CancellationToken token() { + return source.token(); + } + + @Override + public CompletableFuture toCompletableFuture() { + return cf; + } + + /** Completes (normally, never exceptionally) once this task's future has settled. */ + CompletableFuture terminated() { + return terminated; + } + + static Throwable unwrap(Throwable t) { + while ((t instanceof CompletionException || t instanceof ExecutionException) + && t.getCause() != null) { + t = t.getCause(); + } + return t; + } + + static RuntimeException rethrow(Throwable t) { + if (t instanceof RuntimeException) return (RuntimeException) t; + if (t instanceof Error) throw (Error) t; + return new CompletionException(t); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultTaskScope.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultTaskScope.java new file mode 100644 index 0000000000..d330882c22 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/DefaultTaskScope.java @@ -0,0 +1,223 @@ +package io.temporal.internal.concurrent.structured; + +import io.temporal.common.CancellationToken; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.function.Function; +import javax.annotation.Nonnull; + +/** + * Reference implementation of {@link TaskScope}. Each task's {@link CancelSource} is linked to the + * scope's token, so {@link #cancelAll()} trips every task at once. + */ +final class DefaultTaskScope implements TaskScope { + + private final CancelSource scope = + new CancelSource<>(CancellationException::new); + private final AtomicBoolean closed = new AtomicBoolean(false); + private final List> ownedTasks = new CopyOnWriteArrayList<>(); + private final List> resultTasks = new CopyOnWriteArrayList<>(); + + @Override + public CancellationToken token() { + return scope.token(); + } + + @Override + public AsyncTask attach(@Nonnull CompletableFuture future) { + Objects.requireNonNull(future, "future"); + ensureOpen(); + CancelSource childSrc = + CancelSource.linkedTo(CancellationException::new, scope.token()); + DefaultAsyncTask task = + new DefaultAsyncTask<>( + future, childSrc, () -> future.cancel(true), ownedTasks::add, this::replaceResultTask); + ownedTasks.add(task); + resultTasks.add(task); + return task; + } + + private void replaceResultTask(DefaultAsyncTask parent, DefaultAsyncTask child) { + int index = resultTasks.indexOf(parent); + if (index >= 0) { + resultTasks.set(index, child); + } else { + resultTasks.add(child); + } + } + + private void ensureOpen() { + if (closed.get()) { + throw new IllegalStateException("TaskScope is closed"); + } + } + + @Override + public void cancelAll() { + scope.cancel(); + } + + @Override + public CompletableFuture awaitAll(Function, R> resultTransformer) { + CompletableFuture result = new CompletableFuture<>(); + AtomicReference errorRef = new AtomicReference<>(); + awaitTermination(0, failFastWatch(errorRef)) + .whenComplete( + (ignored, terminationError) -> { + Throwable error = errorRef.get(); + if (error != null) { + result.completeExceptionally(error); + return; + } + try { + List> collected = resultTaskSnapshot(); + List values = new ArrayList<>(collected.size()); + for (DefaultAsyncTask task : collected) { + values.add(task.join()); + } + result.complete(resultTransformer.apply(values)); + } catch (Throwable t) { + result.completeExceptionally(t); + } + }); + propagateCancellation(result); + return result; + } + + @Override + public CompletableFuture>> awaitAllSettled() { + CompletableFuture>> result = new CompletableFuture<>(); + // All-settled never fails fast, so it collects outcomes without cancelling siblings. + awaitTermination(0, null) + .whenComplete( + (ignored, terminationError) -> { + List> collected = resultTaskSnapshot(); + List> results = new ArrayList<>(collected.size()); + for (DefaultAsyncTask task : collected) { + results.add(task.joinSettled()); + } + result.complete(results); + }); + propagateCancellation(result); + return result; + } + + /** + * Completes once every owned task has settled. The owned-task list grows as tasks are derived, so + * each round re-checks and waits for any that appeared meanwhile. Never completes exceptionally. + * {@code onDiscover} runs once per task as it is first observed. + */ + private CompletableFuture awaitTermination( + int from, Consumer> onDiscover) { + List> snapshot = new ArrayList<>(ownedTasks); + if (from >= snapshot.size()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture[] terminations = new CompletableFuture[snapshot.size() - from]; + for (int i = from; i < snapshot.size(); i++) { + DefaultAsyncTask task = snapshot.get(i); + if (onDiscover != null) { + onDiscover.accept(task); + } + terminations[i - from] = task.terminated(); + } + int next = snapshot.size(); + return CompletableFuture.allOf(terminations) + .thenCompose(ignored -> awaitTermination(next, onDiscover)); + } + + /** + * Records the first failure and cancels the whole sibling group. Registered on each task as it is + * discovered, so tasks derived mid-wait fail fast too. + */ + private Consumer> failFastWatch(AtomicReference errorRef) { + return task -> + task.toCompletableFuture() + .whenComplete( + (ignored, error) -> { + if (error != null + && errorRef.compareAndSet(null, DefaultAsyncTask.unwrap(error))) { + cancelAll(); + } + }); + } + + private void propagateCancellation(CompletableFuture result) { + result.whenComplete( + (ignored, error) -> { + if (result.isCancelled()) { + cancelAll(); + } + }); + } + + @SuppressWarnings("unchecked") + private List> resultTaskSnapshot() { + return (List>) (List) new ArrayList<>(resultTasks); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + // Cancelling settles every attached and derived future synchronously; there are no threads to + // wait for. + cancelAll(); + } + + /** + * Non-blocking completion for {@link TaskScope#withScope}: when {@code body} settles, cancels the + * group on failure, waits for every task to settle, then delivers the outcome. The returned + * future does not settle until all tasks have, so no task outlives the scope. + */ + CompletableFuture closeWhenDone(CompletableFuture body) { + CompletableFuture delivered = new CompletableFuture<>(); + body.whenComplete( + (value, error) -> { + if (error != null) { + cancelAll(); + } + allTerminated() + .whenComplete( + (ignored, terminationError) -> { + closed.set(true); + if (error != null) { + delivered.completeExceptionally(DefaultAsyncTask.unwrap(error)); + } else { + delivered.complete(value); + } + }); + }); + propagateCancellation(delivered); + return delivered; + } + + private CompletableFuture allTerminated() { + return awaitTermination(0, null); + } + + /** + * Runs {@code body} against {@code scope} and ties the scope's lifetime to the returned future. + */ + static CompletableFuture run( + DefaultTaskScope scope, Function, CompletableFuture> body) { + CompletableFuture future; + try { + future = Objects.requireNonNull(body.apply(scope), "future"); + } catch (Throwable t) { + scope.close(); + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(t); + return failed; + } + return scope.closeWhenDone(future); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/README.md b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/README.md new file mode 100644 index 0000000000..33be278049 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/README.md @@ -0,0 +1,163 @@ +# Structured Concurrency + +Minimal, non-blocking, "structured concurrency" wrapper for `CompletableFuture`. No executors or +thread management. Just a wrapper that provides correctness guarantees and convenience. + +## Usage + +```java +CompletableFuture> result = + TaskScope.withScope( // 1. create a scope + scope -> { + scope.attach(startWork()); // 2. add tasks to the scope + scope.attach(startMoreWork()) + .map(value -> doDownstreamWork(value)); + + return scope.awaitAll(); // 3. collect results + }); + +result.get(); // 4. get results a usual +// or +result.cancel(true); // 5. cancel (propagates to all child tasks) +``` + +What it provides: + +1. A `withScope` boundary whose result waits for every attached task to settle before completing + normally or exceptionally, so no task outlives the scope. +2. Fan-in over a group of async operations: `awaitAll` (results in order), `awaitAll(transformer)` + (reshape the group), and `awaitAllSettled` (per-task `Result`). +3. Fail-fast: the first failure completes the scope's result with that error and cancels the rest. +4. Scope cancellation (`cancelAll`, or cancelling the returned future) propagates to every attached + task; task-chain cancellation propagates from parent stages to derived stages. +5. Cooperative cancellation via `CancellationToken`. +6. `TaskScope` methods return a `TaskChain`, not raw task handles, so tasks cannot be joined, + cancelled, or converted back to futures outside the owning scope. + +## Execution model + +`TaskScope` is an ownership boundary, not an executor. It owns task lifetime, cancellation, result +collection, and the guarantee that scoped work settles before the returned future does. It does not fork +new threads, use executors under the hood, or create any virtual thread abstractions. It's just a wrapper +over CompletableFuture. + +## Why + +```java +List> batches = getPayloadBatches(); +CompletableFuture> result = + TaskScope.withScope( scope -> { + StorageDriverStoreContext context = new StorageDriverStoreContextImpl(target, scope.token()); + + for (Batch batch : batches) { + scope + .attach(batch.driver.store(context, batch.values())) + .map(claims -> createReferencePayloads(batch, claims)); + } + return scope.awaitAll(ListUtils::flatten); + }); + +result.cancel(true); +``` + +The equivalent code using `CompletableFuture` directly has to rebuild the same ownership and cancellation rules by hand: + +```java +List> batches = getPayloadBatches(); +CancelSource cancellation = new CancelSource(); +StorageDriverStoreContext context = + new StorageDriverStoreContextImpl(target, cancellation.token()); + +List> upstream = new ArrayList<>(); +List>> downstream = new ArrayList<>(); +CompletableFuture> result = new CompletableFuture<>(); + +for (Batch batch : batches) { + CompletableFuture> storeFuture = + batch.driver.store(context, batch.values()); + upstream.add(storeFuture); + + CompletableFuture> downstreamFuture = + storeFuture.thenApply(claims -> createReferencePayloads(batch, claims)); + downstream.add(downstreamFuture); +} + +if (downstream.isEmpty()) { + result.complete(Collections.emptyList()); + return result; +} + +AtomicBoolean completed = new AtomicBoolean(false); +AtomicInteger remaining = new AtomicInteger(downstream.size()); + +Runnable cancelTracked = + () -> { + cancellation.cancel(); + for (CompletableFuture upstreamFuture : upstream) { + upstreamFuture.cancel(true); + } + for (CompletableFuture downstreamFuture : downstream) { + downstreamFuture.cancel(true); + } + }; + +Supplier> allTrackedSettled = + () -> { + List> tracked = new ArrayList<>(upstream.size() + downstream.size()); + tracked.addAll(upstream); + tracked.addAll(downstream); + return CompletableFuture + .allOf(tracked.toArray(new CompletableFuture[tracked.size()])) + .handle((ignored, terminationError) -> null); + }; + +for (CompletableFuture> downstreamFuture : downstream) { + downstreamFuture.whenComplete( + (ignored, err) -> { + if (err != null) { + if (completed.compareAndSet(false, true)) { + cancelTracked.run(); + allTrackedSettled + .get() + .whenComplete( + (unused, terminationError) -> result.completeExceptionally(err)); + } + return; + } + + if (remaining.decrementAndGet() == 0 && completed.compareAndSet(false, true)) { + allTrackedSettled + .get() + .whenComplete( + (unused, terminationError) -> { + try { + List> values = new ArrayList<>(); + for (CompletableFuture> completedDownstream : downstream) { + values.add(completedDownstream.join()); + } + result.complete(ListUtils.flatten(values)); + } catch (Throwable resultError) { + result.completeExceptionally(resultError); + } + }); + } + }); +} + +result.whenComplete( + (ignored, err) -> { + if (result.isCancelled() && completed.compareAndSet(false, true)) { + cancelTracked.run(); + } + }); + +return result; +``` + +With direct `CompletableFuture` composition, fan-out/fan-in code usually degrades into: + +- ad-hoc cancellation wiring per call site +- separate upstream and downstream tracking so scope cancellation reaches both driver work and derived stages +- manual propagation to cooperative cancellation tokens as well as `CompletableFuture.cancel(true)` +- no built-in parent boundary that owns the child set +- duplicated fail-fast, all-settled, and group-termination orchestration logic diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/Result.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/Result.java new file mode 100644 index 0000000000..c06f2de080 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/Result.java @@ -0,0 +1,105 @@ +package io.temporal.internal.concurrent.structured; + +import java.util.concurrent.CancellationException; +import java.util.function.Function; + +/** + * A settled outcome of a task: {@code SUCCESS}, {@code FAILURE}, or {@code CANCELLED}. + * + *

This is the element type returned by {@link TaskScope#awaitAllSettled}. + * + * @param the success value type + */ +public final class Result { + + public enum Status { + SUCCESS, + FAILURE, + CANCELLED + } + + private final Status status; + private final T value; + private final Throwable cause; + + private Result(Status status, T value, Throwable cause) { + this.status = status; + this.value = value; + this.cause = cause; + } + + public static Result success(T value) { + return new Result<>(Status.SUCCESS, value, null); + } + + public static Result failure(Throwable t) { + return new Result<>(Status.FAILURE, null, t); + } + + public static Result cancelled() { + return new Result<>(Status.CANCELLED, null, new CancellationException()); + } + + public Status status() { + return status; + } + + public boolean isSuccess() { + return status == Status.SUCCESS; + } + + public boolean isFailure() { + return status == Status.FAILURE; + } + + public boolean isCancelled() { + return status == Status.CANCELLED; + } + + /** + * @return the value on success; otherwise throws {@link IllegalStateException} (cause attached). + */ + public T get() { + if (status == Status.SUCCESS) return value; + throw new IllegalStateException("Result.get() attempted on unsuccessful result", cause); + } + + /** + * @return the value on success, else {@code other}. + */ + public T orElse(T other) { + return status == Status.SUCCESS ? value : other; + } + + /** + * @return the throwable for FAILURE/CANCELLED, or {@code null} for SUCCESS. + */ + public Throwable cause() { + return cause; + } + + /** Maps the success value, leaving FAILURE/CANCELLED untouched. */ + @SuppressWarnings("unchecked") + public Result map(Function fn) { + return status == Status.SUCCESS ? Result.success(fn.apply(value)) : (Result) this; + } + + /** Collapses both branches into one value. CANCELLED is routed to {@code onFailure}. */ + public R fold( + Function onSuccess, + Function onFailure) { + return status == Status.SUCCESS ? onSuccess.apply(value) : onFailure.apply(cause); + } + + @Override + public String toString() { + switch (status) { + case SUCCESS: + return "Success(" + value + ")"; + case CANCELLED: + return "Cancelled"; + default: + return "Failure(" + cause + ")"; + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskChain.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskChain.java new file mode 100644 index 0000000000..3557779a0e --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskChain.java @@ -0,0 +1,27 @@ +package io.temporal.internal.concurrent.structured; + +import java.util.function.Consumer; +import java.util.function.Function; + +/** A scope-owned continuation chain. Results are observed through the owning {@link TaskScope}. */ +public interface TaskChain { + + /** Transforms the success value once available. Failures/cancellation pass through untouched. */ + TaskChain map(Function fn); + + /** + * Supplies a fallback value if this chain fails. Unlike {@code CompletableFuture.exceptionally}, + * a cancelled chain is not recovered (cancellation propagates) and the fallback receives the + * unwrapped cause. + */ + TaskChain recover(Function fn); + + /** Runs a side effect on success and yields a {@code Void} chain. */ + default TaskChain thenAccept(Consumer fn) { + return map( + value -> { + fn.accept(value); + return null; + }); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskScope.java b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskScope.java new file mode 100644 index 0000000000..11628503d4 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/concurrent/structured/TaskScope.java @@ -0,0 +1,85 @@ +package io.temporal.internal.concurrent.structured; + +import io.temporal.common.CancellationToken; +import java.util.List; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import javax.annotation.Nonnull; + +/** + * A structured-concurrency scope that owns a group of sibling tasks and a shared cancellation + * signal, and guarantees that none of those tasks outlive the scope's completion. + * + *

{@code
+ * CompletableFuture> report = TaskScope.withScope(scope -> {
+ *     scope.attach(fetchData(1));
+ *     scope.attach(fetchData(2));
+ *
+ *     return scope.awaitAll();
+ * });
+ * }
+ */ +public interface TaskScope extends AutoCloseable { + + /** + * @return the scope-wide cancellation token (tripped by {@link #cancelAll()} or {@link + * #close()}). + */ + CancellationToken token(); + + /** + * Attaches an existing asynchronous task to this scope. Scope cancellation requests cancellation + * on the attached future. + */ + TaskChain attach(@Nonnull CompletableFuture future); + + /** Cancels every task in this scope. */ + void cancelAll(); + + /** + * Non-blocking fail-fast wait that completes with the collected task results in the order the + * tasks were attached. + * + *

This method does not close the scope. Scope lifetime remains caller-owned. + */ + default CompletableFuture> awaitAll() { + return awaitAll(Function.identity()); + } + + /** + * Non-blocking fail-fast wait that collects all task results and passes them to a transformer. + * + *

The returned future completes after all collected tasks complete successfully. Attached + * tasks are collected by default; when a collected task is transformed, the transformed child + * replaces its parent in the collected result list. On first failure or cancellation it completes + * exceptionally and cancels unfinished tasks. + * + *

This method does not close the scope. Scope lifetime remains caller-owned. + */ + CompletableFuture awaitAll(Function, R> resultTransformer); + + /** + * Non-blocking wait that completes with each collected task's settled outcome in the order the + * tasks were attached. + * + *

Task failures and cancellations are returned as {@link Result} values instead of completing + * the returned future exceptionally. + * + *

This method does not close the scope. Scope lifetime remains caller-owned. + */ + CompletableFuture>> awaitAllSettled(); + + /** Cancels all tasks; idempotent. Cancellation settles every attached and derived future. */ + @Override + void close(); + + /** + * Runs async work in a lexical scope and closes the scope when the returned future settles. The + * returned future does not complete until every task attached in {@code body} has settled, so no + * task outlives the scope. + */ + static CompletableFuture withScope(Function, CompletableFuture> body) { + return DefaultTaskScope.run(new DefaultTaskScope<>(), body); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java index 9b04bb5cd9..1379aed154 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java @@ -7,7 +7,6 @@ import com.uber.m3.tally.NoopScope; import io.grpc.Status; import io.grpc.StatusRuntimeException; -import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityInfo; import io.temporal.api.enums.v1.TimeoutType; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest; @@ -16,6 +15,7 @@ import io.temporal.client.ActivityCanceledException; import io.temporal.client.ActivityCompletionException; import io.temporal.client.WorkflowClient; +import io.temporal.common.CancellationToken; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.failure.TimeoutFailure; import io.temporal.serviceclient.WorkflowServiceStubs; @@ -367,7 +367,7 @@ private HeartbeatContextImpl createHeartbeatContext( } private static ActivityCanceledException assertCancellationFutureCompletedExceptionally( - ActivityCancellationToken cancellationToken) { + CancellationToken cancellationToken) { CompletableFuture cancellationFuture = cancellationToken.getCancellationFuture(); assertTrue(cancellationFuture.isDone()); assertTrue(cancellationFuture.isCompletedExceptionally()); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/common/ListUtilsTest.java b/temporal-sdk/src/test/java/io/temporal/internal/common/ListUtilsTest.java new file mode 100644 index 0000000000..08cabd0201 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/common/ListUtilsTest.java @@ -0,0 +1,75 @@ +package io.temporal.internal.common; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; +import org.junit.Test; + +public class ListUtilsTest { + + @Test + public void flattensNestedCollectionsInOrder() { + List flat = + ListUtils.flatten( + Arrays.asList(Arrays.asList(1, 2), Arrays.asList(3), Arrays.asList(4, 5))); + + assertEquals(Arrays.asList(1, 2, 3, 4, 5), flat); + } + + @Test + public void preservesDuplicates() { + List flat = ListUtils.flatten(Arrays.asList(Arrays.asList(1, 1), Arrays.asList(1))); + + assertEquals(Arrays.asList(1, 1, 1), flat); + } + + @Test + public void skipsEmptyInnerCollections() { + List flat = + ListUtils.flatten( + Arrays.asList( + Collections.emptyList(), + Arrays.asList(1), + Collections.emptyList())); + + assertEquals(Arrays.asList(1), flat); + } + + @Test + public void emptyOuterCollectionYieldsEmptyList() { + assertTrue(ListUtils.flatten(Collections.>emptyList()).isEmpty()); + } + + @Test + public void acceptsMixedListImplementations() { + List> nested = new ArrayList<>(); + nested.add(new LinkedList<>(Arrays.asList(1, 2))); + nested.add(new ArrayList<>(Arrays.asList(3))); + + assertEquals(Arrays.asList(1, 2, 3), ListUtils.flatten(nested)); + } + + @Test + public void returnsIndependentCopy() { + List inner = new ArrayList<>(Arrays.asList(1, 2)); + List> outer = new ArrayList<>(); + outer.add(inner); + + List flat = ListUtils.flatten(outer); + assertNotSame(inner, flat); + + // Mutating an input after flattening must not change the result. + inner.add(3); + assertEquals(Arrays.asList(1, 2), flat); + + // Mutating the result must not change the inputs. + flat.add(99); + assertEquals(Arrays.asList(1, 2, 3), inner); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/AsyncTaskTest.java b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/AsyncTaskTest.java new file mode 100644 index 0000000000..62809042fa --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/AsyncTaskTest.java @@ -0,0 +1,81 @@ +package io.temporal.internal.concurrent.structured; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +public class AsyncTaskTest { + + @Test + public void recoverDoesNotHandleCancellation() { + DefaultTaskScope scope = new DefaultTaskScope<>(); + + CompletableFuture upstream = new CompletableFuture<>(); + AsyncTask task = scope.attach(upstream); + AsyncTask recovered = task.recover(error -> 99); + + task.cancel(); + + assertTrue(task.joinSettled().isCancelled()); + assertTrue(recovered.joinSettled().isCancelled()); + } + + @Test + public void cancelReturnsFalseWhenAlreadySettledAndPreservesValue() { + DefaultTaskScope scope = new DefaultTaskScope<>(); + + CompletableFuture upstream = new CompletableFuture<>(); + AsyncTask task = scope.attach(upstream); + upstream.complete(1); + + assertFalse(task.cancel()); + assertEquals(1, task.join().intValue()); + assertFalse(task.isCancelled()); + } + + @Test + public void cancelPropagatesToUnsettledDerivedChild() { + DefaultTaskScope scope = new DefaultTaskScope<>(); + + CompletableFuture upstream = new CompletableFuture<>(); + AsyncTask parent = scope.attach(upstream); + AsyncTask child = parent.map(value -> value + 1); + + parent.cancel(); + + assertTrue(parent.joinSettled().isCancelled()); + assertTrue(child.joinSettled().isCancelled()); + } + + @Test + public void mapRecoverChainTransformsFailureIntoSuccess() { + DefaultTaskScope scope = new DefaultTaskScope<>(); + + CompletableFuture upstream = new CompletableFuture<>(); + AsyncTask task = + scope.attach(upstream).map(value -> value / 0).recover(error -> 7).map(value -> value * 2); + + upstream.complete(1); + + assertEquals(14, task.join().intValue()); + } + + @Test + public void thenAcceptRunsSideEffectAndCompletesVoid() { + DefaultTaskScope scope = new DefaultTaskScope<>(); + + AtomicReference seen = new AtomicReference<>(); + CompletableFuture upstream = new CompletableFuture<>(); + AsyncTask done = scope.attach(upstream).thenAccept(seen::set); + + upstream.complete(7); + + assertNull(done.join()); + assertEquals(Integer.valueOf(7), seen.get()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/CancelSourceTest.java b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/CancelSourceTest.java new file mode 100644 index 0000000000..c923db8e13 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/CancelSourceTest.java @@ -0,0 +1,112 @@ +package io.temporal.internal.concurrent.structured; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import io.temporal.common.CancellationToken; +import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class CancelSourceTest { + + @Test + public void cancelRunsAllCallbacksEvenIfOneThrows() { + CancelSource source = new CancelSource<>(CancellationException::new); + AtomicInteger callbacksRan = new AtomicInteger(); + + source + .token() + .onCancel( + () -> { + callbacksRan.incrementAndGet(); + throw new IllegalStateException("boom"); + }); + source.token().onCancel(callbacksRan::incrementAndGet); + + source.cancel(); + source.cancel(); + + assertEquals(2, callbacksRan.get()); + } + + @Test + public void closingRegistrationPreventsCallback() { + CancelSource source = new CancelSource<>(CancellationException::new); + AtomicInteger callbacksRan = new AtomicInteger(); + + CancellationToken.Registration registration = + source.token().onCancel(callbacksRan::incrementAndGet); + registration.close(); + + source.cancel(); + + assertEquals(0, callbacksRan.get()); + } + + @Test + public void onCancelRunsImmediatelyWhenAlreadyCancelled() { + CancelSource source = new CancelSource<>(CancellationException::new); + AtomicInteger callbacksRan = new AtomicInteger(); + + source.cancel(); + source.token().onCancel(callbacksRan::incrementAndGet); + + assertEquals(1, callbacksRan.get()); + } + + @Test + public void linkedCancellationFlowsDownstreamOnly() { + CancelSource parent = new CancelSource<>(CancellationException::new); + CancelSource child = + CancelSource.linkedTo(CancellationException::new, parent.token()); + + child.cancel(); + + assertTrue(child.isCancelled()); + assertFalse(parent.isCancelled()); + + parent.cancel(); + + assertTrue(child.isCancelled()); + assertTrue(parent.isCancelled()); + } + + @Test + public void linkedToCancelsWhenAnyParentCancels() { + CancelSource a = new CancelSource<>(CancellationException::new); + CancelSource b = new CancelSource<>(CancellationException::new); + CancelSource child = + CancelSource.linkedTo(CancellationException::new, a.token(), b.token()); + + assertFalse(child.isCancelled()); + + b.cancel(); + + assertTrue(child.isCancelled()); + assertFalse(a.isCancelled()); + } + + @Test + public void linkedToAlreadyCancelledParentCancelsImmediately() { + CancelSource parent = new CancelSource<>(CancellationException::new); + parent.cancel(); + + CancelSource child = + CancelSource.linkedTo(CancellationException::new, parent.token()); + + assertTrue(child.isCancelled()); + } + + @Test + public void registrationCloseAfterCancelIsSafe() { + CancelSource source = new CancelSource<>(CancellationException::new); + CancellationToken.Registration registration = + source.token().onCancel(() -> {}); // registered before cancel + + source.cancel(); + + registration.close(); // must not throw even though cancel() dropped the callback list + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeAndResultTest.java b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeAndResultTest.java new file mode 100644 index 0000000000..06216b2f87 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeAndResultTest.java @@ -0,0 +1,183 @@ +package io.temporal.internal.concurrent.structured; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.Test; + +public class TaskScopeAndResultTest { + + @Test + public void awaitAllAndAwaitAllSettledHandleEmptyScopes() { + try (TaskScope scope = new DefaultTaskScope<>()) { + assertTrue(scope.awaitAll().join().isEmpty()); + assertTrue(scope.awaitAllSettled().join().isEmpty()); + } + } + + @Test + public void awaitAllReturnsValuesInAttachOrder() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + scope.attach(first); + scope.attach(second); + + CompletableFuture> out = scope.awaitAll(); + + // Completion order does not affect collection order. + second.complete(2); + first.complete(1); + + assertEquals(Arrays.asList(1, 2), out.join()); + } + } + + @Test + public void awaitAllSettledPreservesPerTaskOutcome() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture ok = new CompletableFuture<>(); + CompletableFuture bad = new CompletableFuture<>(); + CompletableFuture cancelled = new CompletableFuture<>(); + scope.attach(ok); + scope.attach(bad); + scope.attach(cancelled); + + ok.complete(1); + bad.completeExceptionally(new IllegalArgumentException("bad")); + cancelled.cancel(true); + + List> results = scope.awaitAllSettled().join(); + + assertTrue(results.get(0).isSuccess()); + assertTrue(results.get(1).isFailure()); + assertTrue(results.get(2).isCancelled()); + } + } + + @Test + public void resultHelpersBehaveAcrossStates() { + Result success = Result.success(3); + Result failure = Result.failure(new IllegalArgumentException("bad")); + Result cancelled = Result.cancelled(); + + assertEquals(4, success.map(value -> value + 1).get().intValue()); + assertEquals(9, success.fold(value -> value * 3, error -> -1).intValue()); + assertEquals(7, failure.orElse(7).intValue()); + assertEquals(8, cancelled.orElse(8).intValue()); + assertFalse(failure.map(value -> value + 1).isSuccess()); + assertEquals("cancelled", cancelled.fold(value -> "success", error -> "cancelled")); + } + + @Test + public void awaitAllAppliesTransformerAcrossResults() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + scope.attach(first); + scope.attach(second); + + CompletableFuture sum = scope.awaitAll(values -> values.get(0) + values.get(1)); + + first.complete(2); + second.complete(3); + + assertEquals(5, sum.join().intValue()); + } + } + + @Test + public void awaitAllCollectsTransformedChildInsteadOfParent() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture upstream = new CompletableFuture<>(); + scope.attach(upstream).map(value -> value * 10); + + upstream.complete(1); + + // The transformed child replaces its parent in the collected results. + assertEquals(Arrays.asList(10), scope.awaitAll().join()); + } + } + + @Test + public void awaitAllSettledDoesNotCancelSiblingsOnFailure() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture good = new CompletableFuture<>(); + CompletableFuture bad = new CompletableFuture<>(); + AsyncTask goodTask = scope.attach(good); + scope.attach(bad); + + CompletableFuture>> out = scope.awaitAllSettled(); + bad.completeExceptionally(new IllegalStateException("boom")); + + // All-settled never fails fast, so the still-pending sibling is not cancelled. + assertFalse(goodTask.isCancelled()); + assertFalse(out.isDone()); + + good.complete(1); + + List> results = out.join(); + assertTrue(results.get(0).isSuccess()); + assertEquals(Integer.valueOf(1), results.get(0).get()); + assertTrue(results.get(1).isFailure()); + } + } + + @Test + public void attachMapCollectsTransformedValue() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture upstream = new CompletableFuture<>(); + scope.attach(upstream).map(value -> value + 100); + + upstream.complete(5); + + // Mirrors the production pattern: attach an external future, then transform it in-scope. + assertEquals(Arrays.asList(105), scope.awaitAll().join()); + } + } + + @Test + public void awaitAllPropagatesTransformerException() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture upstream = new CompletableFuture<>(); + scope.attach(upstream); + upstream.complete(1); + + CompletableFuture result = + scope.awaitAll( + values -> { + throw new IllegalStateException("transform"); + }); + + try { + result.join(); + fail("Expected the transformer failure to propagate"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof IllegalStateException); + } + } + } + + @Test + public void largeFanOutCollectsAllResultsInOrder() { + int taskCount = 200; + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + for (int i = 0; i < taskCount; i++) { + scope.attach(CompletableFuture.completedFuture(i)); + } + + List results = scope.awaitAll().join(); + + assertEquals(taskCount, results.size()); + for (int i = 0; i < taskCount; i++) { + assertEquals(Integer.valueOf(i), results.get(i)); + } + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeTest.java b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeTest.java new file mode 100644 index 0000000000..5025afefd6 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/concurrent/structured/TaskScopeTest.java @@ -0,0 +1,165 @@ +package io.temporal.internal.concurrent.structured; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import org.junit.Test; + +public class TaskScopeTest { + + @Test + public void awaitAllFailsFastAndCancelsSiblings() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture slow = new CompletableFuture<>(); + CompletableFuture failing = new CompletableFuture<>(); + scope.attach(slow); + scope.attach(failing); + + CompletableFuture> out = scope.awaitAll(); + failing.completeExceptionally(new IllegalStateException("boom")); + + try { + out.join(); + fail("Expected awaitAll to fail fast"); + } catch (CompletionException e) { + assertTrue(e.getCause() instanceof IllegalStateException); + } + assertTrue(slow.isCancelled()); + } + } + + @Test + public void awaitAllSettledWaitsForAllEvenWhenSomeFail() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture failing = new CompletableFuture<>(); + CompletableFuture slow = new CompletableFuture<>(); + scope.attach(failing); + scope.attach(slow); + + CompletableFuture>> out = scope.awaitAllSettled(); + failing.completeExceptionally(new IllegalStateException("boom")); + + // Must keep waiting for the still-pending sibling. + assertFalse(out.isDone()); + + slow.complete(1); + + List> results = out.join(); + assertTrue(results.get(0).isFailure()); + assertTrue(results.get(1).isSuccess()); + } + } + + @Test + public void awaitAllWaitsForTaskDerivedWhileWaiting() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture root = new CompletableFuture<>(); + TaskChain rootTask = scope.attach(root); + + CompletableFuture> out = scope.awaitAll(); + assertFalse(out.isDone()); + + // Derive a stage after awaitAll has already snapshotted the initial task set. + rootTask.map(value -> value + 1); + root.complete(1); + + assertEquals(Arrays.asList(2), out.join()); + } + } + + @Test + public void cancellingReturnedFutureCancelsScope() { + try (DefaultTaskScope scope = new DefaultTaskScope<>()) { + CompletableFuture pending = new CompletableFuture<>(); + scope.attach(pending); + + CompletableFuture awaiting = scope.awaitAll(); + awaiting.cancel(true); + + // Cancelling the future returned by awaitAll cancels the whole scope. + assertTrue(scope.token().isCancellationRequested()); + assertTrue(pending.isCancelled()); + } + } + + @Test + public void attachAfterCloseThrows() { + DefaultTaskScope scope = new DefaultTaskScope<>(); + scope.close(); + + try { + scope.attach(new CompletableFuture<>()); + fail("Expected attach on a closed scope to throw"); + } catch (IllegalStateException expected) { + // expected + } + } + + @Test + public void withScopeCompletesWithTransformedResult() throws Exception { + CompletableFuture first = new CompletableFuture<>(); + CompletableFuture second = new CompletableFuture<>(); + + CompletableFuture result = + TaskScope.withScope( + scope -> { + scope.attach(first); + scope.attach(second); + return scope.awaitAll(values -> values.get(0) + values.get(1)); + }); + + first.complete(2); + second.complete(3); + + assertEquals(5, result.get(2, TimeUnit.SECONDS).intValue()); + } + + @Test + public void withScopeFailsFastAndSettlesAfterAllTasksSettle() throws Exception { + CompletableFuture failing = new CompletableFuture<>(); + CompletableFuture sibling = new CompletableFuture<>(); + + CompletableFuture result = + TaskScope.withScope( + scope -> { + scope.attach(failing); + scope.attach(sibling); + return scope.awaitAll(ignored -> 0); + }); + + failing.completeExceptionally(new IllegalStateException("boom")); + + try { + result.get(2, TimeUnit.SECONDS); + fail("Expected the failure to propagate"); + } catch (ExecutionException e) { + assertTrue(e.getCause() instanceof IllegalStateException); + } + // Fail-fast cancels the sibling; the scope's result only settles once it has. + assertTrue(sibling.isCancelled()); + } + + @Test + public void withScopeBodyThrowingSynchronouslyCompletesFutureExceptionally() throws Exception { + CompletableFuture result = + TaskScope.withScope( + scope -> { + throw new IllegalStateException("boom"); + }); + + try { + result.get(2, TimeUnit.SECONDS); + fail("Expected the body failure to propagate"); + } catch (ExecutionException e) { + assertTrue(e.getCause() instanceof IllegalStateException); + } + } +} From b1b2d586fa5ea45b65df2a7a1a3c2d37419d5c15 Mon Sep 17 00:00:00 2001 From: mavemuri <74267563+mavemuri@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:59:50 -0700 Subject: [PATCH 050/107] NEXUS-485: Support Workflow Update as a Nexus Operation (#2945) * NEXUS-485: Support Workflow Update as a Nexus Operation * address comments, change signatures to newer * address comments 2: add all workflow exec overloads * address comments: log failed --- .github/workflows/ci.yml | 2 + .../client/RootWorkflowClientInvoker.java | 56 +- .../internal/common/InternalUtils.java | 25 + .../internal/common/LinkConverter.java | 64 ++ .../nexus/InternalNexusOperationContext.java | 15 + .../nexus/NexusOperationMetadata.java | 22 + .../internal/nexus/OperationToken.java | 35 + .../internal/nexus/OperationTokenType.java | 3 +- .../internal/nexus/OperationTokenUtil.java | 41 + .../nexus/CancelUpdateWorkflowInput.java | 37 + .../temporal/nexus/TemporalNexusClient.java | 857 ++++++++++++++++++ .../nexus/TemporalNexusClientImpl.java | 624 ++++++++++++- .../nexus/TemporalOperationHandler.java | 44 +- ...kflowClientInvokerLinkPropagationTest.java | 81 ++ .../internal/nexus/UpdateRunTokenTest.java | 106 +++ .../nexus/UpdateWorkflowOperationTest.java | 271 ++++++ 16 files changed, 2263 insertions(+), 20 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/CancelUpdateWorkflowInput.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/nexus/UpdateRunTokenTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/UpdateWorkflowOperationTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e473036051..1995170ef0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -120,6 +120,8 @@ jobs: --dynamic-config-value history.enableChasm=true \ --dynamic-config-value history.enableCHASMSignalBacklinks=true \ --dynamic-config-value history.enableTransitionHistory=true \ + --dynamic-config-value history.enableUpdateCallbacks=true \ + --dynamic-config-value history.enableCHASMCallbacks=true \ --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ --dynamic-config-value frontend.workerCommandsEnabled=true \ --dynamic-config-value system.enableCancelActivityWorkerCommand=true & diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java index 510471f2e6..570c29d251 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java @@ -23,7 +23,11 @@ import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; import io.temporal.internal.common.HeaderUtils; +import io.temporal.internal.common.InternalUtils; import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.NexusOperationMetadata; +import io.temporal.internal.nexus.OperationTokenUtil; import io.temporal.internal.worker.WorkerVersioningProtoUtils; import io.temporal.payload.context.WorkflowSerializationContext; import io.temporal.serviceclient.StatusUtils; @@ -473,6 +477,19 @@ public WorkflowUpdateHandle startUpdate(StartUpdateInput input) { } } while (updateNotYetDurable(input, result)); + // If triggered by a Nexus Operation, set necessary fields- link, result + if (CurrentNexusOperationContext.isNexusContext()) { + NexusOperationMetadata nexusOperationMetadata = + CurrentNexusOperationContext.get().getNexusOperationMetadata(); + if (nexusOperationMetadata != null) { + if (result.hasLink()) { + // add forward links for caller->handler + CurrentNexusOperationContext.get().addResponseLink(result.getLink()); + } + nexusOperationMetadata.operationCompleted = result.hasOutcome(); + } + } + return toUpdateHandle(input, result, dataConverterWithWorkflowContext); } @@ -495,14 +512,47 @@ private UpdateWorkflowExecutionRequest toUpdateWorkflowExecutionRequest( .setName(input.getUpdateName()); inputArgs.ifPresent(updateInput::setArgs); - Request request = + Request.Builder requestBuilder = Request.newBuilder() .setMeta( Meta.newBuilder() .setUpdateId(input.getUpdateId()) .setIdentity(clientOptions.getIdentity())) - .setInput(updateInput) - .build(); + .setInput(updateInput); + + // If this update is being issued via TemporalNexusClientImpl.startWorkflowUpdate, + // set the fields the server needs to deliver the Nexus completion callback + if (CurrentNexusOperationContext.isNexusContext()) { + InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.get(); + // already in a Nexus operation context, dont need to check nexusContext again + NexusOperationMetadata nexusOperationMetadata = nexusContext.getNexusOperationMetadata(); + if (nexusOperationMetadata != null) { + try { + nexusOperationMetadata.operationToken = + OperationTokenUtil.generateWorkflowUpdateOperationToken( + clientOptions.getNamespace(), + input.getWorkflowExecution().getWorkflowId(), + input.getWorkflowExecution().getRunId(), + input.getUpdateId()); + } catch (Exception e) { + throw new IllegalStateException("failed to generate update operation token", e); + } + List requestLinks = nexusContext.getRequestLinks(); + requestBuilder + .setRequestId(nexusOperationMetadata.requestId) + .addCompletionCallbacks( + InternalUtils.buildNexusCallback( + nexusOperationMetadata.callbackHeaders, + nexusOperationMetadata.callbackUrl, + nexusOperationMetadata.operationToken, + requestLinks)) + .addAllLinks(requestLinks); + } + // If no NexusOperationMetadata was found, but there is a NexusContext, then the + // update was likely trigger via Operation handler directly + } + + Request request = requestBuilder.build(); return UpdateWorkflowExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java index 72987e39c2..7a2d22b6b7 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java @@ -157,6 +157,31 @@ public static boolean isWorkflowStreamReservedName(String name) { return name.startsWith(WORKFLOW_STREAM_RESERVED_PREFIX); } + /** Helper to build a Nexus Callback from the provided input. */ + public static Callback buildNexusCallback( + Map callbackHeaders, + String callbackUrl, + String operationToken, + List links) { + Map headers = + callbackHeaders.entrySet().stream() + .collect( + Collectors.toMap( + (k) -> k.getKey().toLowerCase(), + Map.Entry::getValue, + (a, b) -> a, + TreeMap::new)); + headers.put(Header.OPERATION_TOKEN.toLowerCase(), operationToken); + Callback.Builder cbBuilder = + Callback.newBuilder() + .setNexus( + Callback.Nexus.newBuilder().setUrl(callbackUrl).putAllHeader(headers).build()); + if (links != null) { + cbBuilder.addAllLinks(links); + } + return cbBuilder.build(); + } + /** Check the method name for reserved prefixes or names. */ public static void checkMethodName(POJOWorkflowMethodMetadata methodMetadata) { boolean workflowStreamExempt = 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 1eef63af25..ce23178726 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 @@ -19,6 +19,7 @@ public class LinkConverter { private static final Logger log = LoggerFactory.getLogger(LinkConverter.class); + private static final String temporalUrlScheme = "temporal"; private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history"; private static final String nexusOperationLinkPathFormat = "temporal:///namespaces/%s/nexus-operations/%s/%s/details"; @@ -35,6 +36,7 @@ public class LinkConverter { Link.WorkflowEvent.getDescriptor().getFullName(); private static final String nexusOperationLinkType = Link.NexusOperation.getDescriptor().getFullName(); + private static final String workflowLinkType = Link.Workflow.getDescriptor().getFullName(); public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { try { @@ -93,6 +95,24 @@ public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.Workfl return null; } + public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) { + try { + String namespace = URLEncoder.encode(w.getNamespace(), StandardCharsets.UTF_8.toString()); + String workflowId = + URLEncoder.encode(w.getWorkflowId(), StandardCharsets.UTF_8.toString()) + .replace("+", "%20"); // handle workflowIds supporting spaces + String runId = URLEncoder.encode(w.getRunId(), StandardCharsets.UTF_8.toString()); + String url = String.format(linkPathFormat, namespace, workflowId, runId); + return io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl(url) + .setType(workflowLinkType) + .build(); + } catch (Exception e) { + log.error("Failed to convert WorkflowLink {} to NexusLink", w, e); + return null; + } + } + public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) { Link.Builder link = Link.newBuilder(); try { @@ -166,6 +186,44 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL return link.build(); } + public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) { + Link.Builder link = Link.newBuilder(); + try { + URI uri = new URI(nexusLink.getUrl()); + log.debug("Parsing nexus link URL: {}", uri.getRawPath()); + if (!uri.getScheme().equals(temporalUrlScheme)) { + log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); + return null; + } + StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); + // maybe add constants for "namespaces", "workflows" too + if (!st.nextToken().equals("namespaces")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.nextToken().equals("workflows")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens()) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + link.setWorkflow( + Link.Workflow.newBuilder() + .setNamespace(namespace) + .setWorkflowId(workflowID) + .setRunId(runID)); + } catch (Exception e) { + log.error("Failed to convert NexusLink {} to WorkflowLink", nexusLink, e); + return null; + } + return link.build(); + } + /** * Dispatches on the oneof variant of {@code commonLink} and converts to the matching {@link * io.temporal.api.nexus.v1.Link}. Returns {@code null} if no variant is set or encoding fails. @@ -177,6 +235,9 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { if (commonLink.hasNexusOperation()) { return nexusOperationToNexusLink(commonLink.getNexusOperation()); } + if (commonLink.hasWorkflow()) { + return workflowLinkToNexusLink(commonLink.getWorkflow()); + } return null; } @@ -192,6 +253,9 @@ public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) { if (nexusOperationLinkType.equals(type)) { return nexusLinkToNexusOperation(nexusLink); } + if (workflowLinkType.equals(type)) { + return nexusLinkToWorkflowLink(nexusLink); + } log.warn("ignoring unsupported nexus link type: {}", type); return null; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java index 0308683857..8fd807439d 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java @@ -38,6 +38,21 @@ public class InternalNexusOperationContext { private final Object responseLinksLock = new Object(); private final List responseLinks = new ArrayList<>(); + private NexusOperationMetadata nexusOperationMetadata; + + /** + * Set the Nexus operation metadata + * + * @param metadata {@link NexusOperationMetadata} to be set + */ + public void setNexusOperationMetadata(NexusOperationMetadata metadata) { + this.nexusOperationMetadata = metadata; + } + + public NexusOperationMetadata getNexusOperationMetadata() { + return nexusOperationMetadata; + } + public InternalNexusOperationContext( String namespace, String taskQueue, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java new file mode 100644 index 0000000000..50ebcb9354 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java @@ -0,0 +1,22 @@ +package io.temporal.internal.nexus; + +import io.temporal.common.Experimental; +import java.util.Map; + +/** Container for an in-flight Nexus operation metadata. */ +@Experimental +public final class NexusOperationMetadata { + public final String requestId; + public final String callbackUrl; + public final Map callbackHeaders; + + public String operationToken; + public boolean operationCompleted; + + public NexusOperationMetadata( + String requestId, String callbackUrl, Map callbackHeaders) { + this.requestId = requestId; + this.callbackUrl = callbackUrl; + this.callbackHeaders = callbackHeaders; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java index 4bd5635e93..47a8217e7f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java @@ -18,22 +18,49 @@ public class OperationToken { @JsonProperty("wid") private final String workflowId; + @JsonProperty("rid") + @JsonInclude(JsonInclude.Include.NON_NULL) + // only set for updates and activities + private final String runId; + + @JsonProperty("uid") + @JsonInclude(JsonInclude.Include.NON_NULL) + // only set for updates + private final String updateId; + public OperationToken( @JsonProperty("t") Integer type, @JsonProperty("ns") String namespace, @JsonProperty("wid") String workflowId, + @JsonProperty("rid") String runId, + @JsonProperty("uid") String updateId, @JsonProperty("v") Integer version) { this.type = OperationTokenType.fromValue(type); this.namespace = namespace; this.workflowId = workflowId; + this.runId = runId; + this.updateId = updateId; this.version = version; } + /** Generate a token for a workflow run operation */ public OperationToken(OperationTokenType type, String namespace, String workflowId) { this.type = type; this.namespace = namespace; this.workflowId = workflowId; this.version = null; + this.runId = null; + this.updateId = null; + } + + /** Generate a token for a workflow update operation */ + public OperationToken(String namespace, String workflowId, String runId, String updateId) { + this.type = OperationTokenType.WORKFLOW_UPDATE; + this.namespace = namespace; + this.workflowId = workflowId; + this.runId = runId; + this.updateId = updateId; + this.version = null; } public Integer getVersion() { @@ -51,4 +78,12 @@ public String getNamespace() { public String getWorkflowId() { return workflowId; } + + public String getUpdateId() { + return updateId; + } + + public String getRunId() { + return runId; + } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java index 11aa57a81e..4735ab34e3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java @@ -5,7 +5,8 @@ public enum OperationTokenType { UNKNOWN(0), - WORKFLOW_RUN(1); + WORKFLOW_RUN(1), + WORKFLOW_UPDATE(3); // 2 is reserved for Activities private final int value; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java index 737a84aad4..c24662728c 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java @@ -31,6 +31,9 @@ public static OperationToken loadOperationToken(String operationToken) { if (token.getVersion() != null && token.getVersion() != 0) { throw new IllegalArgumentException("Invalid operation token: unexpected version field"); } + if (Strings.isNullOrEmpty(token.getNamespace())) { + throw new IllegalArgumentException("Invalid operation token: missing namespace(ns)"); + } if (Strings.isNullOrEmpty(token.getWorkflowId())) { throw new IllegalArgumentException("Invalid operation token: missing workflow ID (wid)"); } @@ -52,6 +55,25 @@ public static OperationToken loadWorkflowRunOperationToken(String operationToken return token; } + /** + * Load a workflow update operation token, asserting that the token type is {@link + * OperationTokenType#WORKFLOW_UPDATE}. + * + * @throws IllegalArgumentException if the operation token is invalid or not a workflow update + * token + */ + public static OperationToken loadWorkflowUpdateOperationToken(String operationToken) { + OperationToken token = loadOperationToken(operationToken); + if (!token.getType().equals(OperationTokenType.WORKFLOW_UPDATE)) { + throw new IllegalArgumentException( + "Invalid workflow update token: incorrect operation token type: " + token.getType()); + } + if (Strings.isNullOrEmpty(token.getUpdateId())) { + throw new IllegalArgumentException("Invalid workflow update token: missing update ID (uid)"); + } + return token; + } + /** * Extract the workflow ID from a workflow run operation token. * @@ -70,5 +92,24 @@ public static String generateWorkflowRunOperationToken(String workflowId, String return encoder.encodeToString(json.getBytes()); } + /** Generate a workflow update operation token from namespace, workflowId, runId, updateId */ + public static String generateWorkflowUpdateOperationToken( + String namespace, String workflowId, String runId, String updateId) + throws JsonProcessingException { + if (Strings.isNullOrEmpty(namespace)) { + throw new IllegalArgumentException("Invalid workflow update token: missing namespace(ns)"); + } + if (Strings.isNullOrEmpty(workflowId)) { + throw new IllegalArgumentException( + "Invalid workflow update token: missing workflow ID (wid)"); + } + if (Strings.isNullOrEmpty(updateId)) { + throw new IllegalArgumentException("Invalid workflow update token: missing update ID (uid)"); + } + runId = Strings.emptyToNull(runId); // empty runId is allowed but should not be serialized + String json = ow.writeValueAsString(new OperationToken(namespace, workflowId, runId, updateId)); + return encoder.encodeToString(json.getBytes()); + } + private OperationTokenUtil() {} } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/CancelUpdateWorkflowInput.java b/temporal-sdk/src/main/java/io/temporal/nexus/CancelUpdateWorkflowInput.java new file mode 100644 index 0000000000..a78f0bf755 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/CancelUpdateWorkflowInput.java @@ -0,0 +1,37 @@ +package io.temporal.nexus; + +import io.temporal.common.Experimental; +import java.util.Objects; + +/** + * Input to {@link TemporalOperationHandler#cancelUpdateWorkflow} describing the workflow update to + * cancel. + */ +@Experimental +public final class CancelUpdateWorkflowInput { + + private final String workflowId; + private final String runId; + private final String updateId; + + public CancelUpdateWorkflowInput(String workflowId, String runId, String updateId) { + this.workflowId = Objects.requireNonNull(workflowId); + this.runId = Objects.requireNonNull(runId); + this.updateId = Objects.requireNonNull(updateId); + } + + /** Returns the workflow ID extracted from the operation token. */ + public String getWorkflowId() { + return workflowId; + } + + /** Returns the run ID extracted from the operation token, or empty if not present. */ + public String getRunId() { + return runId; + } + + /** Returns the update ID extracted from the operation token. */ + public String getUpdateId() { + return updateId; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java index 4eed1fe350..9d0c730e94 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java @@ -1,5 +1,8 @@ package io.temporal.nexus; +import io.nexusrpc.OperationException; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.UpdateOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; import io.temporal.common.Experimental; @@ -507,4 +510,858 @@ TemporalOperationResult startWorkflow( Type resultType, WorkflowOptions options, Object... args); + + /** + * Starts a workflow update on an existing workflow as a Nexus operation. The result is delivered + * asynchronously via the Nexus completion callback, unless the update RPC comes back already + * completed (e.g. a retried request, or a request that failed validation), in which case the + * result (or failure) is returned synchronously. + * + *

{@code updateMethod} must be an unbound method reference to a method on {@code + * workflowClass} (as opposed to {@link #startWorkflow}, which creates a new workflow, this + * targets the existing workflow identified by {@code workflowId}). + * + *

{@code options}' {@code waitForStage} has to be set to {@code WaitForStage = ACCEPTED} as + * Nexus Operations only support async Update requests. If not, the operation will be marked as + * failed. If {@code options} does not set an update ID, it defaults to the Nexus request ID which + * is consistent with other SDKs usage. + * + *

A Nexus callback URL is required for this operation; if the caller did not provide one, this + * method throws a {@code BAD_REQUEST} {@code HandlerException}. + * + *

Example: + * + *

{@code
+   * client.startWorkflowUpdate(
+   *     MyWorkflow.class, input.getWorkflowId(),
+   *     MyWorkflow::myUpdate, input.getArg(),
+   *     UpdateOptions.newBuilder(String.class)
+   *         .setUpdateName("myUpdate")
+   *         .setWaitForStage(WorkflowUpdateStage.ACCEPTED)
+   *         .build())
+   * }
+ * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func1 updateMethod, + UpdateOptions options) + throws OperationException; + + /** + * Starts a one-argument workflow update on an existing workflow as a Nexus operation. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException; + + /** + * Starts a two-argument workflow update on an existing workflow as a Nexus operation. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException; + + /** + * Starts a three-argument workflow update on an existing workflow as a Nexus operation. See + * {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full + * behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException; + + /** + * Starts a four-argument workflow update on an existing workflow as a Nexus operation. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException; + + /** + * Starts a five-argument workflow update on an existing workflow as a Nexus operation. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException; + + /** + * Starts a six-argument workflow update on an existing workflow as a Nexus operation. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param arg6 sixth update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @param the type of the sixth update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException; + + /** + * Starts a zero-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param options update options + * @param the workflow interface type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc1 updateMethod, + UpdateOptions options) + throws OperationException; + + /** + * Starts a one-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException; + + /** + * Starts a two-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException; + + /** + * Starts a three-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException; + + /** + * Starts a four-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException; + + /** + * Starts a five-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException; + + /** + * Starts a six-argument workflow update with no return value on an existing workflow as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param workflowId the ID of the existing workflow to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param arg6 sixth update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @param the type of the sixth update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException; + + // ---------- Update Workflow overloads for WorkflowExecution. Experimental, may be removed in the + // future. ---------- + + /** + * Starts a workflow update on the provided workflow execution as a Nexus operation. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func1 updateMethod, + UpdateOptions options) + throws OperationException; + + /** + * Starts a one-argument workflow update on the provided workflow execution as a Nexus operation. + * See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full + * behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException; + + /** + * Starts a two-argument workflow update on the provided workflow execution as a Nexus operation. + * See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full + * behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException; + + /** + * Starts a three-argument workflow update on the provided workflow execution as a Nexus + * operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for + * the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException; + + /** + * Starts a four-argument workflow update on the provided workflow execution as a Nexus operation. + * See {@link #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full + * behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException; + + /** + * Starts a five-argument workflow update on the provided workflow execution. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException; + + /** + * Starts a six-argument workflow update on the provided workflow execution. See {@link + * #startWorkflowUpdate(Class, String, Functions.Func1, UpdateOptions)} for the full behavior + * contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param arg6 sixth update argument + * @param options update options (must include the update result class) + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @param the type of the sixth update argument + * @param the update return type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException; + + /** + * Starts a zero-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param options update options + * @param the workflow interface type + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc1 updateMethod, + UpdateOptions options) + throws OperationException; + + /** + * Starts a one-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException; + + /** + * Starts a two-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException; + + /** + * Starts a three-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException; + + /** + * Starts a four-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException; + + /** + * Starts a five-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException; + + /** + * Starts a six-argument workflow update with no return value on the provided workflow execution + * as a Nexus operation. See {@link #startWorkflowUpdate(Class, String, Functions.Func1, + * UpdateOptions)} for the full behavior contract. + * + * @param workflowClass the workflow interface class + * @param execution the workflow execution to update + * @param updateMethod unbound method reference to the update method + * @param arg1 first update argument + * @param arg2 second update argument + * @param arg3 third update argument + * @param arg4 fourth update argument + * @param arg5 fifth update argument + * @param arg6 sixth update argument + * @param options update options + * @param the workflow interface type + * @param the type of the first update argument + * @param the type of the second update argument + * @param the type of the third update argument + * @param the type of the fourth update argument + * @param the type of the fifth update argument + * @param the type of the sixth update argument + * @return a {@link TemporalOperationResult}; sync if the update already completed, async + * (carrying the update-workflow operation token) otherwise + */ + TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException; } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java index 43e29e18f5..b314b430a3 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java @@ -1,14 +1,29 @@ package io.temporal.nexus; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.google.common.base.Strings; +import io.nexusrpc.OperationException; import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.HandlerException.RetryBehavior; import io.nexusrpc.handler.OperationContext; import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.UpdateOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowTargetOptions; +import io.temporal.client.WorkflowUpdateException; +import io.temporal.client.WorkflowUpdateHandle; +import io.temporal.client.WorkflowUpdateStage; import io.temporal.common.Experimental; import io.temporal.internal.client.NexusStartWorkflowResponse; +import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.NexusOperationMetadata; import io.temporal.internal.nexus.NexusStartWorkflowHelper; +import io.temporal.internal.nexus.OperationToken; +import io.temporal.internal.nexus.OperationTokenUtil; import io.temporal.workflow.Functions; import java.lang.reflect.Type; import java.util.Objects; @@ -244,13 +259,7 @@ public TemporalOperationResult startWorkflow( } private TemporalOperationResult invokeAndReturn(WorkflowHandle handle) { - if (!asyncOperationStarted.compareAndSet(false, true)) { - throw new HandlerException( - HandlerException.ErrorType.BAD_REQUEST, - new IllegalStateException( - "Only one async operation can be started per operation handler invocation. " - + "Use getWorkflowClient() for additional workflow interactions.")); - } + markAsyncOperationStarted(); try { NexusStartWorkflowResponse response = NexusStartWorkflowHelper.startWorkflowAndAttachLinks( @@ -265,4 +274,605 @@ private TemporalOperationResult invokeAndReturn(WorkflowHandle handle) throw t; } } + + // ---------- Update Workflow overloads ---------- + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func1 updateMethod, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1, arg2), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Func7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5, arg6), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc1 updateMethod, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1, arg2), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + String workflowId, + Functions.Proc7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException { + T stub = client.newWorkflowStub(workflowClass, workflowId); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5, arg6), effective)); + } + + /** Function that will trigger {@code startUpdate} on overloads */ + @FunctionalInterface + private interface UpdateCommand { + WorkflowUpdateHandle triggerUpdate(UpdateOptions options); + } + + /** Common code for all {@code startWorkflowUpdate} overloads. */ + private TemporalOperationResult executeUpdate( + UpdateOptions options, UpdateCommand updateWrapper) throws OperationException { + + UpdateOptions.Builder effectiveOptsBuilder = UpdateOptions.newBuilder(options); + String requestId = operationStartDetails.getRequestId(); + if (Strings.isNullOrEmpty(options.getUpdateId())) { + // if updateId is unset, use requestId - consistent with other SDKs + effectiveOptsBuilder.setUpdateId(requestId); + } + options = effectiveOptsBuilder.build(); + checkNexusUpdateOptionsValid(options); + markAsyncOperationStarted(); + + InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.get(); + try { + String callbackUrl = operationStartDetails.getCallbackUrl(); + if (Strings.isNullOrEmpty(callbackUrl)) { + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + new IllegalArgumentException("callback URL is required for a Nexus operation")); + } + NexusOperationMetadata nexusOperationMetadata = + new NexusOperationMetadata( + requestId, callbackUrl, operationStartDetails.getCallbackHeaders()); + // set the nexusOperationMetadata and capture operationCompleted + nexusContext.setNexusOperationMetadata(nexusOperationMetadata); + WorkflowUpdateHandle handle = updateWrapper.triggerUpdate(options); + if (nexusOperationMetadata.operationCompleted) { + try { + R value = handle.getResult(); + return TemporalOperationResult.sync(value); + } catch (WorkflowUpdateException e) { + // Only case where operation is completed but getResult fails is if the update + // fails non-retriably - validation failure - so fail the operation immediately + throw OperationException.failed(e); + } + } + // regenerate token so it has the actual run ID that update is running on + // previous generation is only to handle completion before handle is returned + String token = ""; + try { + OperationToken ot = + OperationTokenUtil.loadWorkflowUpdateOperationToken( + nexusOperationMetadata.operationToken); + token = + OperationTokenUtil.generateWorkflowUpdateOperationToken( + ot.getNamespace(), + ot.getWorkflowId(), + handle.getExecution().getRunId(), + ot.getUpdateId()); + } catch (IllegalArgumentException | JsonProcessingException e) { + // should not happen, this is all in SDK + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, "unexpected error reconstructing token", e); + } + return TemporalOperationResult.async(token); + } catch (Throwable t) { + // Reset on failure so that if the update RPC throws, the handler can retry without being + // blocked by the guard. + asyncOperationStarted.set(false); + throw t; + } finally { + nexusContext.setNexusOperationMetadata(null); + } + } + + /** + * @throws OperationException if the options provided are invalid like missing + * UpdateName/WorkflowID/etc + */ + private void checkNexusUpdateOptionsValid(UpdateOptions options) + throws OperationException { + if (options.getWaitForStage() != WorkflowUpdateStage.ACCEPTED) { + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, + "invalid update request", + new IllegalArgumentException( + "nexus op workflow updates only support WorkflowUpdateStageAccepted for async updates"), + RetryBehavior.RETRYABLE); + } + try { + options.validate(); + } catch (IllegalStateException e) { + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, + "invalid update request", + e, + RetryBehavior.RETRYABLE); + } + } + + private void markAsyncOperationStarted() { + if (!asyncOperationStarted.compareAndSet(false, true)) { + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + new IllegalStateException( + "Only one async operation can be started per operation handler invocation. " + + "Use getWorkflowClient() for additional workflow interactions.")); + } + } + + // ---------- Update Workflow overloads for WorkflowExecution ---------- + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func1 updateMethod, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1, arg2), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Func7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5, arg6), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc1 updateMethod, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc2 updateMethod, + A1 arg1, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc3 updateMethod, + A1 arg1, + A2 arg2, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate(() -> updateMethod.apply(stub, arg1, arg2), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc4 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc5 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc6 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5), effective)); + } + + @Override + public TemporalOperationResult startWorkflowUpdate( + Class workflowClass, + WorkflowExecution execution, + Functions.Proc7 updateMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + UpdateOptions options) + throws OperationException { + T stub = newWorkflowStub(workflowClass, execution); + return executeUpdate( + options, + effective -> + WorkflowClient.startUpdate( + () -> updateMethod.apply(stub, arg1, arg2, arg3, arg4, arg5, arg6), effective)); + } + + private T newWorkflowStub(Class workflowClass, WorkflowExecution execution) { + return client.newWorkflowStub( + workflowClass, WorkflowTargetOptions.newBuilder().setWorkflowExecution(execution).build()); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java index 6a01d11fc6..84720408e0 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java @@ -1,12 +1,12 @@ package io.temporal.nexus; +import io.nexusrpc.OperationException; import io.nexusrpc.handler.*; import io.temporal.client.WorkflowClient; import io.temporal.common.Experimental; import io.temporal.internal.nexus.CurrentNexusOperationContext; import io.temporal.internal.nexus.InternalNexusOperationContext; import io.temporal.internal.nexus.OperationToken; -import io.temporal.internal.nexus.OperationTokenType; import io.temporal.internal.nexus.OperationTokenUtil; /** @@ -48,7 +48,8 @@ public class TemporalOperationHandler implements OperationHandler { @FunctionalInterface public interface StartHandler { TemporalOperationResult apply( - TemporalOperationStartContext context, TemporalNexusClient client, T input); + TemporalOperationStartContext context, TemporalNexusClient client, T input) + throws OperationException; } private final StartHandler startHandler; @@ -70,7 +71,7 @@ public static TemporalOperationHandler create(StartHandler st @Override public final OperationStartResult start( - OperationContext ctx, OperationStartDetails details, T input) { + OperationContext ctx, OperationStartDetails details, T input) throws OperationException { InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); TemporalNexusClient client = new TemporalNexusClientImpl(nexusCtx.getWorkflowClient(), ctx, details); @@ -100,12 +101,20 @@ public final void cancel(OperationContext ctx, OperationCancelDetails details) { } TemporalOperationCancelContext cancelContext = new TemporalOperationCancelContext(ctx, details); - if (token.getType() == OperationTokenType.WORKFLOW_RUN) { - cancelWorkflowRun(cancelContext, new CancelWorkflowRunInput(token.getWorkflowId())); - } else { - throw new HandlerException( - HandlerException.ErrorType.BAD_REQUEST, - new IllegalArgumentException("unsupported operation token type: " + token.getType())); + switch (token.getType()) { + case WORKFLOW_RUN: + cancelWorkflowRun(cancelContext, new CancelWorkflowRunInput(token.getWorkflowId())); + break; + case WORKFLOW_UPDATE: + cancelUpdateWorkflow( + cancelContext, + new CancelUpdateWorkflowInput( + token.getWorkflowId(), token.getRunId(), token.getUpdateId())); + break; + default: + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, + new IllegalArgumentException("unsupported operation token type: " + token.getType())); } } @@ -123,4 +132,21 @@ protected void cancelWorkflowRun( WorkflowClient client = CurrentNexusOperationContext.get().getWorkflowClient(); client.newUntypedWorkflowStub(input.getWorkflowId()).cancel(); } + + /** + * Called when a cancel request is received for a workflow update token. Override to customize + * cancel behavior. + * + *

Default behavior: not implemented. There is no server primitive to cancel an in-flight + * workflow update. + * + * @param context the cancel context + * @param input describes the update to cancel + */ + protected void cancelUpdateWorkflow( + TemporalOperationCancelContext context, CancelUpdateWorkflowInput input) { + throw new HandlerException( + HandlerException.ErrorType.NOT_IMPLEMENTED, + new UnsupportedOperationException("cannot cancel an UpdateWorkflow operation")); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java index 85be43a8ce..a597ae96d2 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java @@ -9,25 +9,33 @@ import io.temporal.api.common.v1.Link; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.EventType; +import io.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage; +import io.temporal.api.update.v1.UpdateRef; import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest; import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse; import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionResponse; import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; import io.temporal.api.workflowservice.v1.StartWorkflowExecutionResponse; +import io.temporal.api.workflowservice.v1.UpdateWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.UpdateWorkflowExecutionResponse; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowUpdateStage; import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.StartUpdateInput; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalInput; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalWithStartInput; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowStartInput; import io.temporal.internal.client.external.GenericWorkflowClient; import io.temporal.internal.nexus.CurrentNexusOperationContext; import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.NexusOperationMetadata; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Optional; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -267,6 +275,79 @@ public void startSetsForwardLinkOnlyAndCapturesNoResponseLink() { nexusCtx.getResponseLinks().isEmpty()); } + /** + * Verify startUpdate only adds completion callback if {@link NexusOperationMetadata} is present + * on the operation context, i.e. when the update was started via {@code + * TemporalNexusClientImpl.startWorkflowUpdate} + */ + @Test + public void updateWorkflowSetCallbacksIffNexusMetadataPresent() { + nexusCtx.setNexusOperationMetadata( + new NexusOperationMetadata("rid", "temporal://dummy", Collections.emptyMap())); + + when(genericClient.update(any(UpdateWorkflowExecutionRequest.class), any())) + .thenReturn(acceptedUpdateResponse()); + + invoker.startUpdate(newStartUpdateInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UpdateWorkflowExecutionRequest.class); + org.mockito.Mockito.verify(genericClient).update(captor.capture(), any()); + Assert.assertEquals( + "expect callback to be attached when NexusOperationMetadata is present", + 1, + captor.getValue().getRequest().getCompletionCallbacksCount()); + } + + /** + * Verify plain Nexus operation handlers that don't go through {@code TemporalNexusClient} ie, + * missing {@link NexusOperationMetadata} do not have a callback attached + */ + @Test + public void updateWorkflowSkipSetCallbacksIfNexusMetadataAbsent() { + when(genericClient.update(any(UpdateWorkflowExecutionRequest.class), any())) + .thenReturn(acceptedUpdateResponse()); + + invoker.startUpdate(newStartUpdateInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UpdateWorkflowExecutionRequest.class); + org.mockito.Mockito.verify(genericClient).update(captor.capture(), any()); + Assert.assertEquals( + "expect no callback when NexusOperationMetadata is absent", + 0, + captor.getValue().getRequest().getCompletionCallbacksCount()); + } + + private static UpdateWorkflowExecutionResponse acceptedUpdateResponse() { + return UpdateWorkflowExecutionResponse.newBuilder() + .setStage( + UpdateWorkflowExecutionLifecycleStage + .UPDATE_WORKFLOW_EXECUTION_LIFECYCLE_STAGE_ACCEPTED) + .setUpdateRef( + UpdateRef.newBuilder() + .setWorkflowExecution( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).setRunId("rid")) + .setUpdateId("uid")) + .build(); + } + + private static StartUpdateInput newStartUpdateInput() { + return new StartUpdateInput<>( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).setRunId("rid").build(), + Optional.of("TestWorkflow"), + "un", + Header.empty(), + "uid", + new Object[] {" "}, + String.class, + String.class, + "", + io.temporal.api.update.v1.WaitPolicy.newBuilder() + .setLifecycleStage(WorkflowUpdateStage.ACCEPTED.getProto()) + .build()); + } + // ── helpers ────────────────────────────────────────────────────────────────────────────── private static WorkflowSignalInput newSignalInput() { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/UpdateRunTokenTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/UpdateRunTokenTest.java new file mode 100644 index 0000000000..5895d29169 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/UpdateRunTokenTest.java @@ -0,0 +1,106 @@ +package io.temporal.internal.nexus; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import java.util.Base64; +import org.junit.Assert; +import org.junit.Test; + +public class UpdateRunTokenTest { + private static final ObjectWriter ow = + new ObjectMapper().registerModule(new Jdk8Module()).writer(); + private static final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + + private static final String namespace = "ns"; + private static final String workflowId = "w"; + private static final String runId = "r"; + private static final String updateId = "u"; + + @Test + public void serializeWorkflowUpdateTokenOmitsEmptyRunId() throws JsonProcessingException { + OperationToken token = new OperationToken("ns", "wid", null, "uid"); + String json = ow.writeValueAsString(token); + JsonNode node = new ObjectMapper().readTree(json); + Assert.assertFalse(node.has("rid")); + } + + @Test + public void testEncodeDecode() throws JsonProcessingException { + String encoded = + OperationTokenUtil.generateWorkflowUpdateOperationToken( + namespace, workflowId, runId, updateId); + + OperationToken token = OperationTokenUtil.loadWorkflowUpdateOperationToken(encoded); + Assert.assertEquals(OperationTokenType.WORKFLOW_UPDATE, token.getType()); + Assert.assertEquals(namespace, token.getNamespace()); + Assert.assertEquals(workflowId, token.getWorkflowId()); + Assert.assertEquals(runId, token.getRunId()); + Assert.assertEquals(updateId, token.getUpdateId()); + Assert.assertNull(token.getVersion()); + } + + @Test + public void generateUpdateTokenRejectInvalid() { + // missing ns + Assert.assertThrows( + IllegalArgumentException.class, + () -> OperationTokenUtil.generateWorkflowUpdateOperationToken("", "w", "", "u")); + // missing workflowId + Assert.assertThrows( + IllegalArgumentException.class, + () -> OperationTokenUtil.generateWorkflowUpdateOperationToken("n", "", "", "u")); + // missing updateId + Assert.assertThrows( + IllegalArgumentException.class, + () -> OperationTokenUtil.generateWorkflowUpdateOperationToken("n", "w", "", "")); + } + + @Test + public void loadUpdateTokenRejectInvalid() { + // missing namespace + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadWorkflowUpdateOperationToken( + encoder.encodeToString( + "{\"t\":3,\"ns\":\"\",\"wid\":\"w\",\"uid\":\"u\"}".getBytes()))); + // missing workflowId + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadWorkflowUpdateOperationToken( + encoder.encodeToString( + "{\"t\":3,\"ns\":\"n\",\"wid\":\"\",\"uid\":\"u\"}".getBytes()))); + // missing updateId + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadWorkflowUpdateOperationToken( + encoder.encodeToString( + "{\"t\":3,\"ns\":\"n\",\"wid\":\"w\",\"uid\":\"\"}".getBytes()))); + } + + @Test + public void rejectInvalidUpdateTokenLoads() throws JsonProcessingException { + // loading update token from an encoded workflow run token should fail + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadWorkflowUpdateOperationToken( + OperationTokenUtil.generateWorkflowRunOperationToken("w", "n"))); + } + + @Test + public void loadWorkflowUpdateOperationTokenFromEncodedToken() { + // {"t":3,"ns":"ns","wid":"w","rid":"r","uid":"u"} + String encodedToken = "eyJ0IjozLCJucyI6Im5zIiwid2lkIjoidyIsInJpZCI6InIiLCJ1aWQiOiJ1In0"; + + OperationToken token = OperationTokenUtil.loadWorkflowUpdateOperationToken(encodedToken); + Assert.assertEquals(OperationTokenType.WORKFLOW_UPDATE, token.getType()); + Assert.assertEquals("ns", token.getNamespace()); + Assert.assertEquals("w", token.getWorkflowId()); + Assert.assertEquals("r", token.getRunId()); + Assert.assertEquals("u", token.getUpdateId()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/UpdateWorkflowOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/UpdateWorkflowOperationTest.java new file mode 100644 index 0000000000..b228d02b3d --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/UpdateWorkflowOperationTest.java @@ -0,0 +1,271 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.UpdateOptions; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowUpdateStage; +import io.temporal.failure.ApplicationFailure; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.internal.nexus.OperationToken; +import io.temporal.internal.nexus.OperationTokenType; +import io.temporal.internal.nexus.OperationTokenUtil; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import org.junit.AfterClass; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.Test; + +public class UpdateWorkflowOperationTest extends BaseNexusTest { + + private static final String asyncVal = "async"; + private static final String doneSignal = "done"; + private static final String targetWorkflowId = "update-handler-workflow-" + UUID.randomUUID(); + + @ClassRule + public static SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(CallerWorkflow.class, HandlerWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setUseTimeskipping(false) + .build(); + + private static WorkflowStub targetStub; + + @BeforeClass + public static void startTargetWorkflow() { + // start the handler workflow + targetStub = + testWorkflowRule + .getWorkflowClient() + .newUntypedWorkflowStub( + "HandlerWorkflow", + WorkflowOptions.newBuilder() + .setWorkflowId(targetWorkflowId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .build()); + targetStub.start(); + } + + @AfterClass + public static void completeTargetWorkflow() { + // complete the handler workflow + targetStub.signal(doneSignal); + } + + @Override + protected SDKTestWorkflowRule getTestWorkflowRule() { + return testWorkflowRule; + } + + @Test + public void syncValidationFailureFailsOperation() { + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions( + TestWorkflows.TestWorkflow1.class, "caller-validation-failure"); + + // empty value fails setValueValidator -> synchronous operation failure, no token issued + WorkflowFailedException e = + Assert.assertThrows( + WorkflowFailedException.class, () -> workflowStub.execute(targetWorkflowId + "|")); + + Assert.assertTrue(e.getCause() instanceof NexusOperationFailure); + NexusOperationFailure nexusFailure = (NexusOperationFailure) e.getCause(); + Assert.assertTrue(nexusFailure.getCause() instanceof ApplicationFailure); + } + + @Test + public void syncCompletedUpdateReturnsResult() { + // NOTE: this test makes no assumptions about whether update callbacks are enabled + // If enabled, the first call will resolve asynchronously due to NEXUS-489. If not, + // it resolves sync. In either case, the second call dedups and resolves synchronously + String fixedUpdateId = "fixed-update-id-" + testWorkflowRule.getTaskQueue(); + + TestWorkflows.TestWorkflow1 first = + testWorkflowRule.newWorkflowStubTimeoutOptions( + TestWorkflows.TestWorkflow1.class, "caller-dedup-completed-first"); + Assert.assertEquals( + "immediate-value", first.execute(targetWorkflowId + "|immediate-value|" + fixedUpdateId)); + + TestWorkflows.TestWorkflow1 second = + testWorkflowRule.newWorkflowStubTimeoutOptions( + TestWorkflows.TestWorkflow1.class, "caller-dedup-completed-second"); + Assert.assertEquals( + "immediate-value", + second.execute(targetWorkflowId + "|immediate-value|" + fixedUpdateId + "|dedup")); + } + + @Test + public void asyncUpdateWorkflowOperationCompletes() { + // Requires history.enableUpdateCallbacks and history.enableCHASMCallbacks + assumeTrue( + "server does not support update completion callbacks", + SDKTestWorkflowRule.useExternalService); + + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions( + TestWorkflows.TestWorkflow1.class, "caller-async-update"); + String result = workflowStub.execute(targetWorkflowId + "|" + asyncVal); + Assert.assertEquals(asyncVal, result); + } + + public static class CallerWorkflow implements TestWorkflows.TestWorkflow1 { + + @Override + public String execute(String input) { + String[] parts = input.split("\\|", 4); + String targetWorkflowId = parts[0]; + String value = parts.length > 1 ? parts[1] : ""; + String updateId = parts.length > 2 ? parts[2] : null; + boolean expectDedup = parts.length > 3 && "dedup".equals(parts[3]); + + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder() + .setEndpoint(getEndpointName()) + .setOperationOptions(options) + .build(); + + TestNexusUpdateService serviceStub = + Workflow.newNexusServiceStub(TestNexusUpdateService.class, serviceOptions); + + NexusOperationHandle handle = + Workflow.startNexusOperation( + serviceStub::update, new UpdateRequest(targetWorkflowId, value, updateId)); + + NexusOperationExecution execution = handle.getExecution().get(); + if (asyncVal.equals(value)) { + // must be issued with a token + Assert.assertTrue(execution.getOperationToken().isPresent()); + OperationToken token = + OperationTokenUtil.loadWorkflowUpdateOperationToken( + execution.getOperationToken().get()); + Assert.assertEquals(OperationTokenType.WORKFLOW_UPDATE, token.getType()); + Assert.assertEquals(targetWorkflowId, token.getWorkflowId()); + } else if (expectDedup) { + // dedup onto an already-completed update - sync, no token + Assert.assertFalse(execution.getOperationToken().isPresent()); + } + + return handle.getResult().get(); + } + } + + // Handler workflow + @WorkflowInterface + public interface HandlerWorkflow { + @WorkflowMethod + void execute(); + + @UpdateMethod(name = "setValue") + String setValue(String value); + + @UpdateValidatorMethod(updateName = "setValue") + void setValueValidator(String value); + + @SignalMethod(name = doneSignal) + void done(); + } + + public static class HandlerWorkflowImpl implements HandlerWorkflow { + private boolean completed; + + @Override + public void execute() { + Workflow.await(() -> completed); + } + + @Override + public void done() { + completed = true; + } + + @Override + public String setValue(String value) { + if (asyncVal.equals(value)) { + Workflow.sleep(Duration.ofSeconds(1)); + } + return value; + } + + @Override + public void setValueValidator(String value) { + if (value == null || value.isEmpty()) { + throw new IllegalArgumentException("value required"); + } + } + } + + // Nexus Service + @Service + public interface TestNexusUpdateService { + @Operation + String update(UpdateRequest input); + } + + @ServiceImpl(service = TestNexusUpdateService.class) + public static class TestNexusServiceImpl { + @OperationImpl + public OperationHandler update() { + return TemporalOperationHandler.create( + (context, client, input) -> { + UpdateOptions.Builder optionsBuilder = + UpdateOptions.newBuilder(String.class) + .setUpdateName("setValue") + .setWaitForStage(WorkflowUpdateStage.ACCEPTED); + if (input.getUpdateId() != null) { + optionsBuilder.setUpdateId(input.getUpdateId()); + } + return client.startWorkflowUpdate( + HandlerWorkflow.class, + input.getTargetWorkflowId(), + HandlerWorkflow::setValue, + input.getValue(), + optionsBuilder.build()); + }); + } + } + + // container for the update input + public static final class UpdateRequest { + public String targetWorkflowId; + public String value; + public String updateId; + + public UpdateRequest() {} + + public UpdateRequest(String targetWorkflowId, String value, String updateId) { + this.targetWorkflowId = targetWorkflowId; + this.value = value; + this.updateId = updateId; + } + + public String getTargetWorkflowId() { + return targetWorkflowId; + } + + public String getValue() { + return value; + } + + public String getUpdateId() { + return updateId; + } + } +} From 69a5d3fa44da4043697b7e57d7ef6691c24287cd Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Fri, 31 Jul 2026 15:02:56 -0700 Subject: [PATCH 051/107] Do not send versioning info on worker command channel polls (#2987) --- .../internal/worker/AsyncNexusPollTask.java | 22 ++++---- .../internal/worker/NexusPollTask.java | 22 ++++---- ...ivityCancellationTokenIntegrationTest.java | 53 +++++++++++++++++++ 3 files changed, 77 insertions(+), 20 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java index d83bda0be2..a53b07327a 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/AsyncNexusPollTask.java @@ -94,16 +94,18 @@ public AsyncNexusPollTask( pollRequest.setWorkerInstanceKey(workerInstanceKey); - if (versioningOptions.getWorkerDeploymentOptions() != null) { - pollRequest.setDeploymentOptions( - WorkerVersioningProtoUtils.deploymentOptionsToProto( - versioningOptions.getWorkerDeploymentOptions())); - } else if (serverCapabilities.get().getBuildIdBasedVersioning()) { - pollRequest.setWorkerVersionCapabilities( - WorkerVersionCapabilities.newBuilder() - .setBuildId(versioningOptions.getBuildId()) - .setUseVersioning(versioningOptions.isUsingVersioning()) - .build()); + if (!workerCommandsTaskQueue) { + if (versioningOptions.getWorkerDeploymentOptions() != null) { + pollRequest.setDeploymentOptions( + WorkerVersioningProtoUtils.deploymentOptionsToProto( + versioningOptions.getWorkerDeploymentOptions())); + } else if (serverCapabilities.get().getBuildIdBasedVersioning()) { + pollRequest.setWorkerVersionCapabilities( + WorkerVersionCapabilities.newBuilder() + .setBuildId(versioningOptions.getBuildId()) + .setUseVersioning(versioningOptions.isUsingVersioning()) + .build()); + } } this.pollRequest = pollRequest.build(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java index b53546cfd2..6f5c3010fd 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusPollTask.java @@ -86,16 +86,18 @@ public NexusPollTask( : TaskQueueKind.TASK_QUEUE_KIND_NORMAL)); pollRequest.setWorkerInstanceKey(workerInstanceKey); - if (versioningOptions.getWorkerDeploymentOptions() != null) { - pollRequest.setDeploymentOptions( - WorkerVersioningProtoUtils.deploymentOptionsToProto( - versioningOptions.getWorkerDeploymentOptions())); - } else if (serverCapabilities.get().getBuildIdBasedVersioning()) { - pollRequest.setWorkerVersionCapabilities( - WorkerVersionCapabilities.newBuilder() - .setBuildId(versioningOptions.getBuildId()) - .setUseVersioning(versioningOptions.isUsingVersioning()) - .build()); + if (!workerCommandsTaskQueue) { + if (versioningOptions.getWorkerDeploymentOptions() != null) { + pollRequest.setDeploymentOptions( + WorkerVersioningProtoUtils.deploymentOptionsToProto( + versioningOptions.getWorkerDeploymentOptions())); + } else if (serverCapabilities.get().getBuildIdBasedVersioning()) { + pollRequest.setWorkerVersionCapabilities( + WorkerVersionCapabilities.newBuilder() + .setBuildId(versioningOptions.getBuildId()) + .setUseVersioning(versioningOptions.isUsingVersioning()) + .build()); + } } this.pollRequest = pollRequest.build(); } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java index c6e994fa4d..d704e186b2 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java @@ -1,19 +1,29 @@ package io.temporal.workflow.activityTests.cancellation; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assume.assumeTrue; +import io.grpc.CallOptions; +import io.grpc.Channel; +import io.grpc.ClientCall; +import io.grpc.ClientInterceptor; +import io.grpc.ForwardingClientCall; +import io.grpc.MethodDescriptor; import io.temporal.activity.Activity; import io.temporal.activity.ActivityCancellationType; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityOptions; +import io.temporal.api.enums.v1.TaskQueueKind; import io.temporal.api.workflowservice.v1.DescribeNamespaceRequest; import io.temporal.api.workflowservice.v1.DescribeNamespaceResponse; +import io.temporal.api.workflowservice.v1.PollNexusTaskQueueRequest; import io.temporal.client.ActivityCanceledException; import io.temporal.client.WorkflowClientOptions; import io.temporal.failure.ActivityFailure; import io.temporal.failure.CanceledFailure; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.Async; import io.temporal.workflow.CancellationScope; @@ -25,6 +35,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -34,10 +45,18 @@ public class ActivityCancellationTokenIntegrationTest { + private final List workerCommandPollRequests = + new CopyOnWriteArrayList<>(); + @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setTestTimeoutSeconds(30) + .setWorkflowServiceStubsOptions( + WorkflowServiceStubsOptions.newBuilder() + .addGrpcClientInterceptor( + new WorkerCommandPollRecordingInterceptor(workerCommandPollRequests)) + .build()) .setWorkflowClientOptions( WorkflowClientOptions.newBuilder() .setWorkerHeartbeatInterval(Duration.ofSeconds(1)) @@ -69,11 +88,45 @@ public void checkServerSupportsWorkerCommands() { } @Test + @SuppressWarnings("deprecation") public void activityObservesCancellationWithoutHeartbeat() { TestCancellationWorkflow workflow = testWorkflowRule.newWorkflowStub(TestCancellationWorkflow.class); assertEquals("cancelled", workflow.execute(testWorkflowRule.getTaskQueue())); + + assertFalse("Expected a worker command Nexus poll", workerCommandPollRequests.isEmpty()); + for (PollNexusTaskQueueRequest request : workerCommandPollRequests) { + assertFalse(request.hasDeploymentOptions()); + assertFalse(request.hasWorkerVersionCapabilities()); + } + } + + private static class WorkerCommandPollRecordingInterceptor implements ClientInterceptor { + private final List workerCommandPollRequests; + + private WorkerCommandPollRecordingInterceptor( + List workerCommandPollRequests) { + this.workerCommandPollRequests = workerCommandPollRequests; + } + + @Override + public ClientCall interceptCall( + MethodDescriptor method, CallOptions callOptions, Channel next) { + return new ForwardingClientCall.SimpleForwardingClientCall( + next.newCall(method, callOptions)) { + @Override + public void sendMessage(ReqT message) { + if (message instanceof PollNexusTaskQueueRequest) { + PollNexusTaskQueueRequest request = (PollNexusTaskQueueRequest) message; + if (request.getTaskQueue().getKind() == TaskQueueKind.TASK_QUEUE_KIND_WORKER_COMMANDS) { + workerCommandPollRequests.add(request); + } + } + super.sendMessage(message); + } + }; + } } @WorkflowInterface From 1dabe5d773feaedaef66a2a71b1347dc20e666d3 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Sun, 2 Aug 2026 22:42:39 -0700 Subject: [PATCH 052/107] Add Standalone Activities to Temporal Nexus Operation Handler (#2918) * Enable Nexus activity operations without regressing workflow updates Compose standalone activity support with the workflow-update Nexus model already present on master. Shared token, callback, link, client, and cancellation paths retain both operation families. Constraint: Preserve the workflow-update token and API contracts from master Rejected: Choose one conflict side | each side would drop a supported Nexus operation family Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep activity execution at token type 2 and workflow update at token type 3 Tested: Focused temporal-sdk token, link, invoker, client, async activity, and cancellation tests Not-tested: Full repository suite and real-server-only standalone activity cases * Make sure we test attaching * Make sure to handle ActivityAlreadyStartedException * Add test with SANO * Bump test server --- .github/workflows/ci.yml | 3 +- .../temporal/client/ActivityClientImpl.java | 8 +- .../client/ActivityClientInternal.java | 15 + .../client/NexusStartActivityRequest.java | 82 ++++ .../client/NexusStartActivityResponse.java | 35 ++ .../client/RootActivityClientInvoker.java | 51 ++- .../client/RootWorkflowClientInvoker.java | 2 +- .../internal/common/InternalUtils.java | 63 ++-- .../internal/common/LinkConverter.java | 75 ++++ .../nexus/InternalNexusOperationContext.java | 22 +- .../nexus/NexusOperationMetadata.java | 2 +- .../nexus/NexusStartActivityHelper.java | 57 +++ .../internal/nexus/NexusTaskHandlerImpl.java | 4 +- .../internal/nexus/OperationToken.java | 45 ++- .../internal/nexus/OperationTokenType.java | 3 +- .../internal/nexus/OperationTokenUtil.java | 72 +++- .../nexus/CancelActivityExecutionInput.java | 38 ++ .../temporal/nexus/TemporalNexusClient.java | 355 ++++++++++++++++++ .../nexus/TemporalNexusClientImpl.java | 304 +++++++++++++++ .../nexus/TemporalOperationHandler.java | 41 +- .../client/nexus/NexusClientTest.java | 151 +++++++- .../client/RootActivityClientInvokerTest.java | 183 +++++++++ .../internal/common/LinkConverterTest.java | 80 ++++ ...TokenTest.java => OperationTokenTest.java} | 97 ++++- .../nexus/TemporalNexusClientImplTest.java | 303 +++++++++++++++ .../ActivityHandleFailOnConflictTest.java | 143 +++++++ ...tivityHandleUseExistingOnConflictTest.java | 169 +++++++++ .../nexus/AsyncActivityOperationTest.java | 103 +++++ .../CancelActivityAsyncOperationTest.java | 264 +++++++++++++ 29 files changed, 2702 insertions(+), 68 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientInternal.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityRequest.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityResponse.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/CancelActivityExecutionInput.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java rename temporal-sdk/src/test/java/io/temporal/internal/nexus/{WorkflowRunTokenTest.java => OperationTokenTest.java} (57%) create mode 100644 temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1995170ef0..339a274928 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: - name: Start CLI server env: - TEMPORAL_CLI_VERSION: 1.7.2-standalone-nexus-operations + TEMPORAL_CLI_VERSION: 1.7.4-standalone-nexus-operations run: | wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz tar -xzf temporal_cli.tar.gz @@ -115,6 +115,7 @@ jobs: --dynamic-config-value 'callback.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ --dynamic-config-value frontend.activityAPIsEnabled=true \ --dynamic-config-value activity.enableStandalone=true \ + --dynamic-config-value activity.enableCallbacks=true \ --dynamic-config-value activity.startDelayEnabled=true \ --dynamic-config-value nexusoperation.enableStandalone=true \ --dynamic-config-value history.enableChasm=true \ diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityClientImpl.java index efcc9df698..77bf2bd794 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityClientImpl.java @@ -6,6 +6,7 @@ import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.common.interceptors.ActivityClientInterceptor; import io.temporal.common.interceptors.Header; +import io.temporal.internal.client.ActivityClientInternal; import io.temporal.internal.client.ActivityHandleImpl; import io.temporal.internal.client.RootActivityClientInvoker; import io.temporal.internal.client.external.GenericWorkflowClientImpl; @@ -27,7 +28,7 @@ * Implementation of {@link ActivityClient} that delegates calls through the activity interceptor * chain and ultimately to the Temporal service. */ -class ActivityClientImpl implements ActivityClient { +class ActivityClientImpl implements ActivityClient, ActivityClientInternal { private final WorkflowServiceStubs stubs; private final ActivityClientOptions options; @@ -56,6 +57,11 @@ private static ActivityClientCallsInterceptor initializeClientInvoker( return invoker; } + @Override + public ActivityClientCallsInterceptor getInvoker() { + return invoker; + } + // ---- Interface-based start (Proc variants) ---- @Override diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientInternal.java new file mode 100644 index 0000000000..95de2bfc1f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientInternal.java @@ -0,0 +1,15 @@ +package io.temporal.internal.client; + +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; + +/** + * Internal-only view of an {@code ActivityClient} that exposes its invocation chain. + * + *

Lives in {@code io.temporal.internal.client} so that other internal SDK packages (e.g. {@code + * io.temporal.nexus}) can route a fully-constructed {@link + * ActivityClientCallsInterceptor.StartActivityInput} through an internal client without forcing the + * concrete implementation class to be public. + */ +public interface ActivityClientInternal { + ActivityClientCallsInterceptor getInvoker(); +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityRequest.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityRequest.java new file mode 100644 index 0000000000..4de1626408 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityRequest.java @@ -0,0 +1,82 @@ +package io.temporal.internal.client; + +import io.nexusrpc.Link; +import io.temporal.client.StartActivityOptions; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.Header; +import java.util.List; +import java.util.Map; + +/** + * Request used to start an activity from a Nexus operation handler. Mirrors {@link + * NexusStartWorkflowRequest} but carries the activity-specific scheduling payload. + */ +@Experimental +public final class NexusStartActivityRequest { + private final String requestId; + private final String callbackUrl; + private final Map callbackHeaders; + private final String taskQueue; + private final List links; + private final String activityType; + private final List args; + private final StartActivityOptions options; + private final Header header; + + public NexusStartActivityRequest( + String requestId, + String callbackUrl, + Map callbackHeaders, + String taskQueue, + List links, + String activityType, + List args, + StartActivityOptions options, + Header header) { + this.requestId = requestId; + this.callbackUrl = callbackUrl; + this.callbackHeaders = callbackHeaders; + this.taskQueue = taskQueue; + this.links = links; + this.activityType = activityType; + this.args = args; + this.options = options; + this.header = header; + } + + public String getRequestId() { + return requestId; + } + + public String getCallbackUrl() { + return callbackUrl; + } + + public Map getCallbackHeaders() { + return callbackHeaders; + } + + public String getTaskQueue() { + return taskQueue; + } + + public List getLinks() { + return links; + } + + public String getActivityType() { + return activityType; + } + + public List getArgs() { + return args; + } + + public StartActivityOptions getOptions() { + return options; + } + + public Header getHeader() { + return header; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityResponse.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityResponse.java new file mode 100644 index 0000000000..6971ee550e --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusStartActivityResponse.java @@ -0,0 +1,35 @@ +package io.temporal.internal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nullable; + +/** + * Response returned from starting an activity via {@link NexusStartActivityRequest}. Mirrors {@link + * NexusStartWorkflowResponse}. + */ +@Experimental +public final class NexusStartActivityResponse { + private final String activityId; + private final @Nullable String runId; + private final String operationToken; + + public NexusStartActivityResponse( + String activityId, @Nullable String runId, String operationToken) { + this.activityId = activityId; + this.runId = runId; + this.operationToken = operationToken; + } + + public String getActivityId() { + return activityId; + } + + @Nullable + public String getRunId() { + return runId; + } + + public String getOperationToken() { + return operationToken; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index 96a9e70f5a..3d4b1155d3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -3,12 +3,15 @@ import static io.temporal.internal.common.RetryOptionsUtils.toRetryPolicy; import static io.temporal.internal.common.WorkflowExecutionUtils.makeUserMetaData; +import com.google.common.base.Strings; import com.google.common.collect.Iterators; import io.grpc.Deadline; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.temporal.api.activity.v1.ActivityExecutionOutcome; import io.temporal.api.common.v1.ActivityType; +import io.temporal.api.common.v1.Callback; +import io.temporal.api.common.v1.Link; import io.temporal.api.common.v1.Payloads; import io.temporal.api.errordetails.v1.ActivityExecutionAlreadyStartedFailure; import io.temporal.api.sdk.v1.UserMetadata; @@ -19,9 +22,13 @@ import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; import io.temporal.internal.common.HeaderUtils; +import io.temporal.internal.common.InternalUtils; import io.temporal.internal.common.ProtoConverters; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.SearchAttributesUtil; +import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.NexusOperationMetadata; import io.temporal.serviceclient.StatusUtils; import java.lang.reflect.Type; import java.util.*; @@ -49,12 +56,19 @@ public RootActivityClientInvoker( public StartActivityOutput startActivity(StartActivityInput input) { StartActivityOptions options = input.getOptions(); DataConverter dc = clientOptions.getDataConverter(); + InternalNexusOperationContext nexusContext = + CurrentNexusOperationContext.isNexusContext() ? CurrentNexusOperationContext.get() : null; + NexusOperationMetadata nexusOperationMetadata = + nexusContext == null ? null : nexusContext.getNexusOperationMetadata(); StartActivityExecutionRequest.Builder request = StartActivityExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) .setIdentity(clientOptions.getIdentity()) - .setRequestId(UUID.randomUUID().toString()) + .setRequestId( + nexusOperationMetadata == null + ? UUID.randomUUID().toString() + : nexusOperationMetadata.requestId) .setActivityId(options.getId()) .setActivityType(ActivityType.newBuilder().setName(input.getActivityType()).build()) .setTaskQueue(TaskQueue.newBuilder().setName(options.getTaskQueue()).build()) @@ -104,6 +118,37 @@ public StartActivityOutput startActivity(StartActivityInput input) { io.temporal.api.common.v1.Header grpcHeader = HeaderUtils.toHeaderGrpc(input.getHeader(), null); request.setHeader(grpcHeader); + if (nexusOperationMetadata != null) { + List protoLinks = nexusContext.getRequestLinks(); + request.addAllLinks(protoLinks); + request.setOnConflictOptions( + io.temporal.api.common.v1.OnConflictOptions.newBuilder() + .setAttachRequestId(true) + .setAttachLinks(true) + .setAttachCompletionCallbacks(true)); + // Generate the operation token from the user-supplied activity ID and namespace so the + // dual OPERATION_ID + OPERATION_TOKEN headers can be injected before the start RPC fires. + try { + nexusOperationMetadata.operationToken = + io.temporal.internal.nexus.OperationTokenUtil.generateActivityExecutionOperationToken( + options.getId(), clientOptions.getNamespace()); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new io.nexusrpc.handler.HandlerException( + io.nexusrpc.handler.HandlerException.ErrorType.BAD_REQUEST, + "failed to generate activity operation token", + e); + } + if (!Strings.isNullOrEmpty(nexusOperationMetadata.callbackUrl)) { + Callback cb = + InternalUtils.buildNexusCallback( + nexusOperationMetadata.callbackUrl, + nexusOperationMetadata.callbackHeaders, + nexusOperationMetadata.operationToken, + protoLinks); + request.addCompletionCallbacks(cb); + } + } + StartActivityExecutionResponse response; try { response = genericClient.startActivity(request.build()); @@ -120,6 +165,10 @@ public StartActivityOutput startActivity(StartActivityInput input) { throw e; } + if (nexusOperationMetadata != null && response.hasLink()) { + nexusContext.addResponseLink(response.getLink()); + } + String runId = response.getRunId().isEmpty() ? null : response.getRunId(); return new StartActivityOutput(options.getId(), runId); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java index 570c29d251..502c12e8ee 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java @@ -542,8 +542,8 @@ private UpdateWorkflowExecutionRequest toUpdateWorkflowExecutionRequest( .setRequestId(nexusOperationMetadata.requestId) .addCompletionCallbacks( InternalUtils.buildNexusCallback( - nexusOperationMetadata.callbackHeaders, nexusOperationMetadata.callbackUrl, + nexusOperationMetadata.callbackHeaders, nexusOperationMetadata.operationToken, requestLinks)) .addAllLinks(requestLinks); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java index 7a2d22b6b7..08679ca103 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/InternalUtils.java @@ -104,35 +104,10 @@ public static NexusWorkflowStarter createNexusBoundStub( // If a callback URL is provided, pass it as a completion callback. if (!Strings.isNullOrEmpty(request.getCallbackUrl())) { - // Add the Nexus operation ID to the headers if it is not already present to support - // fabricating - // a NexusOperationStarted event if the completion is received before the response to a - // StartOperation request. - Map headers = - request.getCallbackHeaders().entrySet().stream() - .collect( - Collectors.toMap( - (k) -> k.getKey().toLowerCase(), - Map.Entry::getValue, - (a, b) -> a, - () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER))); - if (!headers.containsKey(Header.OPERATION_ID)) { - headers.put(Header.OPERATION_ID.toLowerCase(), operationToken); - } - if (!headers.containsKey(Header.OPERATION_TOKEN)) { - headers.put(Header.OPERATION_TOKEN.toLowerCase(), operationToken); - } - Callback.Builder cbBuilder = - Callback.newBuilder() - .setNexus( - Callback.Nexus.newBuilder() - .setUrl(request.getCallbackUrl()) - .putAllHeader(headers) - .build()); - if (links != null) { - cbBuilder.addAllLinks(links); - } - nexusWorkflowOptions.setCompletionCallbacks(Collections.singletonList(cbBuilder.build())); + Callback cb = + buildNexusCallback( + request.getCallbackUrl(), request.getCallbackHeaders(), operationToken, links); + nexusWorkflowOptions.setCompletionCallbacks(Collections.singletonList(cb)); } if (options.getTaskQueue() == null) { @@ -157,12 +132,23 @@ public static boolean isWorkflowStreamReservedName(String name) { return name.startsWith(WORKFLOW_STREAM_RESERVED_PREFIX); } - /** Helper to build a Nexus Callback from the provided input. */ + /** + * Builds a {@link Callback} for use as a Nexus completion callback. Injects both the legacy + * {@code Nexus-Operation-Id} and the newer {@code Nexus-Operation-Token} headers + * (case-insensitive lookup) when not already present so the server can fabricate + * operation-started events if the completion is received before the response to a StartOperation + * request. + * + *

Shared by the workflow start path ({@link #createNexusBoundStub}) and the activity start + * path ({@code RootActivityClientInvoker.startActivity}). The dual {@code OPERATION_ID} + {@code + * OPERATION_TOKEN} headers must be injected before the start RPC is issued. + */ + @SuppressWarnings("deprecation") // Check the OPERATION_ID header for backwards compatibility public static Callback buildNexusCallback( - Map callbackHeaders, String callbackUrl, + Map callbackHeaders, String operationToken, - List links) { + List protoLinks) { Map headers = callbackHeaders.entrySet().stream() .collect( @@ -170,14 +156,19 @@ public static Callback buildNexusCallback( (k) -> k.getKey().toLowerCase(), Map.Entry::getValue, (a, b) -> a, - TreeMap::new)); - headers.put(Header.OPERATION_TOKEN.toLowerCase(), operationToken); + () -> new TreeMap<>(String.CASE_INSENSITIVE_ORDER))); + if (!headers.containsKey(Header.OPERATION_ID)) { + headers.put(Header.OPERATION_ID.toLowerCase(), operationToken); + } + if (!headers.containsKey(Header.OPERATION_TOKEN)) { + headers.put(Header.OPERATION_TOKEN.toLowerCase(), operationToken); + } Callback.Builder cbBuilder = Callback.newBuilder() .setNexus( Callback.Nexus.newBuilder().setUrl(callbackUrl).putAllHeader(headers).build()); - if (links != null) { - cbBuilder.addAllLinks(links); + if (protoLinks != null) { + cbBuilder.addAllLinks(protoLinks); } return cbBuilder.build(); } 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 ce23178726..54b3cdc728 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 @@ -23,6 +23,8 @@ public class LinkConverter { private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history"; private static final String nexusOperationLinkPathFormat = "temporal:///namespaces/%s/nexus-operations/%s/%s/details"; + private static final String activityLinkPathFormat = + "temporal:///namespaces/%s/activities/%s/%s/details"; private static final String linkReferenceTypeKey = "referenceType"; private static final String linkEventIDKey = "eventID"; private static final String linkEventTypeKey = "eventType"; @@ -37,6 +39,7 @@ public class LinkConverter { private static final String nexusOperationLinkType = Link.NexusOperation.getDescriptor().getFullName(); private static final String workflowLinkType = Link.Workflow.getDescriptor().getFullName(); + private static final String activityLinkType = Link.Activity.getDescriptor().getFullName(); public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { try { @@ -238,6 +241,9 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { if (commonLink.hasWorkflow()) { return workflowLinkToNexusLink(commonLink.getWorkflow()); } + if (commonLink.hasActivity()) { + return activityToNexusLink(commonLink.getActivity()); + } return null; } @@ -256,10 +262,79 @@ public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) { if (workflowLinkType.equals(type)) { return nexusLinkToWorkflowLink(nexusLink); } + if (activityLinkType.equals(type)) { + return nexusLinkToActivity(nexusLink); + } log.warn("ignoring unsupported nexus link type: {}", type); return null; } + public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity activity) { + try { + String url = + String.format( + activityLinkPathFormat, + URLEncoder.encode(activity.getNamespace(), StandardCharsets.UTF_8.toString()), + URLEncoder.encode(activity.getActivityId(), StandardCharsets.UTF_8.toString()) + .replace("+", "%20"), + URLEncoder.encode(activity.getRunId(), StandardCharsets.UTF_8.toString())); + return io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl(url) + .setType(activityLinkType) + .build(); + } catch (Exception e) { + log.error("Failed to encode activity Nexus link URL", e); + } + return null; + } + + public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) { + if (!activityLinkType.equals(nexusLink.getType())) { + log.error( + "Failed to parse Nexus link URL: cannot parse link type {} to {}", + nexusLink.getType(), + activityLinkType); + return null; + } + Link.Builder link = Link.newBuilder(); + try { + URI uri = new URI(nexusLink.getUrl()); + if (!"temporal".equals(uri.getScheme())) { + log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); + return null; + } + StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); + if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens() || !st.nextToken().equals("activities")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String activityId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens()) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { + log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + return null; + } + link.setActivity( + Link.Activity.newBuilder() + .setNamespace(namespace) + .setActivityId(activityId) + .setRunId(runId)); + } catch (Exception e) { + log.error("Failed to parse activity Nexus link URL", e); + return null; + } + return link.build(); + } + public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) { try { String url = diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java index 8fd807439d..97ab4f5ed2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java @@ -40,19 +40,6 @@ public class InternalNexusOperationContext { private NexusOperationMetadata nexusOperationMetadata; - /** - * Set the Nexus operation metadata - * - * @param metadata {@link NexusOperationMetadata} to be set - */ - public void setNexusOperationMetadata(NexusOperationMetadata metadata) { - this.nexusOperationMetadata = metadata; - } - - public NexusOperationMetadata getNexusOperationMetadata() { - return nexusOperationMetadata; - } - public InternalNexusOperationContext( String namespace, String taskQueue, @@ -97,6 +84,15 @@ public NexusOperationContext getUserFacingContext() { return new NexusOperationContextImpl(); } + /** Sets metadata for the Temporal primitive backing the current Nexus operation. */ + public void setNexusOperationMetadata(NexusOperationMetadata metadata) { + this.nexusOperationMetadata = metadata; + } + + public NexusOperationMetadata getNexusOperationMetadata() { + return nexusOperationMetadata; + } + /** * Set the {@code common.v1.Link}s extracted from the inbound Nexus task so they can be attached * to RPCs issued by the operation handler. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java index 50ebcb9354..99b601fff9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusOperationMetadata.java @@ -3,7 +3,7 @@ import io.temporal.common.Experimental; import java.util.Map; -/** Container for an in-flight Nexus operation metadata. */ +/** Container for in-flight Nexus operation metadata. */ @Experimental public final class NexusOperationMetadata { public final String requestId; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java new file mode 100644 index 0000000000..9cf346c8f7 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java @@ -0,0 +1,57 @@ +package io.temporal.internal.nexus; + +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.client.StartActivityOptions; +import io.temporal.common.Experimental; +import io.temporal.common.interceptors.Header; +import io.temporal.internal.client.NexusStartActivityRequest; +import io.temporal.internal.client.NexusStartActivityResponse; +import java.util.List; +import java.util.function.Function; + +/** Shared helper for starting an activity from a Nexus operation. */ +@Experimental +public class NexusStartActivityHelper { + + /** + * Starts an activity via the provided invoker function and returns the response. The root + * activity invoker records the link from {@code StartActivityExecutionResponse}; the Nexus task + * handler attaches that link to the operation response. + * + * @param ctx the operation context + * @param details the operation start details containing requestId, callback, links + * @param activityType the activity type name + * @param args the activity arguments + * @param options the activity scheduling options (must include task queue, ID) + * @param header the propagated header + * @param invoker function that starts the activity given a {@link NexusStartActivityRequest} + * @return the {@link NexusStartActivityResponse} containing the activity ID and operation token + */ + public static NexusStartActivityResponse startActivityAndAttachLinks( + OperationContext ctx, + OperationStartDetails details, + String activityType, + List args, + StartActivityOptions options, + Header header, + Function invoker) { + InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); + + NexusStartActivityRequest nexusRequest = + new NexusStartActivityRequest( + details.getRequestId(), + details.getCallbackUrl(), + details.getCallbackHeaders(), + nexusCtx.getTaskQueue(), + details.getLinks(), + activityType, + args, + options, + header); + + return invoker.apply(nexusRequest); + } + + private NexusStartActivityHelper() {} +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index adaecca330..92af2f6a84 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -10,6 +10,7 @@ import io.nexusrpc.handler.*; import io.temporal.api.common.v1.Payload; import io.temporal.api.nexus.v1.*; +import io.temporal.client.ActivityAlreadyStartedException; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowException; import io.temporal.client.WorkflowNotFoundException; @@ -192,7 +193,8 @@ private CancelOperationResponse handleCancelledOperation( private void convertKnownFailures(Throwable e) { Throwable failure = CheckedExceptionWrapper.unwrap(e); - if (failure instanceof WorkflowException) { + if (failure instanceof WorkflowException + || failure instanceof ActivityAlreadyStartedException) { if (failure instanceof WorkflowNotFoundException) { throw new HandlerException(HandlerException.ErrorType.NOT_FOUND, failure); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java index 47a8217e7f..176168a1f9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationToken.java @@ -1,5 +1,6 @@ package io.temporal.internal.nexus; +import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; @@ -16,28 +17,34 @@ public class OperationToken { private final String namespace; @JsonProperty("wid") + @JsonInclude(JsonInclude.Include.NON_NULL) private final String workflowId; + @JsonProperty("aid") + @JsonInclude(JsonInclude.Include.NON_NULL) + private final String activityId; + @JsonProperty("rid") @JsonInclude(JsonInclude.Include.NON_NULL) - // only set for updates and activities private final String runId; @JsonProperty("uid") @JsonInclude(JsonInclude.Include.NON_NULL) - // only set for updates private final String updateId; + @JsonCreator public OperationToken( @JsonProperty("t") Integer type, @JsonProperty("ns") String namespace, @JsonProperty("wid") String workflowId, + @JsonProperty("aid") String activityId, @JsonProperty("rid") String runId, @JsonProperty("uid") String updateId, @JsonProperty("v") Integer version) { this.type = OperationTokenType.fromValue(type); this.namespace = namespace; this.workflowId = workflowId; + this.activityId = activityId; this.runId = runId; this.updateId = updateId; this.version = version; @@ -48,9 +55,30 @@ public OperationToken(OperationTokenType type, String namespace, String workflow this.type = type; this.namespace = namespace; this.workflowId = workflowId; - this.version = null; + this.activityId = null; this.runId = null; this.updateId = null; + this.version = null; + } + + public OperationToken( + OperationTokenType type, String namespace, String workflowId, String activityId) { + this(type, namespace, workflowId, activityId, null); + } + + public OperationToken( + OperationTokenType type, + String namespace, + String workflowId, + String activityId, + String runId) { + this.type = type; + this.namespace = namespace; + this.workflowId = workflowId; + this.activityId = activityId; + this.runId = runId; + this.updateId = null; + this.version = null; } /** Generate a token for a workflow update operation */ @@ -58,6 +86,7 @@ public OperationToken(String namespace, String workflowId, String runId, String this.type = OperationTokenType.WORKFLOW_UPDATE; this.namespace = namespace; this.workflowId = workflowId; + this.activityId = null; this.runId = runId; this.updateId = updateId; this.version = null; @@ -83,6 +112,16 @@ public String getUpdateId() { return updateId; } + public String getActivityId() { + return activityId; + } + + /** + * Returns the activity run ID embedded in the token, or {@code null} if absent. + * + *

For activity-execution tokens, the run ID is only present after the start RPC completes. + * Workflow-update tokens may also carry a run ID. + */ public String getRunId() { return runId; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java index 4735ab34e3..a7afe11b18 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenType.java @@ -6,7 +6,8 @@ public enum OperationTokenType { UNKNOWN(0), WORKFLOW_RUN(1), - WORKFLOW_UPDATE(3); // 2 is reserved for Activities + ACTIVITY_EXECUTION(2), + WORKFLOW_UPDATE(3); private final int value; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java index c24662728c..a97d75c7c5 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/OperationTokenUtil.java @@ -34,8 +34,21 @@ public static OperationToken loadOperationToken(String operationToken) { if (Strings.isNullOrEmpty(token.getNamespace())) { throw new IllegalArgumentException("Invalid operation token: missing namespace(ns)"); } - if (Strings.isNullOrEmpty(token.getWorkflowId())) { - throw new IllegalArgumentException("Invalid operation token: missing workflow ID (wid)"); + switch (token.getType()) { + case WORKFLOW_RUN: + case WORKFLOW_UPDATE: + if (Strings.isNullOrEmpty(token.getWorkflowId())) { + throw new IllegalArgumentException("Invalid operation token: missing workflow ID (wid)"); + } + break; + case ACTIVITY_EXECUTION: + if (Strings.isNullOrEmpty(token.getActivityId())) { + throw new IllegalArgumentException("Invalid operation token: missing activity ID (aid)"); + } + break; + default: + throw new IllegalArgumentException( + "Invalid operation token: unknown operation token type: " + token.getType()); } return token; } @@ -83,6 +96,31 @@ public static String loadWorkflowIdFromOperationToken(String operationToken) { return loadWorkflowRunOperationToken(operationToken).getWorkflowId(); } + /** + * Load an activity execution operation token, asserting that the token type is {@link + * OperationTokenType#ACTIVITY_EXECUTION}. + * + * @throws IllegalArgumentException if the operation token is invalid or not an activity execution + * token + */ + public static OperationToken loadActivityExecutionOperationToken(String operationToken) { + OperationToken token = loadOperationToken(operationToken); + if (!token.getType().equals(OperationTokenType.ACTIVITY_EXECUTION)) { + throw new IllegalArgumentException( + "Invalid activity execution token: incorrect operation token type: " + token.getType()); + } + return token; + } + + /** + * Extract the activity ID from an activity execution operation token. + * + * @throws IllegalArgumentException if the operation token is invalid + */ + public static String loadActivityIdFromOperationToken(String operationToken) { + return loadActivityExecutionOperationToken(operationToken).getActivityId(); + } + /** Generate a workflow run operation token from a workflow ID and namespace. */ public static String generateWorkflowRunOperationToken(String workflowId, String namespace) throws JsonProcessingException { @@ -111,5 +149,35 @@ public static String generateWorkflowUpdateOperationToken( return encoder.encodeToString(json.getBytes()); } + /** + * Generate an activity execution operation token from an activity ID and namespace. + * + *

This overload omits the run ID. Use it when writing the token into the Nexus operation-token + * callback header — that token is generated before the start RPC completes, so the run ID is not + * yet known. + */ + public static String generateActivityExecutionOperationToken(String activityId, String namespace) + throws JsonProcessingException { + return generateActivityExecutionOperationToken(activityId, null, namespace); + } + + /** + * Generate an activity execution operation token from an activity ID, run ID, and namespace. The + * {@code runId} is included only when non-null. + * + *

This overload is used for the operation token returned to the Nexus caller from a start + * operation — at that point the start RPC has completed and the run ID is known. The header token + * written into the activity completion callback must NOT carry a run ID; use {@link + * #generateActivityExecutionOperationToken(String, String)} for that path. + */ + public static String generateActivityExecutionOperationToken( + String activityId, String runId, String namespace) throws JsonProcessingException { + String json = + ow.writeValueAsString( + new OperationToken( + OperationTokenType.ACTIVITY_EXECUTION, namespace, null, activityId, runId)); + return encoder.encodeToString(json.getBytes()); + } + private OperationTokenUtil() {} } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/CancelActivityExecutionInput.java b/temporal-sdk/src/main/java/io/temporal/nexus/CancelActivityExecutionInput.java new file mode 100644 index 0000000000..6794033817 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/nexus/CancelActivityExecutionInput.java @@ -0,0 +1,38 @@ +package io.temporal.nexus; + +import io.temporal.common.Experimental; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Input to {@link TemporalOperationHandler#cancelActivityExecution} describing the activity + * execution to cancel. + */ +@Experimental +public final class CancelActivityExecutionInput { + + private final String activityId; + private final @Nullable String runId; + + public CancelActivityExecutionInput(String activityId, @Nullable String runId) { + this.activityId = Objects.requireNonNull(activityId); + this.runId = runId; + } + + /** Returns the activity ID extracted from the operation token. */ + public String getActivityId() { + return activityId; + } + + /** + * Returns the activity run ID extracted from the operation token, or {@code null} if absent. + * + *

Run ID is only present on operation tokens that were generated by this SDK AFTER the start + * activity RPC completed. Tokens originating from the activity completion callback header do not + * carry a run ID. + */ + @Nullable + public String getRunId() { + return runId; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java index 9d0c730e94..0d9d1f302a 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java @@ -2,6 +2,7 @@ import io.nexusrpc.OperationException; import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.StartActivityOptions; import io.temporal.client.UpdateOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; @@ -1364,4 +1365,358 @@ TemporalOperationResult startWorkflowUpdate( A6 arg6, UpdateOptions options) throws OperationException; + + // ---------- Activity overloads ---------- + + /** + * Starts a zero-argument activity that returns a value. + * + *

Example: + * + *

{@code
+   * client.startActivity(MyActivity.class, MyActivity::run, options)
+   * }
+ * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func1 activityMethod, + StartActivityOptions options); + + /** + * Starts a one-argument activity that returns a value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func2 activityMethod, + A1 arg1, + StartActivityOptions options); + + /** + * Starts a two-argument activity that returns a value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func3 activityMethod, + A1 arg1, + A2 arg2, + StartActivityOptions options); + + /** + * Starts a three-argument activity that returns a value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func4 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + StartActivityOptions options); + + /** + * Starts a four-argument activity that returns a value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param arg4 fourth activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the type of the fourth activity argument + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func5 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + StartActivityOptions options); + + /** + * Starts a five-argument activity that returns a value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param arg4 fourth activity argument + * @param arg5 fifth activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the type of the fourth activity argument + * @param the type of the fifth activity argument + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func6 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + StartActivityOptions options); + + /** + * Starts a six-argument activity that returns a value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param arg4 fourth activity argument + * @param arg5 fifth activity argument + * @param arg6 sixth activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the type of the fourth activity argument + * @param the type of the fifth activity argument + * @param the type of the sixth activity argument + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func7 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + StartActivityOptions options); + + /** + * Starts a zero-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, Functions.Proc1 activityMethod, StartActivityOptions options); + + /** + * Starts a one-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc2 activityMethod, + A1 arg1, + StartActivityOptions options); + + /** + * Starts a two-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc3 activityMethod, + A1 arg1, + A2 arg2, + StartActivityOptions options); + + /** + * Starts a three-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc4 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + StartActivityOptions options); + + /** + * Starts a four-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param arg4 fourth activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the type of the fourth activity argument + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc5 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + StartActivityOptions options); + + /** + * Starts a five-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param arg4 fourth activity argument + * @param arg5 fifth activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the type of the fourth activity argument + * @param the type of the fifth activity argument + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc6 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + StartActivityOptions options); + + /** + * Starts a six-argument activity with no return value. + * + * @param activityInterface the activity interface class + * @param activityMethod unbound method reference to the activity method + * @param arg1 first activity argument + * @param arg2 second activity argument + * @param arg3 third activity argument + * @param arg4 fourth activity argument + * @param arg5 fifth activity argument + * @param arg6 sixth activity argument + * @param options activity start options (must include taskQueue) + * @param the activity interface type + * @param the type of the first activity argument + * @param the type of the second activity argument + * @param the type of the third activity argument + * @param the type of the fourth activity argument + * @param the type of the fifth activity argument + * @param the type of the sixth activity argument + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc7 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + StartActivityOptions options); + + /** + * Starts an activity using an untyped activity type name. + * + *

Example: + * + *

{@code
+   * client.startActivity("MyActivity", String.class, options, input)
+   * }
+ * + * @param activityType the activity type name string + * @param resultClass the expected result class + * @param options activity start options (must include taskQueue) + * @param args activity arguments + * @param the activity return type + * @return an async {@link TemporalOperationResult} with the activity-execution operation token + */ + TemporalOperationResult startActivity( + String activityType, Class resultClass, StartActivityOptions options, Object... args); } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java index b314b430a3..d962d38770 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClientImpl.java @@ -7,7 +7,11 @@ import io.nexusrpc.handler.HandlerException.RetryBehavior; import io.nexusrpc.handler.OperationContext; import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.api.common.v1.Payload; import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.StartActivityOptions; import io.temporal.client.UpdateOptions; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowOptions; @@ -17,15 +21,28 @@ import io.temporal.client.WorkflowUpdateHandle; import io.temporal.client.WorkflowUpdateStage; import io.temporal.common.Experimental; +import io.temporal.common.context.ContextPropagator; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.common.interceptors.Header; +import io.temporal.internal.client.ActivityClientInternal; +import io.temporal.internal.client.NexusStartActivityResponse; import io.temporal.internal.client.NexusStartWorkflowResponse; import io.temporal.internal.nexus.CurrentNexusOperationContext; import io.temporal.internal.nexus.InternalNexusOperationContext; import io.temporal.internal.nexus.NexusOperationMetadata; +import io.temporal.internal.nexus.NexusStartActivityHelper; import io.temporal.internal.nexus.NexusStartWorkflowHelper; import io.temporal.internal.nexus.OperationToken; import io.temporal.internal.nexus.OperationTokenUtil; +import io.temporal.internal.util.MethodExtractor; import io.temporal.workflow.Functions; +import java.lang.reflect.Method; import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; @@ -875,4 +892,291 @@ private T newWorkflowStub(Class workflowClass, WorkflowExecution executio return client.newWorkflowStub( workflowClass, WorkflowTargetOptions.newBuilder().setWorkflowExecution(execution).build()); } + + // ---------- Activity overloads (Func returning) ---------- + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func1 activityMethod, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Collections.emptyList(), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func2 activityMethod, + A1 arg1, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Collections.singletonList(arg1), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func3 activityMethod, + A1 arg1, + A2 arg2, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func4 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2, arg3), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func5 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2, arg3, arg4), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func6 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2, arg3, arg4, arg5), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Func7 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl( + activityType, Arrays.asList(arg1, arg2, arg3, arg4, arg5, arg6), options); + } + + // ---------- Activity overloads (Proc void) ---------- + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, Functions.Proc1 activityMethod, StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Collections.emptyList(), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc2 activityMethod, + A1 arg1, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Collections.singletonList(arg1), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc3 activityMethod, + A1 arg1, + A2 arg2, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc4 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2, arg3), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc5 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2, arg3, arg4), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc6 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl(activityType, Arrays.asList(arg1, arg2, arg3, arg4, arg5), options); + } + + @Override + public TemporalOperationResult startActivity( + Class activityInterface, + Functions.Proc7 activityMethod, + A1 arg1, + A2 arg2, + A3 arg3, + A4 arg4, + A5 arg5, + A6 arg6, + StartActivityOptions options) { + Method method = MethodExtractor.extract(activityInterface, activityMethod); + String activityType = MethodExtractor.activityTypeName(activityInterface, method); + return startActivityImpl( + activityType, Arrays.asList(arg1, arg2, arg3, arg4, arg5, arg6), options); + } + + // ---------- Activity untyped ---------- + + @Override + public TemporalOperationResult startActivity( + String activityType, Class resultClass, StartActivityOptions options, Object... args) { + List argList = args == null ? Collections.emptyList() : Arrays.asList(args); + return startActivityImpl(activityType, argList, options); + } + + private TemporalOperationResult startActivityImpl( + String activityType, List args, StartActivityOptions options) { + markAsyncOperationStarted(); + InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.get(); + try { + NexusOperationMetadata nexusOperationMetadata = + new NexusOperationMetadata( + operationStartDetails.getRequestId(), + operationStartDetails.getCallbackUrl(), + operationStartDetails.getCallbackHeaders()); + nexusContext.setNexusOperationMetadata(nexusOperationMetadata); + NexusStartActivityResponse response = + NexusStartActivityHelper.startActivityAndAttachLinks( + operationContext, + operationStartDetails, + activityType, + args, + options, + propagatedHeader(), + request -> { + ActivityClientCallsInterceptor.StartActivityInput input = + new ActivityClientCallsInterceptor.StartActivityInput( + request.getActivityType(), + request.getArgs(), + request.getOptions(), + request.getHeader()); + // Build an internal ActivityClient aligned with the surrounding WorkflowClient. + // User-configured standalone ActivityClient interceptors are not available in the + // Nexus operation-handler lifecycle. + ActivityClient activityClient = + ActivityClient.newInstance( + client.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder() + .setNamespace(client.getOptions().getNamespace()) + .setDataConverter(client.getOptions().getDataConverter()) + .setIdentity(client.getOptions().getIdentity()) + .build()); + ActivityClientCallsInterceptor.StartActivityOutput out = + ((ActivityClientInternal) activityClient).getInvoker().startActivity(input); + // The invoker generated the runId-free token before the start RPC and injected it + // into the callback headers when a callback URL was supplied. That token cannot + // include a run ID because the run ID isn't known until after the start RPC + // returns. The operation token returned to the Nexus caller can — and should — + // include it, so it's regenerated here from the same activity ID + the run ID the + // start RPC produced. + String headerToken = nexusOperationMetadata.operationToken; + if (headerToken == null) { + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, + "invoker did not generate a Nexus operation token for activity start", + new IllegalStateException( + "operationToken is null on NexusOperationMetadata after activity start")); + } + String returnToken; + try { + returnToken = + OperationTokenUtil.generateActivityExecutionOperationToken( + out.getActivityId(), + out.getActivityRunId(), + client.getOptions().getNamespace()); + } catch (JsonProcessingException e) { + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, + "failed to generate activity operation token", + e); + } + return new NexusStartActivityResponse( + out.getActivityId(), out.getActivityRunId(), returnToken); + }); + return TemporalOperationResult.async(response.getOperationToken()); + } catch (Throwable t) { + // Reset on failure so that if the activity start throws, the handler can retry without + // being blocked by the guard. + asyncOperationStarted.set(false); + throw t; + } finally { + nexusContext.setNexusOperationMetadata(null); + } + } + + private Header propagatedHeader() { + List propagators = client.getOptions().getContextPropagators(); + if (propagators.isEmpty()) { + return Header.empty(); + } + Map result = new HashMap<>(); + for (ContextPropagator propagator : propagators) { + result.putAll(propagator.serializeContext(propagator.getCurrentContext())); + } + return new Header(result); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java index 84720408e0..9f14a1346a 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java @@ -2,6 +2,8 @@ import io.nexusrpc.OperationException; import io.nexusrpc.handler.*; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; import io.temporal.client.WorkflowClient; import io.temporal.common.Experimental; import io.temporal.internal.nexus.CurrentNexusOperationContext; @@ -11,7 +13,8 @@ /** * Generic Nexus operation handler backed by Temporal. Implements {@link OperationHandler} and - * provides a composable way to map Temporal operations (start workflow, etc.) to Nexus operations. + * provides a composable way to map Temporal operations (start workflow, start activity, etc.) to + * Nexus operations. * *

Usage example: * @@ -30,8 +33,10 @@ * } * *

This class supports subclassing to customize cancel behavior. Override {@link - * #cancelWorkflowRun} to change how workflow-run cancellations are handled. The {@link #start} and - * {@link #cancel} methods should not be overridden — they contain the core dispatch logic. + * #cancelWorkflowRun} to change how workflow-run (token type {@code t:1}) cancellations are + * handled, or {@link #cancelActivityExecution} to change how activity-execution (token type {@code + * t:2}) cancellations are handled. The {@link #start} and {@link #cancel} methods should not be + * overridden — they contain the core dispatch logic. * * @param the input type * @param the result type @@ -60,7 +65,7 @@ protected TemporalOperationHandler(StartHandler startHandler) { /** * Creates a {@link TemporalOperationHandler} from a start handler. Subclass and override {@link - * #cancelWorkflowRun} to customize cancel behavior. + * #cancelWorkflowRun} or {@link #cancelActivityExecution} to customize cancel behavior. * * @param startHandler the handler to invoke on start operation requests * @return an operation handler backed by the given start handler @@ -111,6 +116,11 @@ public final void cancel(OperationContext ctx, OperationCancelDetails details) { new CancelUpdateWorkflowInput( token.getWorkflowId(), token.getRunId(), token.getUpdateId())); break; + case ACTIVITY_EXECUTION: + cancelActivityExecution( + cancelContext, + new CancelActivityExecutionInput(token.getActivityId(), token.getRunId())); + break; default: throw new HandlerException( HandlerException.ErrorType.BAD_REQUEST, @@ -149,4 +159,27 @@ protected void cancelUpdateWorkflow( HandlerException.ErrorType.NOT_IMPLEMENTED, new UnsupportedOperationException("cannot cancel an UpdateWorkflow operation")); } + + /** + * Called when a cancel request is received for an activity-execution token (type=2). Override to + * customize cancel behavior. + * + *

Default behavior: requests cancellation of the underlying standalone activity execution. + * + * @param context the cancel context + * @param input describes the activity execution to cancel + */ + protected void cancelActivityExecution( + TemporalOperationCancelContext context, CancelActivityExecutionInput input) { + WorkflowClient wc = CurrentNexusOperationContext.get().getWorkflowClient(); + ActivityClient ac = + ActivityClient.newInstance( + wc.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder() + .setNamespace(wc.getOptions().getNamespace()) + .setDataConverter(wc.getOptions().getDataConverter()) + .setIdentity(wc.getOptions().getIdentity()) + .build()); + ac.getHandle(input.getActivityId(), input.getRunId()).cancel(); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index e5f3b36f3e..0046a321d4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -2,13 +2,30 @@ import static org.junit.Assume.assumeTrue; +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.common.v1.Link; +import io.temporal.api.enums.v1.ActivityExecutionStatus; +import io.temporal.api.enums.v1.NexusOperationExecutionStatus; import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.NexusClient; import io.temporal.client.NexusOperationExecutionCount; +import io.temporal.client.NexusOperationExecutionDescription; import io.temporal.client.NexusOperationExecutionMetadata; +import io.temporal.client.StartActivityOptions; import io.temporal.client.StartNexusOperationOptions; import io.temporal.client.UntypedNexusOperationHandle; import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.nexus.TemporalOperationHandler; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.shared.EchoNexusServiceImpl; import io.temporal.workflow.shared.TestNexusServices; @@ -17,6 +34,8 @@ import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import org.junit.Assert; import org.junit.Before; @@ -25,11 +44,16 @@ public class NexusClientTest { + private final AtomicInteger activityInvocationCount = new AtomicInteger(); + private final AtomicReference observedActivityId = new AtomicReference<>(); + private final AtomicReference activityRunId = new AtomicReference<>(); + @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setWorkflowTypes(NexusClientTest.PlaceholderWorkflowImpl.class) - .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .setActivityImplementations(new LinkingActivityImpl()) + .setNexusServiceImplementation(new EchoNexusServiceImpl(), new ActivityNexusServiceImpl()) .build(); @Before @@ -112,6 +136,91 @@ public void runStandaloneNexusOperation() throws Exception { Assert.assertTrue(countNexusOperations() > initialCount); } + @Test + public void standaloneNexusOperationStartsActivity() throws Exception { + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient serviceClient = + testWorkflowRule + .getNexusClient() + .newUntypedNexusServiceClient( + endpoint.getSpec().getName(), ActivityNexusService.class.getSimpleName()); + + String operationId = "operation-" + UUID.randomUUID(); + String activityId = "activity-" + UUID.randomUUID(); + UntypedNexusOperationHandle operationHandle = + serviceClient.start( + "operation", + StartNexusOperationOptions.newBuilder() + .setId(operationId) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(), + activityId); + String operationRunId = operationHandle.getNexusOperationRunId(); + Assert.assertNotNull("expected SANO run id to be populated by start", operationRunId); + + Assert.assertEquals( + "completed " + activityId, operationHandle.getResult(30, TimeUnit.SECONDS, String.class)); + Assert.assertEquals( + "the activity should execute exactly once", 1, activityInvocationCount.get()); + Assert.assertEquals(activityId, observedActivityId.get()); + + String capturedActivityRunId = activityRunId.get(); + Assert.assertNotNull( + "expected the activity implementation to observe its run id", capturedActivityRunId); + ActivityClient activityClient = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + ActivityExecutionDescription activityDescription = + activityClient.getHandle(activityId, capturedActivityRunId).describe(); + Assert.assertEquals(activityId, activityDescription.getActivityId()); + Assert.assertEquals(capturedActivityRunId, activityDescription.getActivityRunId()); + Assert.assertEquals( + ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_COMPLETED, + activityDescription.getStatus()); + Assert.assertEquals(testWorkflowRule.getTaskQueue(), activityDescription.getTaskQueue()); + + Link.NexusOperation forwardLink = null; + for (Link link : activityDescription.getRawInfo().getLinksList()) { + if (link.hasNexusOperation()) { + forwardLink = link.getNexusOperation(); + break; + } + } + Assert.assertNotNull( + "expected Link.NexusOperation on the standalone activity execution", forwardLink); + String namespace = testWorkflowRule.getWorkflowClient().getOptions().getNamespace(); + Assert.assertEquals(namespace, forwardLink.getNamespace()); + Assert.assertEquals(operationId, forwardLink.getOperationId()); + Assert.assertEquals(operationRunId, forwardLink.getRunId()); + + NexusOperationExecutionDescription operationDescription = operationHandle.describe(); + Assert.assertEquals(operationId, operationDescription.getOperationId()); + Assert.assertEquals(operationRunId, operationDescription.getRunId()); + Assert.assertEquals(endpoint.getSpec().getName(), operationDescription.getEndpoint()); + Assert.assertEquals( + ActivityNexusService.class.getSimpleName(), operationDescription.getService()); + Assert.assertEquals("operation", operationDescription.getOperation()); + Assert.assertEquals( + NexusOperationExecutionStatus.NEXUS_OPERATION_EXECUTION_STATUS_COMPLETED, + operationDescription.getStatus()); + + Link.Activity backwardLink = null; + for (Link link : operationDescription.getRawInfo().getLinksList()) { + if (link.hasActivity() && activityId.equals(link.getActivity().getActivityId())) { + backwardLink = link.getActivity(); + break; + } + } + Assert.assertNotNull( + "expected Link.Activity on the standalone Nexus operation execution", backwardLink); + Assert.assertEquals(namespace, backwardLink.getNamespace()); + Assert.assertEquals(activityId, backwardLink.getActivityId()); + Assert.assertEquals(capturedActivityRunId, backwardLink.getRunId()); + } + @Test public void listNexusOperationExecutionsWithQueryFiltersResults() throws Exception { // Run a known operation through to completion, then assert that an OperationId-scoped query @@ -247,4 +356,44 @@ public String execute(String input) { return input; } } + + @ActivityInterface + public interface LinkingActivity { + @ActivityMethod + String execute(String activityId); + } + + public class LinkingActivityImpl implements LinkingActivity { + @Override + public String execute(String activityId) { + activityInvocationCount.incrementAndGet(); + observedActivityId.set(Activity.getExecutionContext().getInfo().getActivityId()); + activityRunId.set(Activity.getExecutionContext().getInfo().getActivityRunId()); + return "completed " + activityId; + } + } + + @Service + public interface ActivityNexusService { + @Operation + String operation(String activityId); + } + + @ServiceImpl(service = ActivityNexusService.class) + public class ActivityNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, activityId) -> + client.startActivity( + LinkingActivity.class, + LinkingActivity::execute, + activityId, + StartActivityOptions.newBuilder() + .setId(activityId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build())); + } + } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java new file mode 100644 index 0000000000..a80932dcc1 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java @@ -0,0 +1,183 @@ +package io.temporal.internal.client; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.uber.m3.tally.NoopScope; +import io.temporal.api.common.v1.Link; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.workflowservice.v1.StartActivityExecutionRequest; +import io.temporal.api.workflowservice.v1.StartActivityExecutionResponse; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor.StartActivityInput; +import io.temporal.common.interceptors.Header; +import io.temporal.internal.client.external.GenericWorkflowClient; +import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.internal.nexus.NexusOperationMetadata; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +/** Unit tests for Nexus metadata propagation by {@link RootActivityClientInvoker}. */ +public class RootActivityClientInvokerTest { + + private static final String NAMESPACE = "test-namespace"; + + private GenericWorkflowClient genericClient; + private RootActivityClientInvoker invoker; + private InternalNexusOperationContext nexusContext; + + @Before + public void setUp() { + genericClient = mock(GenericWorkflowClient.class); + when(genericClient.startActivity(any(StartActivityExecutionRequest.class))) + .thenReturn( + StartActivityExecutionResponse.newBuilder() + .setRunId("activity-run-id") + .setLink(activityLink()) + .build()); + invoker = + new RootActivityClientInvoker( + genericClient, + ActivityClientOptions.newBuilder() + .setNamespace(NAMESPACE) + .setIdentity("test-identity") + .build()); + nexusContext = + new InternalNexusOperationContext( + NAMESPACE, + "test-task-queue", + "test-endpoint", + new NoopScope(), + mock(WorkflowClient.class)); + CurrentNexusOperationContext.set(nexusContext); + } + + @After + public void tearDown() { + CurrentNexusOperationContext.unset(); + } + + @Test + public void nexusMetadataAddsCallbackLinksAndRequestId() { + Map callbackHeaders = new HashMap<>(); + callbackHeaders.put("Custom-Header", "value"); + NexusOperationMetadata metadata = + new NexusOperationMetadata( + "nexus-request-id", "http://localhost/callback", callbackHeaders); + nexusContext.setNexusOperationMetadata(metadata); + Link link = workflowEventLink(); + nexusContext.setRequestLinks(Collections.singletonList(link)); + + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient).startActivity(captor.capture()); + StartActivityExecutionRequest request = captor.getValue(); + Assert.assertEquals("nexus-request-id", request.getRequestId()); + Assert.assertEquals(Collections.singletonList(link), request.getLinksList()); + Assert.assertEquals(1, request.getCompletionCallbacksCount()); + Assert.assertTrue(request.getOnConflictOptions().getAttachRequestId()); + Assert.assertTrue(request.getOnConflictOptions().getAttachLinks()); + Assert.assertTrue(request.getOnConflictOptions().getAttachCompletionCallbacks()); + Assert.assertEquals( + "http://localhost/callback", request.getCompletionCallbacks(0).getNexus().getUrl()); + Assert.assertEquals( + "value", request.getCompletionCallbacks(0).getNexus().getHeaderOrThrow("custom-header")); + Assert.assertNotNull(metadata.operationToken); + Assert.assertEquals( + metadata.operationToken, + request + .getCompletionCallbacks(0) + .getNexus() + .getHeaderOrThrow(io.nexusrpc.Header.OPERATION_TOKEN.toLowerCase())); + Assert.assertEquals(Collections.singletonList(activityLink()), nexusContext.getResponseLinks()); + } + + @Test + public void nexusMetadataWithEmptyCallbackUrlOmitsCompletionCallback() { + NexusOperationMetadata metadata = + new NexusOperationMetadata( + "nexus-request-id", "", Collections.singletonMap("Custom-Header", "value")); + nexusContext.setNexusOperationMetadata(metadata); + Link link = workflowEventLink(); + nexusContext.setRequestLinks(Collections.singletonList(link)); + + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient).startActivity(captor.capture()); + StartActivityExecutionRequest request = captor.getValue(); + Assert.assertEquals("nexus-request-id", request.getRequestId()); + Assert.assertEquals(Collections.singletonList(link), request.getLinksList()); + Assert.assertEquals(0, request.getCompletionCallbacksCount()); + Assert.assertTrue(request.getOnConflictOptions().getAttachRequestId()); + Assert.assertTrue(request.getOnConflictOptions().getAttachLinks()); + Assert.assertTrue(request.getOnConflictOptions().getAttachCompletionCallbacks()); + Assert.assertNotNull(metadata.operationToken); + Assert.assertEquals(Collections.singletonList(activityLink()), nexusContext.getResponseLinks()); + } + + @Test + public void nexusContextWithoutMetadataStartsOrdinaryActivity() { + nexusContext.setRequestLinks(Collections.singletonList(workflowEventLink())); + + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient).startActivity(captor.capture()); + StartActivityExecutionRequest request = captor.getValue(); + Assert.assertFalse(request.getRequestId().isEmpty()); + Assert.assertEquals(0, request.getLinksCount()); + Assert.assertEquals(0, request.getCompletionCallbacksCount()); + Assert.assertFalse(request.hasOnConflictOptions()); + Assert.assertTrue(nexusContext.getResponseLinks().isEmpty()); + } + + private static StartActivityInput newStartActivityInput() { + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("activity-id") + .setTaskQueue("test-task-queue") + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + return new StartActivityInput("TestActivity", Collections.emptyList(), options, Header.empty()); + } + + private static Link workflowEventLink() { + return Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId("caller-workflow-id") + .setRunId("caller-run-id") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventType(EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED))) + .build(); + } + + private static Link activityLink() { + return Link.newBuilder() + .setActivity( + Link.Activity.newBuilder() + .setNamespace(NAMESPACE) + .setActivityId("activity-id") + .setRunId("activity-run-id")) + .build(); + } +} 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 9024cbff6f..34868a2471 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 @@ -1,6 +1,8 @@ package io.temporal.internal.common; +import static io.temporal.internal.common.LinkConverter.activityToNexusLink; import static io.temporal.internal.common.LinkConverter.linkToNexusLink; +import static io.temporal.internal.common.LinkConverter.nexusLinkToActivity; import static io.temporal.internal.common.LinkConverter.nexusLinkToLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToNexusOperation; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowEvent; @@ -466,6 +468,57 @@ public void testConvertNexusToNexusOperation_InvalidPathMissingDetails() { assertNull(nexusLinkToNexusOperation(input)); } + @Test + public void testConvertActivityToNexus_Valid() { + Link.Activity input = + Link.Activity.newBuilder() + .setNamespace("ns") + .setActivityId("act id/with+characters") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl( + "temporal:///namespaces/ns/activities/act%20id%2Fwith%2Bcharacters/run-id/details") + .setType("temporal.api.common.v1.Link.Activity") + .build(); + + assertEquals(expected, activityToNexusLink(input)); + } + + @Test + public void testConvertNexusToActivity_Valid() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl( + "temporal:///namespaces/ns/activities/act%20id%2Fwith%2Bcharacters/run-id/details") + .setType("temporal.api.common.v1.Link.Activity") + .build(); + + Link expected = + Link.newBuilder() + .setActivity( + Link.Activity.newBuilder() + .setNamespace("ns") + .setActivityId("act id/with+characters") + .setRunId("run-id")) + .build(); + + assertEquals(expected, nexusLinkToActivity(input)); + } + + @Test + public void testConvertNexusToActivity_InvalidPath() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/activities/act-id/run-id") + .setType("temporal.api.common.v1.Link.Activity") + .build(); + + assertNull(nexusLinkToActivity(input)); + } + @Test public void testNexusLinkToLink_WorkflowEventRoundTrip() { Link.WorkflowEvent we = @@ -507,6 +560,19 @@ public void testNexusLinkToLink_NexusOperation() { assertEquals(expected, nexusLinkToLink(nexusLink)); } + @Test + public void testNexusLinkToLink_ActivityRoundTrip() { + Link.Activity activity = + Link.Activity.newBuilder() + .setNamespace("ns") + .setActivityId("act-id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link nexusLink = activityToNexusLink(activity); + assertEquals(Link.newBuilder().setActivity(activity).build(), nexusLinkToLink(nexusLink)); + } + @Test public void testNexusLinkToLink_UnknownType() { io.temporal.api.nexus.v1.Link nexusLink = @@ -550,6 +616,20 @@ public void testLinkToNexusLink_NexusOperation() { assertEquals(nexusOperationToNexusLink(no), actual); } + @Test + public void testLinkToNexusLink_Activity() { + Link.Activity activity = + Link.Activity.newBuilder() + .setNamespace("ns") + .setActivityId("act-id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link actual = + linkToNexusLink(Link.newBuilder().setActivity(activity).build()); + assertEquals(activityToNexusLink(activity), actual); + } + @Test public void testLinkToNexusLink_Empty() { assertNull(linkToNexusLink(Link.newBuilder().build())); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/OperationTokenTest.java similarity index 57% rename from temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java rename to temporal-sdk/src/test/java/io/temporal/internal/nexus/OperationTokenTest.java index 1f22fe8c2e..a95cf901ac 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/WorkflowRunTokenTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/OperationTokenTest.java @@ -8,20 +8,27 @@ import org.junit.Assert; import org.junit.Test; -public class WorkflowRunTokenTest { +public class OperationTokenTest { private static final ObjectWriter ow = new ObjectMapper().registerModule(new Jdk8Module()).writer(); private static final ObjectReader or = new ObjectMapper().registerModule(new Jdk8Module()).reader(); private static final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + @Test + public void operationTokenTypeValues() { + Assert.assertEquals(0, OperationTokenType.UNKNOWN.toValue()); + Assert.assertEquals(1, OperationTokenType.WORKFLOW_RUN.toValue()); + Assert.assertEquals(2, OperationTokenType.ACTIVITY_EXECUTION.toValue()); + Assert.assertEquals(3, OperationTokenType.WORKFLOW_UPDATE.toValue()); + } + @Test public void serializeWorkflowRunToken() throws JsonProcessingException { OperationToken token = new OperationToken(OperationTokenType.WORKFLOW_RUN, "namespace", "workflowId"); String json = ow.writeValueAsString(token); final JsonNode node = new ObjectMapper().readTree(json); - System.out.println(json); // Assert that the serialized JSON is as expected Assert.assertEquals(1, node.get("t").asInt()); Assert.assertEquals("namespace", node.get("ns").asText()); @@ -92,6 +99,92 @@ public void loadWorkflowIdFromGoOperationToken() { Assert.assertEquals(OperationTokenType.WORKFLOW_RUN, token.getType()); } + @Test + public void roundTripActivityExecutionToken() throws JsonProcessingException { + String encoded = OperationTokenUtil.generateActivityExecutionOperationToken("act-1", "ns"); + OperationToken token = OperationTokenUtil.loadOperationToken(encoded); + Assert.assertEquals(OperationTokenType.ACTIVITY_EXECUTION, token.getType()); + Assert.assertEquals("act-1", token.getActivityId()); + Assert.assertEquals("ns", token.getNamespace()); + Assert.assertNull(token.getWorkflowId()); + Assert.assertNull(token.getRunId()); + Assert.assertNull(token.getVersion()); + + // Also exercise the symmetric activityId loader. + Assert.assertEquals("act-1", OperationTokenUtil.loadActivityIdFromOperationToken(encoded)); + } + + @Test + public void roundTripActivityExecutionTokenWithRunId() throws JsonProcessingException { + String encoded = + OperationTokenUtil.generateActivityExecutionOperationToken("act-1", "run-1", "ns"); + OperationToken token = OperationTokenUtil.loadOperationToken(encoded); + Assert.assertEquals(OperationTokenType.ACTIVITY_EXECUTION, token.getType()); + Assert.assertEquals("act-1", token.getActivityId()); + Assert.assertEquals("run-1", token.getRunId()); + Assert.assertEquals("ns", token.getNamespace()); + Assert.assertNull(token.getWorkflowId()); + Assert.assertNull(token.getVersion()); + } + + @Test + public void activityExecutionTokenOmitsRunIdWhenNull() throws JsonProcessingException { + // The header-token path passes runId=null and must produce a payload byte-identical to the + // two-arg overload — the runId-aware overload is the *only* one used in code now. + String withRunId = + OperationTokenUtil.generateActivityExecutionOperationToken("act-1", null, "ns"); + String withoutRunId = OperationTokenUtil.generateActivityExecutionOperationToken("act-1", "ns"); + Assert.assertEquals(withoutRunId, withRunId); + } + + @Test + public void workflowRunTokenBytesByteIdenticalSnapshot() throws JsonProcessingException { + String encoded = OperationTokenUtil.generateWorkflowRunOperationToken("wf-1", "ns"); + // The encoded token must remain byte-identical for cross-SDK compatibility. + // Snapshot captured from the current (pre-aid) SDK output. + Assert.assertEquals("eyJ0IjoxLCJucyI6Im5zIiwid2lkIjoid2YtMSJ9", encoded); + } + + @Test + public void malformedActivityTokenRejected() { + String malformed = "{\"t\":2,\"ns\":\"ns\",\"aid\":\"\"}"; + IllegalArgumentException ex = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadOperationToken( + encoder.encodeToString(malformed.getBytes()))); + Assert.assertTrue( + "expected 'missing activity ID' in message but got: " + ex.getMessage(), + ex.getMessage().contains("missing activity ID")); + } + + @Test + public void unknownOperationTokenTypeRejected() { + String unknown = "{\"t\":7,\"ns\":\"ns\",\"wid\":\"x\"}"; + IllegalArgumentException ex = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + OperationTokenUtil.loadOperationToken(encoder.encodeToString(unknown.getBytes()))); + Assert.assertTrue( + "expected 'unknown operation token type' in message but got: " + ex.getMessage(), + ex.getMessage().contains("unknown operation token type")); + } + + @Test + public void loadWorkflowRunRejectsActivityToken() throws JsonProcessingException { + String activityToken = + OperationTokenUtil.generateActivityExecutionOperationToken("act-1", "ns"); + IllegalArgumentException ex = + Assert.assertThrows( + IllegalArgumentException.class, + () -> OperationTokenUtil.loadWorkflowRunOperationToken(activityToken)); + Assert.assertTrue( + "expected 'incorrect operation token type' in message but got: " + ex.getMessage(), + ex.getMessage().contains("incorrect operation token type")); + } + @Test public void loadWorkflowIdFromBadOperationToken() { // Bad token, empty json diff --git a/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java b/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java new file mode 100644 index 0000000000..2433db132c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java @@ -0,0 +1,303 @@ +package io.temporal.nexus; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.withSettings; + +import com.uber.m3.tally.NoopScope; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationContext; +import io.nexusrpc.handler.OperationStartDetails; +import io.temporal.api.common.v1.Link; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.common.context.ContextPropagator; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.internal.client.ActivityClientInternal; +import io.temporal.internal.client.NexusStartWorkflowRequest; +import io.temporal.internal.client.NexusStartWorkflowResponse; +import io.temporal.internal.client.WorkflowClientInternal; +import io.temporal.internal.nexus.CurrentNexusOperationContext; +import io.temporal.internal.nexus.InternalNexusOperationContext; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.workflow.Functions; +import java.time.Duration; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +/** + * Pure unit tests for {@link TemporalNexusClientImpl#markAsyncOperationStarted()} semantics. These + * run without a Temporal server (no {@link io.temporal.testing.internal.SDKTestWorkflowRule}). + * + *

The {@code markAsyncOperationStarted} guard fires as the very first statement in both {@code + * startActivityImpl} and {@code invokeAndReturn}, so the second call always throws {@link + * HandlerException}({@link HandlerException.ErrorType#BAD_REQUEST}) regardless of whether the first + * call's downstream RPC succeeded. + */ +public class TemporalNexusClientImplTest { + + private static final String NAMESPACE = "test-namespace"; + private static final String TASK_QUEUE = "test-task-queue"; + private static final String ENDPOINT = "test-endpoint"; + + private TemporalNexusClientImpl client; + private MockedStatic activityClientFactory; + private AtomicReference activityInput; + + @Before + public void setUp() { + WorkflowClient workflowClient = mock(WorkflowClient.class); + WorkflowClientOptions clientOptions = mock(WorkflowClientOptions.class); + when(workflowClient.getOptions()).thenReturn(clientOptions); + when(clientOptions.getNamespace()).thenReturn(NAMESPACE); + when(clientOptions.getIdentity()).thenReturn("test-identity"); + Payload propagatedPayload = Payload.newBuilder().build(); + ContextPropagator contextPropagator = mock(ContextPropagator.class); + when(contextPropagator.getCurrentContext()).thenReturn("test-context"); + when(contextPropagator.serializeContext("test-context")) + .thenReturn(Collections.singletonMap("propagated-key", propagatedPayload)); + when(clientOptions.getContextPropagators()) + .thenReturn(Collections.singletonList(contextPropagator)); + when(workflowClient.getWorkflowServiceStubs()).thenReturn(mock(WorkflowServiceStubs.class)); + + WorkflowClientInternal workflowClientInternal = mock(WorkflowClientInternal.class); + when(workflowClient.getInternal()).thenReturn(workflowClientInternal); + when(workflowClientInternal.startNexus( + org.mockito.ArgumentMatchers.any(NexusStartWorkflowRequest.class), + org.mockito.ArgumentMatchers.any(Functions.Proc.class))) + .thenReturn( + new NexusStartWorkflowResponse( + WorkflowExecution.newBuilder() + .setWorkflowId("workflow-id") + .setRunId("workflow-run-id") + .build(), + "workflow-operation-token")); + when(workflowClient.newWorkflowStub( + org.mockito.ArgumentMatchers.eq(BlockingWorkflow.class), + org.mockito.ArgumentMatchers.any(WorkflowOptions.class))) + .thenReturn(mock(BlockingWorkflow.class)); + + OperationContext operationContext = mock(OperationContext.class); + when(operationContext.getService()).thenReturn("TestService"); + when(operationContext.getOperation()).thenReturn("testOperation"); + + OperationStartDetails operationStartDetails = + OperationStartDetails.newBuilder() + .setCallbackUrl("http://localhost/callback") + .setRequestId("test-request-id") + .build(); + + client = new TemporalNexusClientImpl(workflowClient, operationContext, operationStartDetails); + + // Set up the thread-local nexus context required by NexusStartActivityHelper and + // NexusStartWorkflowHelper deep in the call stack. + InternalNexusOperationContext internalCtx = + new InternalNexusOperationContext( + NAMESPACE, TASK_QUEUE, ENDPOINT, new NoopScope(), workflowClient); + internalCtx.setStartWorkflowResponseLink(Link.getDefaultInstance()); + CurrentNexusOperationContext.set(internalCtx); + + ActivityClient activityClient = + mock(ActivityClient.class, withSettings().extraInterfaces(ActivityClientInternal.class)); + ActivityClientCallsInterceptor activityInvoker = mock(ActivityClientCallsInterceptor.class); + activityInput = new AtomicReference<>(); + when(((ActivityClientInternal) activityClient).getInvoker()).thenReturn(activityInvoker); + when(activityInvoker.startActivity( + org.mockito.ArgumentMatchers.any( + ActivityClientCallsInterceptor.StartActivityInput.class))) + .thenAnswer( + invocation -> { + activityInput.set(invocation.getArgument(0)); + CurrentNexusOperationContext.get().getNexusOperationMetadata().operationToken = + "activity-operation-token"; + ActivityClientCallsInterceptor.StartActivityInput input = invocation.getArgument(0); + return new ActivityClientCallsInterceptor.StartActivityOutput( + input.getOptions().getId(), "activity-run-id"); + }); + activityClientFactory = mockStatic(ActivityClient.class); + activityClientFactory + .when( + () -> + ActivityClient.newInstance( + org.mockito.ArgumentMatchers.any(WorkflowServiceStubs.class), + org.mockito.ArgumentMatchers.any(ActivityClientOptions.class))) + .thenReturn(activityClient); + } + + @After + public void tearDown() { + activityClientFactory.close(); + CurrentNexusOperationContext.unset(); + } + + // ---------- Activity double-start ---------- + + @Test + public void startActivity_propagatesWorkflowClientContext() { + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("act-context") + .setTaskQueue(TASK_QUEUE) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + client.startActivity(TestActivity.class, TestActivity::doSomething, options); + + Assert.assertNotNull(activityInput.get()); + Assert.assertTrue(activityInput.get().getHeader().getValues().containsKey("propagated-key")); + } + + @Test + public void doubleStartActivity_secondCallThrowsBadRequest() { + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("act-1") + .setTaskQueue(TASK_QUEUE) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + client.startActivity(TestActivity.class, TestActivity::doSomething, options); + + // Second call: markAsyncOperationStarted() sees the flag and must throw BAD_REQUEST + // immediately. + HandlerException ex = + Assert.assertThrows( + HandlerException.class, + () -> + client.startActivity( + TestActivity.class, + TestActivity::doSomething, + StartActivityOptions.newBuilder() + .setId("act-2") + .setTaskQueue(TASK_QUEUE) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build())); + + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, ex.getErrorType()); + Assert.assertTrue( + "Message should contain 'Only one async operation'", + ex.getCause() instanceof IllegalStateException + && ex.getCause() + .getMessage() + .startsWith("Only one async operation can be started per operation handler")); + } + + // ---------- Workflow double-start ---------- + + @Test + public void doubleStartWorkflow_secondCallThrowsBadRequest() { + WorkflowOptions options = + WorkflowOptions.newBuilder().setWorkflowId("wf-1").setTaskQueue(TASK_QUEUE).build(); + + client.startWorkflow(BlockingWorkflow.class, BlockingWorkflow::execute, "input", options); + + // Second call must throw BAD_REQUEST immediately. + HandlerException ex = + Assert.assertThrows( + HandlerException.class, + () -> + client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + "input", + WorkflowOptions.newBuilder() + .setWorkflowId("wf-2") + .setTaskQueue(TASK_QUEUE) + .build())); + + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, ex.getErrorType()); + Assert.assertTrue( + "Message should contain 'Only one async operation'", + ex.getCause() instanceof IllegalStateException + && ex.getCause() + .getMessage() + .startsWith("Only one async operation can be started per operation handler")); + } + + // ---------- Mixed: workflow then activity ---------- + + @Test + public void startWorkflowThenActivity_activityThrowsBadRequest() { + WorkflowOptions wfOptions = + WorkflowOptions.newBuilder().setWorkflowId("wf-mixed-1").setTaskQueue(TASK_QUEUE).build(); + + client.startWorkflow(BlockingWorkflow.class, BlockingWorkflow::execute, "in", wfOptions); + + HandlerException ex = + Assert.assertThrows( + HandlerException.class, + () -> + client.startActivity( + TestActivity.class, + TestActivity::doSomething, + StartActivityOptions.newBuilder() + .setId("act-mixed-1") + .setTaskQueue(TASK_QUEUE) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build())); + + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, ex.getErrorType()); + } + + // ---------- Mixed: activity then workflow ---------- + + @Test + public void startActivityThenWorkflow_workflowThrowsBadRequest() { + StartActivityOptions actOptions = + StartActivityOptions.newBuilder() + .setId("act-mixed-2") + .setTaskQueue(TASK_QUEUE) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + client.startActivity(TestActivity.class, TestActivity::doSomething, actOptions); + + HandlerException ex = + Assert.assertThrows( + HandlerException.class, + () -> + client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + "in", + WorkflowOptions.newBuilder() + .setWorkflowId("wf-mixed-2") + .setTaskQueue(TASK_QUEUE) + .build())); + + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, ex.getErrorType()); + } + + // ---------- Minimal stubs ---------- + + @io.temporal.activity.ActivityInterface + public interface TestActivity { + @io.temporal.activity.ActivityMethod + void doSomething(); + } + + @io.temporal.workflow.WorkflowInterface + public interface BlockingWorkflow { + @io.temporal.workflow.WorkflowMethod + String execute(String input); + } + + public static class BlockingWorkflowImpl implements BlockingWorkflow { + @Override + public String execute(String input) { + return input; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java new file mode 100644 index 0000000000..bf6e23912d --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java @@ -0,0 +1,143 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.enums.v1.ActivityIdConflictPolicy; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityHandle; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.WorkflowFailedException; +import io.temporal.failure.ApplicationFailure; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class ActivityHandleFailOnConflictTest { + private final CountDownLatch activityStarted = new CountDownLatch(1); + private final CountDownLatch releaseActivity = new CountDownLatch(1); + private final AtomicInteger nexusInvocationCount = new AtomicInteger(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setActivityImplementations(new BlockingActivityImpl()) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void activityAlreadyStartedIsBadRequest() throws Exception { + // The in-process test server does not implement the StartActivityExecution RPC. + assumeTrue(SDKTestWorkflowRule.useExternalService); + + String activityId = "activity-" + UUID.randomUUID(); + StartActivityOptions activityOptions = + StartActivityOptions.newBuilder() + .setId(activityId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .setIdConflictPolicy(ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_FAIL) + .build(); + ActivityClient activityClient = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder().setNamespace(SDKTestWorkflowRule.NAMESPACE).build()); + ActivityHandle runningActivity = + activityClient.start( + BlockingActivity.class, BlockingActivity::execute, activityOptions, activityId); + + try { + Assert.assertTrue( + "The standalone activity should start", activityStarted.await(20, TimeUnit.SECONDS)); + + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + WorkflowFailedException workflowFailure = + Assert.assertThrows( + WorkflowFailedException.class, () -> workflowStub.execute(activityId)); + + Assert.assertTrue(workflowFailure.getCause() instanceof NexusOperationFailure); + NexusOperationFailure nexusFailure = (NexusOperationFailure) workflowFailure.getCause(); + Assert.assertTrue(nexusFailure.getCause() instanceof HandlerException); + HandlerException handlerFailure = (HandlerException) nexusFailure.getCause(); + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, handlerFailure.getErrorType()); + Assert.assertFalse(handlerFailure.isRetryable()); + Assert.assertTrue(handlerFailure.getCause() instanceof ApplicationFailure); + Assert.assertEquals( + "io.temporal.client.ActivityAlreadyStartedException", + ((ApplicationFailure) handlerFailure.getCause()).getType()); + Assert.assertEquals(1, nexusInvocationCount.get()); + } finally { + releaseActivity.countDown(); + runningActivity.getResult(30, TimeUnit.SECONDS); + } + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String activityId) { + TestNexusServices.TestNexusService1 serviceStub = + Workflow.newNexusServiceStub(TestNexusServices.TestNexusService1.class); + return serviceStub.operation(activityId); + } + } + + @ActivityInterface + public interface BlockingActivity { + @ActivityMethod + String execute(String activityId); + } + + public class BlockingActivityImpl implements BlockingActivity { + @Override + public String execute(String activityId) { + activityStarted.countDown(); + try { + releaseActivity.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + return activityId; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, activityId) -> { + nexusInvocationCount.incrementAndGet(); + return client.startActivity( + BlockingActivity.class, + BlockingActivity::execute, + activityId, + StartActivityOptions.newBuilder() + .setId(activityId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .setIdConflictPolicy(ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_FAIL) + .build()); + }); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java new file mode 100644 index 0000000000..c699d997ea --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java @@ -0,0 +1,169 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.enums.v1.ActivityIdConflictPolicy; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testUtils.Eventually; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationExecution; +import io.temporal.workflow.NexusOperationHandle; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.TestNexusServices; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class ActivityHandleUseExistingOnConflictTest { + private static final int OPERATION_COUNT = 5; + + private final CountDownLatch activityStarted = new CountDownLatch(1); + private final CountDownLatch releaseActivity = new CountDownLatch(1); + private final AtomicInteger activityInvocationCount = new AtomicInteger(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setActivityImplementations(new BlockingActivityImpl()) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void testOnConflictUseExisting() throws Exception { + // The in-process test server does not implement the StartActivityExecution RPC. + assumeTrue(SDKTestWorkflowRule.useExternalService); + + TestWorkflow workflowStub = testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow.class); + String activityId = "activity-" + UUID.randomUUID(); + WorkflowClient.start(workflowStub::execute, activityId); + + try { + Assert.assertTrue( + "The standalone activity should start", activityStarted.await(20, TimeUnit.SECONDS)); + Eventually.assertEventually( + Duration.ofSeconds(20), + () -> + Assert.assertTrue( + "All Nexus operations should attach before completion", + workflowStub.allOperationsStarted())); + } finally { + releaseActivity.countDown(); + } + + Assert.assertEquals( + "completed " + activityId, + WorkflowStub.fromTyped(workflowStub).getResult(30, TimeUnit.SECONDS, String.class)); + Assert.assertEquals(1, activityInvocationCount.get()); + } + + @WorkflowInterface + public interface TestWorkflow { + @WorkflowMethod + String execute(String activityId); + + @QueryMethod + boolean allOperationsStarted(); + } + + public static class TestNexus implements TestWorkflow { + private boolean allOperationsStarted; + + @Override + public String execute(String activityId) { + TestNexusServices.TestNexusService1 serviceStub = + Workflow.newNexusServiceStub(TestNexusServices.TestNexusService1.class); + List> handles = new ArrayList<>(); + for (int i = 0; i < OPERATION_COUNT; i++) { + handles.add(Workflow.startNexusOperation(serviceStub::operation, activityId)); + } + + String operationToken = null; + for (NexusOperationHandle handle : handles) { + NexusOperationExecution execution = handle.getExecution().get(); + Assert.assertTrue(execution.getOperationToken().isPresent()); + if (operationToken == null) { + operationToken = execution.getOperationToken().get(); + } else { + Assert.assertEquals(operationToken, execution.getOperationToken().get()); + } + } + allOperationsStarted = true; + + String result = null; + for (NexusOperationHandle handle : handles) { + String currentResult = handle.getResult().get(); + if (result == null) { + result = currentResult; + } else { + Assert.assertEquals(result, currentResult); + } + } + return result; + } + + @Override + public boolean allOperationsStarted() { + return allOperationsStarted; + } + } + + @ActivityInterface + public interface BlockingActivity { + @ActivityMethod + String execute(String activityId); + } + + public class BlockingActivityImpl implements BlockingActivity { + @Override + public String execute(String activityId) { + activityInvocationCount.incrementAndGet(); + activityStarted.countDown(); + try { + releaseActivity.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + return "completed " + activityId; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, activityId) -> + client.startActivity( + BlockingActivity.class, + BlockingActivity::execute, + activityId, + StartActivityOptions.newBuilder() + .setId(activityId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(1)) + .setIdConflictPolicy( + ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_USE_EXISTING) + .build())); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java new file mode 100644 index 0000000000..1a1e5bea49 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java @@ -0,0 +1,103 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.client.StartActivityOptions; +import io.temporal.internal.nexus.OperationToken; +import io.temporal.internal.nexus.OperationTokenType; +import io.temporal.internal.nexus.OperationTokenUtil; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class AsyncActivityOperationTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setActivityImplementations(new TestActivityImpl()) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @Test + public void testActivityOperationEndToEnd() { + // The in-process test-server does not implement the StartActivityExecution RPC; the + // standalone-activity Nexus path requires a real server. Unit-only token assertions stay + // active in OperationTokenTest. + assumeTrue(SDKTestWorkflowRule.useExternalService); + // The caller workflow receives the activity result and an ACTIVITY_EXECUTION operation token. + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + String result = workflowStub.execute("world"); + Assert.assertEquals("hello world", result); + } + + @ActivityInterface + public interface TestActivity { + @ActivityMethod + String process(String input); + } + + public static class TestActivityImpl implements TestActivity { + @Override + public String process(String input) { + return "hello " + input; + } + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(20)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder().setOperationOptions(options).build(); + TestNexusServices.TestNexusService1 serviceStub = + Workflow.newNexusServiceStub(TestNexusServices.TestNexusService1.class, serviceOptions); + + NexusOperationHandle handle = + Workflow.startNexusOperation(serviceStub::operation, input); + NexusOperationExecution exec = handle.getExecution().get(); + Assert.assertTrue("Operation token should be present", exec.getOperationToken().isPresent()); + OperationToken token = OperationTokenUtil.loadOperationToken(exec.getOperationToken().get()); + Assert.assertEquals(OperationTokenType.ACTIVITY_EXECUTION, token.getType()); + Assert.assertTrue( + "activityId should start with 'act-' (got " + token.getActivityId() + ")", + token.getActivityId() != null && token.getActivityId().startsWith("act-")); + + return handle.getResult().get(); + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startActivity( + TestActivity.class, + TestActivity::process, + input, + StartActivityOptions.newBuilder() + .setId("act-" + context.getRequestId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build())); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java new file mode 100644 index 0000000000..45a2b78794 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java @@ -0,0 +1,264 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.client.ActivityCompletionException; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowStub; +import io.temporal.common.RetryOptions; +import io.temporal.failure.CanceledFailure; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.nexus.CancelActivityExecutionInput; +import io.temporal.nexus.TemporalOperationCancelContext; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class CancelActivityAsyncOperationTest extends BaseNexusTest { + + static final AtomicBoolean cancelled = new AtomicBoolean(false); + static final AtomicBoolean customCancelInvoked = new AtomicBoolean(false); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(DefaultCancelNexus.class, OverriddenCancelNexus.class) + .setActivityImplementations(new HeartbeatingActivityImpl()) + .setNexusServiceImplementation( + new DefaultCancelNexusServiceImpl(), new OverriddenCancelNexusServiceImpl()) + .build(); + + @Override + protected SDKTestWorkflowRule getTestWorkflowRule() { + return testWorkflowRule; + } + + @Before + public void resetState() { + cancelled.set(false); + customCancelInvoked.set(false); + } + + @Test(timeout = 60_000) + public void testDefaultActivityCancel() { + // Standalone-activity Nexus path requires a real Temporal server; the in-process test server + // does not implement StartActivityExecution. + assumeTrue(SDKTestWorkflowRule.useExternalService); + WorkflowStub stub = + testWorkflowRule.newUntypedWorkflowStubTimeoutOptions("DefaultCancelCaller"); + stub.start(testWorkflowRule.getTaskQueue()); + // Either succeeds with "ok" or throws a workflow failure; we only care about the cancel path. + try { + stub.getResult(String.class); + } catch (WorkflowFailedException ignored) { + // Acceptable: caller workflow may surface the cancel as failure. + } + // History contains EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED on the caller workflow. + testWorkflowRule.assertHistoryEvent( + stub.getExecution().getWorkflowId(), + io.temporal.api.enums.v1.EventType.EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED); + // Activity worker observed cancel. + Assert.assertTrue("activity should have observed cancel", cancelled.get()); + } + + @Test(timeout = 60_000) + public void testOverriddenActivityCancel() { + // Same constraint as testDefaultActivityCancel. + assumeTrue(SDKTestWorkflowRule.useExternalService); + WorkflowStub stub = + testWorkflowRule.newUntypedWorkflowStubTimeoutOptions("OverriddenCancelCaller"); + stub.start(testWorkflowRule.getTaskQueue()); + try { + stub.getResult(String.class); + } catch (WorkflowFailedException ignored) { + // Acceptable. + } + // The overriding handler should have been called. + Assert.assertTrue("override should have been invoked", customCancelInvoked.get()); + // Default cancel was not invoked: activity ran until startToCloseTimeout fired + // (no ActivityCompletionException raised on the worker). + Assert.assertFalse("default cancel should not have been invoked", cancelled.get()); + } + + @ActivityInterface + public interface HeartbeatingActivity { + @ActivityMethod + String process(String input); + } + + public static class HeartbeatingActivityImpl implements HeartbeatingActivity { + @Override + public String process(String input) { + while (true) { + try { + Activity.getExecutionContext().heartbeat(null); + } catch (ActivityCompletionException ex) { + cancelled.set(true); + throw ex; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + return "done"; + } + } + + @WorkflowInterface + public interface DefaultCancelCaller { + @WorkflowMethod + String execute(String taskQueue); + } + + @WorkflowInterface + public interface OverriddenCancelCaller { + @WorkflowMethod + String execute(String taskQueue); + } + + @io.nexusrpc.Service + public interface DefaultCancelNexusService { + @io.nexusrpc.Operation + String operation(String taskQueue); + } + + @io.nexusrpc.Service + public interface OverrideCancelNexusService { + @io.nexusrpc.Operation + String operation(String taskQueue); + } + + // ---- Default cancel ---- + + public static class DefaultCancelNexus implements DefaultCancelCaller { + @Override + public String execute(String taskQueue) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(15)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder() + .setEndpoint(getEndpointName()) + .setOperationOptions(options) + .build(); + DefaultCancelNexusService stub = + Workflow.newNexusServiceStub(DefaultCancelNexusService.class, serviceOptions); + try { + Workflow.newCancellationScope( + () -> { + NexusOperationHandle handle = + Workflow.startNexusOperation(stub::operation, taskQueue); + handle.getExecution().get(); + CancellationScope.current().cancel(); + handle.getResult().get(); + }) + .run(); + } catch (NexusOperationFailure failure) { + if (!(failure.getCause() instanceof CanceledFailure)) { + throw failure; + } + } + return "ok"; + } + } + + @ServiceImpl(service = DefaultCancelNexusService.class) + public class DefaultCancelNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startActivity( + HeartbeatingActivity.class, + HeartbeatingActivity::process, + input, + StartActivityOptions.newBuilder() + .setId("act-" + context.getRequestId()) + .setTaskQueue(input) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(2)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build())); + } + } + + // ---- Overridden cancel ---- + + public static class OverriddenCancelNexus implements OverriddenCancelCaller { + @Override + public String execute(String taskQueue) { + NexusOperationOptions options = + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(15)) + .build(); + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder() + .setEndpoint(getEndpointName()) + .setOperationOptions(options) + .build(); + OverrideCancelNexusService stub = + Workflow.newNexusServiceStub(OverrideCancelNexusService.class, serviceOptions); + try { + Workflow.newCancellationScope( + () -> { + NexusOperationHandle handle = + Workflow.startNexusOperation(stub::operation, taskQueue); + handle.getExecution().get(); + CancellationScope.current().cancel(); + handle.getResult().get(); + }) + .run(); + } catch (NexusOperationFailure failure) { + if (!(failure.getCause() instanceof CanceledFailure)) { + throw failure; + } + } + return "ok"; + } + } + + @ServiceImpl(service = OverrideCancelNexusService.class) + public class OverriddenCancelNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return new TemporalOperationHandler( + (context, client, input) -> + client.startActivity( + HeartbeatingActivity.class, + HeartbeatingActivity::process, + input, + StartActivityOptions.newBuilder() + .setId("act-override-" + context.getRequestId()) + .setTaskQueue(input) + .setStartToCloseTimeout(Duration.ofSeconds(5)) + .setHeartbeatTimeout(Duration.ofSeconds(2)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build())) { + @Override + protected void cancelActivityExecution( + TemporalOperationCancelContext ctx, CancelActivityExecutionInput input) { + customCancelInvoked.set(true); + // Intentionally do NOT invoke default cancel; the activity will self-terminate via + // startToCloseTimeout. + } + }; + } + } +} From 92800ca0b14924a42c78874d1bc40929a985e719 Mon Sep 17 00:00:00 2001 From: Lanie Hei Date: Tue, 4 Aug 2026 11:18:48 -0700 Subject: [PATCH 053/107] Remove experimental markers from user metadata fields (#2958) StaticSummary, StaticDetails, CurrentDetails, Activity Summary, and Timer Summary are stable and shipping. Remove @Experimental annotations to reflect GA status. Co-authored-by: Claude Opus 4.6 --- .../src/main/java/io/temporal/activity/ActivityOptions.java | 2 -- .../main/java/io/temporal/activity/LocalActivityOptions.java | 3 --- .../java/io/temporal/client/WorkflowExecutionDescription.java | 3 --- .../src/main/java/io/temporal/client/WorkflowOptions.java | 4 ---- .../main/java/io/temporal/workflow/ChildWorkflowOptions.java | 4 ---- .../src/main/java/io/temporal/workflow/TimerOptions.java | 2 -- temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java | 2 -- 7 files changed, 20 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/activity/ActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/activity/ActivityOptions.java index f67a4beed2..f48fcadc43 100644 --- a/temporal-sdk/src/main/java/io/temporal/activity/ActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/activity/ActivityOptions.java @@ -238,7 +238,6 @@ public Builder setVersioningIntent(VersioningIntent versioningIntent) { * *

Default is none/empty. */ - @Experimental public Builder setSummary(String summary) { this.summary = summary; return this; @@ -452,7 +451,6 @@ public VersioningIntent getVersioningIntent() { return versioningIntent; } - @Experimental public String getSummary() { return summary; } diff --git a/temporal-sdk/src/main/java/io/temporal/activity/LocalActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/activity/LocalActivityOptions.java index 6f7a33c96f..27f04cb18c 100644 --- a/temporal-sdk/src/main/java/io/temporal/activity/LocalActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/activity/LocalActivityOptions.java @@ -1,7 +1,6 @@ package io.temporal.activity; import com.google.common.base.Objects; -import io.temporal.common.Experimental; import io.temporal.common.MethodRetry; import io.temporal.common.RetryOptions; import java.time.Duration; @@ -167,7 +166,6 @@ public Builder setDoNotIncludeArgumentsIntoMarker(boolean doNotIncludeArgumentsI * *

Default is none/empty. */ - @Experimental public Builder setSummary(String summary) { this.summary = summary; return this; @@ -279,7 +277,6 @@ public boolean isDoNotIncludeArgumentsIntoMarker() { return doNotIncludeArgumentsIntoMarker != null && doNotIncludeArgumentsIntoMarker; } - @Experimental public String getSummary() { return summary; } diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java index f4b29a0af7..6f54031a97 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java @@ -1,7 +1,6 @@ package io.temporal.client; import io.temporal.api.workflowservice.v1.DescribeWorkflowExecutionResponse; -import io.temporal.common.Experimental; import io.temporal.common.converter.DataConverter; import io.temporal.payload.context.WorkflowSerializationContext; import javax.annotation.Nonnull; @@ -25,7 +24,6 @@ public WorkflowExecutionDescription( *

Note: Will be decoded on each invocation, so it is recommended to cache the result if it is * used multiple times. */ - @Experimental @Nullable public String getStaticSummary() { if (!response.getExecutionConfig().getUserMetadata().hasSummary()) { @@ -48,7 +46,6 @@ public String getStaticSummary() { *

Note: Will be decoded on each invocation, so it is recommended to cache the result if it is * used multiple times. */ - @Experimental @Nullable public String getStaticDetails() { if (!response.getExecutionConfig().getUserMetadata().hasDetails()) { diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowOptions.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowOptions.java index 628aa1a61f..17e7033bfa 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowOptions.java @@ -400,7 +400,6 @@ public Builder setStartDelay(Duration startDelay) { * *

Default is none/empty. */ - @Experimental public Builder setStaticSummary(String staticSummary) { this.staticSummary = staticSummary; return this; @@ -413,7 +412,6 @@ public Builder setStaticSummary(String staticSummary) { * *

Default is none/empty. */ - @Experimental public Builder setStaticDetails(String staticDetails) { this.staticDetails = staticDetails; return this; @@ -716,12 +714,10 @@ public String getRequestId() { return links; } - @Experimental public String getStaticSummary() { return staticSummary; } - @Experimental public String getStaticDetails() { return staticDetails; } diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java index 261cd97247..543c76b57e 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java @@ -309,7 +309,6 @@ public Builder setVersioningIntent(VersioningIntent versioningIntent) { * *

Default is none/empty. */ - @Experimental public Builder setStaticSummary(String staticSummary) { this.staticSummary = staticSummary; return this; @@ -322,7 +321,6 @@ public Builder setStaticSummary(String staticSummary) { * *

Default is none/empty. */ - @Experimental public Builder setStaticDetails(String staticDetails) { this.staticDetails = staticDetails; return this; @@ -534,12 +532,10 @@ public VersioningIntent getVersioningIntent() { return versioningIntent; } - @Experimental public String getStaticSummary() { return staticSummary; } - @Experimental public String getStaticDetails() { return staticDetails; } diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/TimerOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/TimerOptions.java index f1c9647409..61abb91165 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/TimerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/TimerOptions.java @@ -1,6 +1,5 @@ package io.temporal.workflow; -import io.temporal.common.Experimental; import java.util.Objects; /** TimerOptions is used to specify options for a timer. */ @@ -42,7 +41,6 @@ private Builder(TimerOptions options) { * *

Default is none/empty. */ - @Experimental public Builder setSummary(String summary) { this.summary = summary; return this; diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java index cc2c3e476f..2cea96c08a 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java @@ -1538,7 +1538,6 @@ public static NexusOperationHandle startNexusOperation(Functions.Func * * @param details details to set */ - @Experimental public static void setCurrentDetails(String details) { WorkflowInternal.setCurrentDetails(details); } @@ -1548,7 +1547,6 @@ public static void setCurrentDetails(String details) { * * @return details of the current workflow */ - @Experimental @Nullable public static String getCurrentDetails() { return WorkflowInternal.getCurrentDetails(); From 8fd8cc33ced2150f3740a3d653b164f7af1b7df1 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 4 Aug 2026 16:58:50 -0700 Subject: [PATCH 054/107] Add dev server downloader & runner to testing package (#2982) --- .github/workflows/ci.yml | 55 +-- CONTRIBUTING.md | 26 ++ build.gradle | 3 +- gradle/temporalCli.gradle | 139 +++++++ .../io/temporal/worker/StickyWorkerTest.java | 6 +- .../WorkerHeartbeatDeploymentVersionTest.java | 1 - .../WorkerHeartbeatIntegrationTest.java | 1 - .../GracefulPollShutdownIntegrationTest.java | 1 - .../workerFactory/WorkerFactoryTests.java | 6 +- .../autoconfigure/WorkerVersioningTest.java | 15 +- temporal-testing/build.gradle | 2 + .../temporal/testing/TemporalDevServer.java | 77 ++++ .../testing/TemporalDevServerOptions.java | 370 ++++++++++++++++++ .../testing/TestWorkflowEnvironment.java | 74 ++++ .../TestWorkflowEnvironmentInternal.java | 63 ++- .../testing/TestWorkflowExtension.java | 130 ++++-- .../io/temporal/testing/TestWorkflowRule.java | 84 +++- .../internal/DevServerTestPreparation.java | 14 + .../internal/DevServerTestProcess.java | 22 ++ .../ExternalServiceTestConfigurator.java | 26 +- .../devserver/SdkJavaTestServerProfile.java | 165 ++++++++ .../TemporalDevServerDownloader.java | 369 +++++++++++++++++ .../devserver/TemporalDevServerLauncher.java | 304 ++++++++++++++ .../TemporalDevServerIntegrationTest.java | 208 ++++++++++ .../testing/TemporalDevServerOptionsTest.java | 61 +++ .../TemporalDevServerDownloaderTest.java | 240 ++++++++++++ ...flowExtensionDevServerIntegrationTest.java | 73 ++++ 27 files changed, 2408 insertions(+), 127 deletions(-) create mode 100644 gradle/temporalCli.gradle create mode 100644 temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java create mode 100644 temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java create mode 100644 temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 339a274928..1c147f7e9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,51 +82,8 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 - - name: Start CLI server - env: - TEMPORAL_CLI_VERSION: 1.7.4-standalone-nexus-operations - run: | - wget -O temporal_cli.tar.gz https://github.com/temporalio/cli/releases/download/v${TEMPORAL_CLI_VERSION}/temporal_cli_${TEMPORAL_CLI_VERSION}_linux_amd64.tar.gz - tar -xzf temporal_cli.tar.gz - chmod +x temporal - ./temporal server start-dev \ - --headless \ - --port 7233 \ - --http-port 7243 \ - --namespace UnitTest \ - --db-filename temporal.sqlite \ - --sqlite-pragma journal_mode=WAL \ - --sqlite-pragma synchronous=OFF \ - --search-attribute CustomKeywordField=Keyword \ - --search-attribute CustomStringField=Text \ - --search-attribute CustomTextField=Text \ - --search-attribute CustomIntField=Int \ - --search-attribute CustomDatetimeField=Datetime \ - --search-attribute CustomDoubleField=Double \ - --search-attribute CustomBoolField=Bool \ - --dynamic-config-value system.enableActivityEagerExecution=true \ - --dynamic-config-value history.MaxBufferedQueryCount=10000 \ - --dynamic-config-value frontend.workerVersioningDataAPIs=true \ - --dynamic-config-value history.enableRequestIdRefLinks=true \ - --dynamic-config-value frontend.WorkerHeartbeatsEnabled=true \ - --dynamic-config-value frontend.ListWorkersEnabled=true \ - --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ - --dynamic-config-value 'component.callbacks.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ - --dynamic-config-value 'callback.allowedAddresses=[{"Pattern":"localhost:7243","AllowInsecure":true}]' \ - --dynamic-config-value frontend.activityAPIsEnabled=true \ - --dynamic-config-value activity.enableStandalone=true \ - --dynamic-config-value activity.enableCallbacks=true \ - --dynamic-config-value activity.startDelayEnabled=true \ - --dynamic-config-value nexusoperation.enableStandalone=true \ - --dynamic-config-value history.enableChasm=true \ - --dynamic-config-value history.enableCHASMSignalBacklinks=true \ - --dynamic-config-value history.enableTransitionHistory=true \ - --dynamic-config-value history.enableUpdateCallbacks=true \ - --dynamic-config-value history.enableCHASMCallbacks=true \ - --dynamic-config-value frontend.enableCancelWorkerPollsOnShutdown=true \ - --dynamic-config-value frontend.workerCommandsEnabled=true \ - --dynamic-config-value system.enableCancelActivityWorkerCommand=true & - sleep 10s + - name: Prepare dev-server tests + run: ./gradlew --no-daemon prepareDevServerTests # Can't actually run tests against Java 8 because Mockito 5 requires Java 11+. # We therefore have to rely on the fact that the code has been compiled with @@ -135,9 +92,7 @@ jobs: - name: Run unit tests (Java 11) env: USER: unittest - TEMPORAL_SERVICE_ADDRESS: localhost:7233 - USE_EXTERNAL_SERVICE: true - run: ./gradlew --no-daemon test -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=11 + run: ./gradlew --no-daemon --offline test -PtestJavaVersion=11 -PtestServer=dev-server - name: Run Jackson 3 converter tests (Java 17) env: @@ -148,9 +103,7 @@ jobs: - name: Run virtual thread tests (Java 21) env: USER: unittest - TEMPORAL_SERVICE_ADDRESS: localhost:7233 - USE_EXTERNAL_SERVICE: true - run: ./gradlew --no-daemon :temporal-sdk:virtualThreadTests -x spotlessCheck -x spotlessApply -x spotlessJava -PtestJavaVersion=21 + run: ./gradlew --no-daemon --offline :temporal-sdk:virtualThreadTests -PtestJavaVersion=21 -PtestServer=dev-server - name: Publish Test Report uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fcc672866f..d5b8881133 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -68,6 +68,32 @@ Good pull requests are focused and easy to review: Run the relevant local checks when practical. CI must pass before a pull request can be merged. +## SDK Java Development + +Java 21 or later is required to run Gradle, compile the project, and run all tests +locally. + +By default, integration tests run against the built-in time-skipping test server. +Some tests require features that the built-in server does not support and are +skipped. Gradle can download the pinned Temporal CLI, start a correctly configured +dev server, wait for it to become ready, configure the tests to use it, and stop it +when the test invocation finishes. + +Run the suite against a managed local Temporal dev server with: + +```bash +./gradlew test -PtestJavaVersion=11 -PtestServer=dev-server +``` + +Normal Gradle test filtering works, so a single dev-server-backed test can be run with: + +```bash +./gradlew :temporal-sdk:test -PtestJavaVersion=11 -PtestServer=dev-server \ + --tests "io.temporal.activity.ActivityPauseTest.activityPause" +``` + +Java 11 must be available to Gradle for these commands. + ## Things to Avoid Avoid changes that make review harder without improving the contribution: diff --git a/build.gradle b/build.gradle index 6dcbcc7cb9..e9bbe424c0 100644 --- a/build.gradle +++ b/build.gradle @@ -77,6 +77,7 @@ apply from: "$rootDir/gradle/errorprone.gradle" apply from: "$rootDir/gradle/publishing.gradle" apply from: "$rootDir/gradle/dependencyManagement.gradle" apply from: "$rootDir/gradle/gatherDependencies.gradle" +apply from: "$rootDir/gradle/temporalCli.gradle" if (project.hasProperty("jacoco")) { apply from: "$rootDir/gradle/jacoco.gradle" -} \ No newline at end of file +} diff --git a/gradle/temporalCli.gradle b/gradle/temporalCli.gradle new file mode 100644 index 0000000000..ffcccac9c2 --- /dev/null +++ b/gradle/temporalCli.gradle @@ -0,0 +1,139 @@ +import org.gradle.api.services.BuildService +import org.gradle.api.services.BuildServiceParameters +import org.gradle.api.tasks.JavaExec +import org.gradle.api.tasks.testing.Test +import org.gradle.jvm.toolchain.JavaLanguageVersion + +import java.util.concurrent.TimeUnit + +abstract class TemporalDevServerTestService + implements BuildService, AutoCloseable { + interface Parameters extends BuildServiceParameters { + ListProperty getClasspath() + + RegularFileProperty getJavaExecutable() + } + + private Process process + + synchronized void start() { + if (process != null) { + if (!process.isAlive()) { + throw new GradleException( + "Temporal dev-server owner exited with code ${process.exitValue()}") + } + return + } + + List command = [ + parameters.javaExecutable.get().asFile.absolutePath, + '-cp', + parameters.classpath.get().join(File.pathSeparator), + 'io.temporal.testing.internal.DevServerTestProcess', + ].collect { it.toString() } + + process = new ProcessBuilder(command) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .start() + String ready = process.inputStream.newReader('UTF-8').readLine() + if (ready != 'READY') { + stop() + throw new GradleException( + 'Temporal dev-server owner exited before readiness; ' + + 'see build/temporal-cli/server/server.log') + } + } + + @Override + synchronized void close() { + stop() + } + + private void stop() { + if (process == null) { + return + } + try { + process.outputStream.close() + if (!process.waitFor(30, TimeUnit.SECONDS)) { + process.destroyForcibly() + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt() + process.destroyForcibly() + } catch (IOException e) { + process.destroyForcibly() + } finally { + process = null + } + } +} + +def devServerProfile = providers.gradleProperty('testServer') + .map { it == 'dev-server' } + .orElse(false) +def devServerJavaLauncher = project(':temporal-testing').javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(JavaVersion.current().majorVersion as int) +} +def devServerService = gradle.sharedServices.registerIfAbsent( + 'temporalDevServerTestService', TemporalDevServerTestService) { + parameters.javaExecutable.set(devServerJavaLauncher.map { it.executablePath }) + parameters.classpath.set(providers.provider { + project(':temporal-testing').sourceSets.main.runtimeClasspath.files.collect { + it.absolutePath + } + }) +} + +def prepareDevServerTests = tasks.register('prepareDevServerTests', JavaExec) { + group = 'verification' + description = + 'Caches the repository dev server and resolves inputs required by dev-server tests.' + getMainClass().set('io.temporal.testing.internal.DevServerTestPreparation') +} + +gradle.projectsEvaluated { + List standardTestTasks = subprojects.collect { subproject -> + subproject.tasks.findByName('test') + }.findAll { task -> task instanceof Test } as List + + prepareDevServerTests.configure { + classpath = project(':temporal-testing').sourceSets.main.runtimeClasspath + dependsOn project(':temporal-testing').tasks.named('classes') + dependsOn standardTestTasks.collect { testTask -> + testTask.project.tasks.named('testClasses') + } + dependsOn project(':temporal-sdk').tasks.named('compileJava17Java') + dependsOn project(':temporal-sdk').tasks.named('compileJava21Java') + dependsOn project(':temporal-sdk').tasks.named('virtualThreadTestsClasses') + doLast { + standardTestTasks.each { testTask -> testTask.classpath.files } + project(':temporal-sdk').tasks.named('virtualThreadTests', Test).get().classpath.files + } + } + + if (!devServerProfile.get()) { + return + } + + allprojects.each { candidateProject -> + candidateProject.tasks.withType(Test).configureEach { + dependsOn project(':temporal-testing').tasks.named('classes') + usesService(devServerService) + doFirst { + devServerService.get().start() + } + environment('USE_EXTERNAL_SERVICE', 'true') + environment('TEMPORAL_SERVICE_ADDRESS', 'localhost:7233') + systemProperty( + 'io.temporal.testing.internal.devServerProfile', + 'true') + } + + candidateProject.tasks.withType(JavaExec).matching { + it.name == 'registerNamespace' + }.configureEach { + onlyIf { false } + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java b/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java index b80d3489ab..4a900476fd 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/StickyWorkerTest.java @@ -21,6 +21,7 @@ import io.temporal.serviceclient.MetricsTag; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.internal.ExternalServiceTestConfigurator; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.Async; import io.temporal.workflow.CompletablePromise; @@ -52,8 +53,9 @@ public class StickyWorkerTest { private static final boolean useExternalService = - Boolean.parseBoolean(System.getenv("USE_EXTERNAL_SERVICE")); - private static final String serviceAddress = System.getenv("TEMPORAL_SERVICE_ADDRESS"); + ExternalServiceTestConfigurator.isUseExternalService(); + private static final String serviceAddress = + ExternalServiceTestConfigurator.getTemporalServiceAddress(); @Rule public TestName testName = new TestName(); diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java index 1c13a0825e..b6788df7ba 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatDeploymentVersionTest.java @@ -46,7 +46,6 @@ public void checkServerSupportsHeartbeats() { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setUseExternalService(true) .setTestTimeoutSeconds(15) .setWorkflowClientOptions( WorkflowClientOptions.newBuilder() diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java index 2684180542..2160d2901b 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerHeartbeatIntegrationTest.java @@ -65,7 +65,6 @@ public void checkServerSupportsHeartbeats() { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setUseExternalService(true) .setTestTimeoutSeconds(15) .setWorkflowClientOptions( WorkflowClientOptions.newBuilder() diff --git a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java index e72acdb6bb..86e728580e 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/GracefulPollShutdownIntegrationTest.java @@ -33,7 +33,6 @@ public class GracefulPollShutdownIntegrationTest { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() - .setUseExternalService(true) .setDoNotStart(true) .setTestTimeoutSeconds(30) .setWorkflowTypes(LoopWorkflowImpl.class) diff --git a/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java b/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java index 3b7974d459..1fb26f3452 100644 --- a/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java +++ b/temporal-sdk/src/test/java/io/temporal/workerFactory/WorkerFactoryTests.java @@ -11,6 +11,7 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.testing.internal.ExternalServiceTestConfigurator; import io.temporal.worker.WorkerFactory; import java.util.concurrent.TimeUnit; import org.junit.After; @@ -22,8 +23,9 @@ public class WorkerFactoryTests { private static final boolean useExternalService = - Boolean.parseBoolean(System.getenv("USE_EXTERNAL_SERVICE")); - private static final String serviceAddress = System.getenv("TEMPORAL_SERVICE_ADDRESS"); + ExternalServiceTestConfigurator.isUseExternalService(); + private static final String serviceAddress = + ExternalServiceTestConfigurator.getTemporalServiceAddress(); @BeforeClass public static void beforeClass() { diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java index 70cc4076bd..8f57f89ad4 100644 --- a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/WorkerVersioningTest.java @@ -13,6 +13,7 @@ import io.temporal.common.WorkflowExecutionHistory; import io.temporal.spring.boot.autoconfigure.workerversioning.TestWorkflow; import io.temporal.spring.boot.autoconfigure.workerversioning.TestWorkflow2; +import io.temporal.testing.internal.ExternalServiceTestConfigurator; import io.temporal.worker.WorkerFactory; import java.time.Duration; import org.junit.jupiter.api.Assumptions; @@ -32,15 +33,23 @@ @ActiveProfiles(profiles = {"worker-versioning", "disable-start-workers"}) @TestInstance(TestInstance.Lifecycle.PER_CLASS) public class WorkerVersioningTest { + private static final boolean useExternalService = initializeExternalService(); + @Autowired ConfigurableApplicationContext applicationContext; @Autowired WorkflowClient workflowClient; @BeforeAll static void checkExternalService() { - String useExternal = System.getenv("USE_EXTERNAL_SERVICE"); Assumptions.assumeTrue( - useExternal != null && useExternal.equalsIgnoreCase("true"), - "Skipping tests because USE_EXTERNAL_SERVICE is not set"); + useExternalService, "Skipping tests because USE_EXTERNAL_SERVICE is not set"); + } + + private static boolean initializeExternalService() { + boolean useExternalService = ExternalServiceTestConfigurator.isUseExternalService(); + if (useExternalService) { + ExternalServiceTestConfigurator.getTemporalServiceAddress(); + } + return useExternalService; } @BeforeEach diff --git a/temporal-testing/build.gradle b/temporal-testing/build.gradle index 606f88fccd..f9ca013456 100644 --- a/temporal-testing/build.gradle +++ b/temporal-testing/build.gradle @@ -17,6 +17,8 @@ dependencies { api project(':temporal-sdk') api project(':temporal-test-server') + implementation 'org.apache.commons:commons-compress:1.28.0' + // This dependency is included in temporal-sdk module as optional with compileOnly scope. // To make things easier for users, it's helpful for the testing module to bring this dependency // transitively as most users work with history jsons in tests. diff --git a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java new file mode 100644 index 0000000000..68d506d9da --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServer.java @@ -0,0 +1,77 @@ +package io.temporal.testing; + +import io.temporal.common.Experimental; +import io.temporal.testing.internal.devserver.TemporalDevServerLauncher; +import javax.annotation.Nonnull; + +/** + * A local Temporal dev server owned by the calling process. + * + *

{@code
+ * try (TemporalDevServer server = TemporalDevServer.start()) {
+ *   WorkflowServiceStubs stubs =
+ *       WorkflowServiceStubs.newServiceStubs(
+ *           WorkflowServiceStubsOptions.newBuilder().setTarget(server.getTarget()).build());
+ *   // Use stubs against server.getNamespace().
+ * }
+ * }
+ */ +@Experimental +public final class TemporalDevServer implements AutoCloseable { + private final String target; + private final String namespace; + private final AutoCloseable owner; + + private TemporalDevServer( + @Nonnull String target, @Nonnull String namespace, @Nonnull AutoCloseable owner) { + this.target = target; + this.namespace = namespace; + this.owner = owner; + } + + /** Starts a dev server in namespace {@code default} with default options. */ + public static TemporalDevServer start() { + return start("default", TemporalDevServerOptions.getDefaultInstance()); + } + + /** Starts a dev server in namespace {@code default} with the supplied options. */ + public static TemporalDevServer start(@Nonnull TemporalDevServerOptions options) { + return start("default", options); + } + + /** Starts a dev server for the supplied namespace and options. */ + public static TemporalDevServer start( + @Nonnull String namespace, @Nonnull TemporalDevServerOptions options) { + if (namespace == null || namespace.trim().isEmpty()) { + throw new IllegalArgumentException("namespace cannot be blank"); + } + if (options == null) { + throw new NullPointerException("options"); + } + TemporalDevServerLauncher.RunningServer running = + TemporalDevServerLauncher.start(namespace, options); + return new TemporalDevServer(running.getTarget(), namespace, running); + } + + /** Returns the usable {@code host:port} gRPC target. */ + public String getTarget() { + return target; + } + + /** Returns the namespace created by the dev server. */ + public String getNamespace() { + return namespace; + } + + /** Stops the owned process. This method is idempotent. */ + @Override + public void close() { + try { + owner.close(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed stopping Temporal dev server at " + target, e); + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java new file mode 100644 index 0000000000..2d89f2f941 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/TemporalDevServerOptions.java @@ -0,0 +1,370 @@ +package io.temporal.testing; + +import io.temporal.common.Experimental; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Options for a {@link TemporalDevServer}. */ +@Experimental +public final class TemporalDevServerOptions { + private static final TemporalDevServerOptions DEFAULT_INSTANCE = newBuilder().build(); + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(@Nonnull TemporalDevServerOptions options) { + return new Builder(options); + } + + public static TemporalDevServerOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + public static final class Builder { + private String existingPath; + private String downloadVersion = "default"; + private String downloadDestination; + private Duration downloadCacheTtl; + private boolean downloadEnabled = true; + private String ip = "127.0.0.1"; + private Integer port; + private String databaseFilename; + private boolean uiEnabled; + private Integer uiPort; + private String logFormat = "pretty"; + private String logLevel = "warn"; + private String workingDirectory; + private String logFile; + private Duration startupTimeout = Duration.ofSeconds(60); + private List extraArgs = new ArrayList<>(); + + private Builder() {} + + private Builder(@Nonnull TemporalDevServerOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.existingPath = options.existingPath; + this.downloadVersion = options.downloadVersion; + this.downloadDestination = options.downloadDestination; + this.downloadCacheTtl = options.downloadCacheTtl; + this.downloadEnabled = options.downloadEnabled; + this.ip = options.ip; + this.port = options.port; + this.databaseFilename = options.databaseFilename; + this.uiEnabled = options.uiEnabled; + this.uiPort = options.uiPort; + this.logFormat = options.logFormat; + this.logLevel = options.logLevel; + this.workingDirectory = options.workingDirectory; + this.logFile = options.logFile; + this.startupTimeout = options.startupTimeout; + this.extraArgs = new ArrayList<>(options.extraArgs); + } + + /** Sets an existing Temporal CLI executable instead of using the download cache. */ + public Builder setExistingPath(@Nullable String existingPath) { + this.existingPath = existingPath; + return this; + } + + /** + * Sets the CLI version to download. {@code "default"} selects the version associated with the + * running sdk-java version; any other non-empty value is sent to temporal.download unchanged. + */ + public Builder setDownloadVersion(@Nonnull String downloadVersion) { + this.downloadVersion = downloadVersion; + return this; + } + + /** Sets the root directory for cached downloads. Defaults to the JVM temporary directory. */ + public Builder setDownloadDestination(@Nullable String downloadDestination) { + this.downloadDestination = downloadDestination; + return this; + } + + /** Sets the maximum age of a cached executable. A null value caches indefinitely. */ + public Builder setDownloadCacheTtl(@Nullable Duration downloadCacheTtl) { + this.downloadCacheTtl = downloadCacheTtl; + return this; + } + + /** Alias for {@link #setDownloadCacheTtl(Duration)}. */ + public Builder setDownloadTtl(@Nullable Duration downloadCacheTtl) { + return setDownloadCacheTtl(downloadCacheTtl); + } + + /** Sets whether a missing or expired executable may be downloaded. */ + public Builder setDownloadEnabled(boolean downloadEnabled) { + this.downloadEnabled = downloadEnabled; + return this; + } + + /** Sets the IP address on which the dev server listens. */ + public Builder setIp(@Nonnull String ip) { + this.ip = ip; + return this; + } + + /** Alias for {@link #setIp(String)}. */ + public Builder setBindIp(@Nonnull String ip) { + return setIp(ip); + } + + /** Sets the gRPC port. A null value asks the OS for an available port. */ + public Builder setPort(@Nullable Integer port) { + this.port = port; + return this; + } + + /** Sets an SQLite database filename. A null value uses in-memory SQLite. */ + public Builder setDatabaseFilename(@Nullable String databaseFilename) { + this.databaseFilename = databaseFilename; + return this; + } + + /** Sets whether the Temporal UI is enabled. */ + public Builder setUiEnabled(boolean uiEnabled) { + this.uiEnabled = uiEnabled; + return this; + } + + /** Alias for {@link #setUiEnabled(boolean)}. */ + public Builder setUi(boolean uiEnabled) { + return setUiEnabled(uiEnabled); + } + + /** Sets the UI port and implicitly enables the UI. */ + public Builder setUiPort(@Nullable Integer uiPort) { + this.uiPort = uiPort; + if (uiPort != null) { + this.uiEnabled = true; + } + return this; + } + + /** Sets the Temporal CLI log format. Defaults to {@code pretty}. */ + public Builder setLogFormat(@Nonnull String logFormat) { + this.logFormat = logFormat; + return this; + } + + /** Sets the Temporal CLI log level. Defaults to {@code warn}. */ + public Builder setLogLevel(@Nonnull String logLevel) { + this.logLevel = logLevel; + return this; + } + + /** Sets the child process working directory. Defaults to the current working directory. */ + public Builder setWorkingDirectory(@Nullable String workingDirectory) { + this.workingDirectory = workingDirectory; + return this; + } + + /** Sets a file that receives server output. A null value inherits the parent output. */ + public Builder setLogFile(@Nullable String logFile) { + this.logFile = logFile; + return this; + } + + /** Sets the single timeout used for health and namespace readiness checks. */ + public Builder setStartupTimeout(@Nonnull Duration startupTimeout) { + this.startupTimeout = startupTimeout; + return this; + } + + /** Sets additional arguments appended to the generated {@code server start-dev} command. */ + public Builder setExtraArgs(@Nonnull List extraArgs) { + if (extraArgs == null) { + throw new NullPointerException("extraArgs"); + } + this.extraArgs = new ArrayList<>(extraArgs); + return this; + } + + /** Sets additional arguments appended to the generated {@code server start-dev} command. */ + public Builder setExtraArgs(@Nonnull String... extraArgs) { + if (extraArgs == null) { + throw new NullPointerException("extraArgs"); + } + this.extraArgs = new ArrayList<>(); + Collections.addAll(this.extraArgs, extraArgs); + return this; + } + + public TemporalDevServerOptions build() { + requireNonBlank(downloadVersion, "downloadVersion"); + requireNonBlank(ip, "ip"); + validatePort(port, "port"); + validatePort(uiPort, "uiPort"); + requireNonBlank(logFormat, "logFormat"); + requireNonBlank(logLevel, "logLevel"); + if (existingPath != null) { + requireNonBlank(existingPath, "existingPath"); + } + if (downloadDestination != null) { + requireNonBlank(downloadDestination, "downloadDestination"); + } + if (databaseFilename != null) { + requireNonBlank(databaseFilename, "databaseFilename"); + } + if (workingDirectory != null) { + requireNonBlank(workingDirectory, "workingDirectory"); + } + if (logFile != null) { + requireNonBlank(logFile, "logFile"); + } + if (downloadCacheTtl != null && downloadCacheTtl.isNegative()) { + throw new IllegalArgumentException("downloadCacheTtl cannot be negative"); + } + if (startupTimeout == null || startupTimeout.isZero() || startupTimeout.isNegative()) { + throw new IllegalArgumentException("startupTimeout must be positive"); + } + for (String arg : extraArgs) { + if (arg == null) { + throw new IllegalArgumentException("extraArgs cannot contain null"); + } + if (arg.indexOf('\n') >= 0 || arg.indexOf('\r') >= 0) { + throw new IllegalArgumentException("extraArgs cannot contain newlines"); + } + } + return new TemporalDevServerOptions(this); + } + + private static void requireNonBlank(@Nullable String value, @Nonnull String name) { + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException(name + " cannot be blank"); + } + } + + private static void validatePort(@Nullable Integer port, @Nonnull String name) { + if (port != null && (port < 1 || port > 65535)) { + throw new IllegalArgumentException(name + " must be between 1 and 65535"); + } + } + } + + private final String existingPath; + private final String downloadVersion; + private final String downloadDestination; + private final Duration downloadCacheTtl; + private final boolean downloadEnabled; + private final String ip; + private final Integer port; + private final String databaseFilename; + private final boolean uiEnabled; + private final Integer uiPort; + private final String logFormat; + private final String logLevel; + private final String workingDirectory; + private final String logFile; + private final Duration startupTimeout; + private final List extraArgs; + + private TemporalDevServerOptions(@Nonnull Builder builder) { + this.existingPath = builder.existingPath; + this.downloadVersion = builder.downloadVersion; + this.downloadDestination = builder.downloadDestination; + this.downloadCacheTtl = builder.downloadCacheTtl; + this.downloadEnabled = builder.downloadEnabled; + this.ip = builder.ip; + this.port = builder.port; + this.databaseFilename = builder.databaseFilename; + this.uiEnabled = builder.uiEnabled; + this.uiPort = builder.uiPort; + this.logFormat = builder.logFormat; + this.logLevel = builder.logLevel; + this.workingDirectory = builder.workingDirectory; + this.logFile = builder.logFile; + this.startupTimeout = builder.startupTimeout; + this.extraArgs = Collections.unmodifiableList(new ArrayList<>(builder.extraArgs)); + } + + @Nullable + public String getExistingPath() { + return existingPath; + } + + public String getDownloadVersion() { + return downloadVersion; + } + + @Nullable + public String getDownloadDestination() { + return downloadDestination; + } + + @Nullable + public Duration getDownloadCacheTtl() { + return downloadCacheTtl; + } + + /** Alias for {@link #getDownloadCacheTtl()}. */ + @Nullable + public Duration getDownloadTtl() { + return downloadCacheTtl; + } + + public boolean isDownloadEnabled() { + return downloadEnabled; + } + + public String getIp() { + return ip; + } + + /** Alias for {@link #getIp()}. */ + public String getBindIp() { + return ip; + } + + @Nullable + public Integer getPort() { + return port; + } + + @Nullable + public String getDatabaseFilename() { + return databaseFilename; + } + + public boolean isUiEnabled() { + return uiEnabled; + } + + @Nullable + public Integer getUiPort() { + return uiPort; + } + + public String getLogFormat() { + return logFormat; + } + + public String getLogLevel() { + return logLevel; + } + + @Nullable + public String getWorkingDirectory() { + return workingDirectory; + } + + @Nullable + public String getLogFile() { + return logFile; + } + + public Duration getStartupTimeout() { + return startupTimeout; + } + + public List getExtraArgs() { + return extraArgs; + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java index 24090982e3..cca5624499 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironment.java @@ -5,6 +5,7 @@ import io.temporal.api.nexus.v1.Endpoint; import io.temporal.client.ActivityClient; import io.temporal.client.WorkflowClient; +import io.temporal.common.Experimental; import io.temporal.common.WorkflowExecutionHistory; import io.temporal.serviceclient.OperatorServiceStubs; import io.temporal.serviceclient.WorkflowServiceStubs; @@ -15,6 +16,7 @@ import java.time.Duration; import java.util.concurrent.TimeUnit; import javax.annotation.Nonnull; +import javax.annotation.Nullable; /** * TestWorkflowEnvironment provides workflow unit testing capabilities. @@ -88,6 +90,78 @@ static TestWorkflowEnvironment newInstance(TestEnvironmentOptions options) { return new TestWorkflowEnvironmentInternal(options); } + /** + * Starts a local Temporal dev server and returns an environment that owns it. + * + *

Unlike the in-memory test server, a local dev-server environment does not support time + * skipping. + * + *

{@code
+   * try (TestWorkflowEnvironment environment = TestWorkflowEnvironment.startLocal()) {
+   *   Worker worker = environment.newWorker("test-task-queue");
+   *   // Register implementations and run workflows against the local dev server.
+   * }
+   * }
+ */ + @Experimental + static TestWorkflowEnvironment startLocal() { + return startLocal( + TestEnvironmentOptions.getDefaultInstance(), TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Starts a local Temporal dev server using the environment namespace and returns an environment + * that owns it. Local dev-server environments do not support time skipping. + */ + @Experimental + static TestWorkflowEnvironment startLocal(@Nullable TestEnvironmentOptions testOptions) { + return startLocal(testOptions, TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Starts a local Temporal dev server with the supplied server options. Local dev-server + * environments do not support time skipping. + */ + @Experimental + static TestWorkflowEnvironment startLocal(@Nonnull TemporalDevServerOptions serverOptions) { + return startLocal(TestEnvironmentOptions.getDefaultInstance(), serverOptions); + } + + /** + * Starts a local Temporal dev server and returns an environment that owns it. + * + *

The namespace in {@code testOptions} is authoritative and is created by the dev server. + * Local dev-server environments do not support time skipping. + */ + @Experimental + static TestWorkflowEnvironment startLocal( + @Nullable TestEnvironmentOptions testOptions, + @Nonnull TemporalDevServerOptions serverOptions) { + if (testOptions == null) { + testOptions = TestEnvironmentOptions.getDefaultInstance(); + } + TestEnvironmentOptions validated = + TestEnvironmentOptions.newBuilder(testOptions).validateAndBuildWithDefaults(); + String namespace = validated.getWorkflowClientOptions().getNamespace(); + TemporalDevServer server = TemporalDevServer.start(namespace, serverOptions); + try { + TestEnvironmentOptions localOptions = + TestEnvironmentOptions.newBuilder(validated) + .setUseExternalService(true) + .setUseTimeskipping(false) + .setTarget(server.getTarget()) + .build(); + return new TestWorkflowEnvironmentInternal(localOptions, server); + } catch (RuntimeException | Error failure) { + try { + server.close(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } + } + /** * Creates a new Worker instance that is connected to the in-memory test Temporal service. * diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java index a24f1e3172..c848bfeb12 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowEnvironmentInternal.java @@ -49,8 +49,16 @@ public final class TestWorkflowEnvironmentInternal implements TestWorkflowEnviro private final WorkerFactory workerFactory; private final @Nullable TimeLockingInterceptor timeLockingInterceptor; private final IdempotentTimeLocker constructorTimeLock; + private final @Nullable TemporalDevServer ownedDevServer; public TestWorkflowEnvironmentInternal(@Nullable TestEnvironmentOptions testEnvironmentOptions) { + this(testEnvironmentOptions, null); + } + + TestWorkflowEnvironmentInternal( + @Nullable TestEnvironmentOptions testEnvironmentOptions, + @Nullable TemporalDevServer ownedDevServer) { + this.ownedDevServer = ownedDevServer; if (testEnvironmentOptions == null) { testEnvironmentOptions = TestEnvironmentOptions.getDefaultInstance(); } @@ -299,24 +307,49 @@ public WorkflowExecutionHistory getWorkflowExecutionHistory( @Override public void close() { - if (testServiceStubs != null) { - testServiceStubs.shutdownNow(); - } - operatorServiceStubs.shutdownNow(); - workerFactory.shutdownNow(); - workerFactory.awaitTermination(10, TimeUnit.SECONDS); - if (constructorTimeLock != null) { - constructorTimeLock.unlockTimeSkipping(); + RuntimeException failure = null; + try { + if (testServiceStubs != null) { + failure = runCleanup(failure, testServiceStubs::shutdownNow); + } + failure = runCleanup(failure, operatorServiceStubs::shutdownNow); + failure = runCleanup(failure, workerFactory::shutdownNow); + failure = runCleanup(failure, () -> workerFactory.awaitTermination(10, TimeUnit.SECONDS)); + if (constructorTimeLock != null) { + failure = runCleanup(failure, constructorTimeLock::unlockTimeSkipping); + } + failure = runCleanup(failure, workflowServiceStubs::shutdownNow); + if (testServiceStubs != null) { + failure = runCleanup(failure, () -> testServiceStubs.awaitTermination(1, TimeUnit.SECONDS)); + } + failure = + runCleanup(failure, () -> operatorServiceStubs.awaitTermination(1, TimeUnit.SECONDS)); + failure = + runCleanup(failure, () -> workflowServiceStubs.awaitTermination(1, TimeUnit.SECONDS)); + if (inProcessServer != null) { + failure = runCleanup(failure, inProcessServer::close); + } + } finally { + if (ownedDevServer != null) { + failure = runCleanup(failure, ownedDevServer::close); + } } - workflowServiceStubs.shutdownNow(); - if (testServiceStubs != null) { - testServiceStubs.awaitTermination(1, TimeUnit.SECONDS); + if (failure != null) { + throw failure; } - operatorServiceStubs.awaitTermination(1, TimeUnit.SECONDS); - workflowServiceStubs.awaitTermination(1, TimeUnit.SECONDS); - if (inProcessServer != null) { - inProcessServer.close(); + } + + private static RuntimeException runCleanup( + @Nullable RuntimeException previousFailure, @Nonnull Runnable cleanup) { + try { + cleanup.run(); + } catch (RuntimeException failure) { + if (previousFailure == null) { + return failure; + } + previousFailure.addSuppressed(failure); } + return previousFailure; } @Override diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java index c508c72803..22c5aae9b0 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowExtension.java @@ -10,6 +10,7 @@ import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; +import io.temporal.common.Experimental; import io.temporal.common.metadata.POJOWorkflowImplMetadata; import io.temporal.common.metadata.POJOWorkflowInterfaceMetadata; import io.temporal.serviceclient.WorkflowServiceStubsOptions; @@ -67,6 +68,12 @@ public class TestWorkflowExtension implements ParameterResolver, TestWatcher, BeforeEachCallback, AfterEachCallback { + private enum ServiceType { + IN_MEMORY, + EXTERNAL, + DEV_SERVER + } + private static final String TEST_ENVIRONMENT_KEY = "testEnvironment"; private static final String WORKER_KEY = "worker"; private static final String WORKFLOW_OPTIONS_KEY = "workflowOptions"; @@ -79,7 +86,8 @@ public class TestWorkflowExtension private final Map, WorkflowImplementationOptions> workflowTypes; private final Object[] activityImplementations; private final Object[] nexusServiceImplementations; - private final boolean useExternalService; + private final ServiceType serviceType; + private final TemporalDevServerOptions devServerOptions; private final String target; private final boolean doNotStart; private final boolean doNotSetupNexusEndpoint; @@ -104,7 +112,8 @@ private TestWorkflowExtension(Builder builder) { workflowTypes = builder.workflowTypes; activityImplementations = builder.activityImplementations; nexusServiceImplementations = builder.nexusServiceImplementations; - useExternalService = builder.useExternalService; + serviceType = builder.serviceType; + devServerOptions = builder.devServerOptions; target = builder.target; doNotStart = builder.doNotStart; doNotSetupNexusEndpoint = builder.doNotSetupNexusEndpoint; @@ -198,35 +207,48 @@ public void beforeEach(ExtensionContext context) { .map(annotation -> Instant.parse(annotation.value()).toEpochMilli()) .orElse(initialTimeMillis); + TestEnvironmentOptions testEnvironmentOptions = createTestEnvOptions(currentInitialTimeMillis); TestWorkflowEnvironment testEnvironment = - TestWorkflowEnvironment.newInstance(createTestEnvOptions(currentInitialTimeMillis)); - - String taskQueue = - String.format("WorkflowTest-%s-%s", context.getDisplayName(), context.getUniqueId()); - String nexusEndpointName = String.format("WorkflowTestNexusEndpoint-%s", UUID.randomUUID()); - boolean createNexusEndpoint = - !doNotSetupNexusEndpoint && nexusServiceImplementations.length > 0; - Worker worker = testEnvironment.newWorker(taskQueue, workerOptions); - workflowTypes.forEach( - (wft, o) -> { - if (createNexusEndpoint) { - o = applyNexusServiceOptions(o, nexusServiceImplementations, nexusEndpointName); - } - worker.registerWorkflowImplementationTypes(o, wft); - }); - worker.registerActivitiesImplementations(activityImplementations); - worker.registerNexusServiceImplementation(nexusServiceImplementations); - - if (!doNotStart) { - testEnvironment.start(); - } - if (createNexusEndpoint) { - setNexusEndpoint(context, testEnvironment.createNexusEndpoint(nexusEndpointName, taskQueue)); - } - - setTestEnvironment(context, testEnvironment); - setWorker(context, worker); - setWorkflowOptions(context, WorkflowOptions.newBuilder().setTaskQueue(taskQueue).build()); + serviceType == ServiceType.DEV_SERVER + ? TestWorkflowEnvironment.startLocal(testEnvironmentOptions, devServerOptions) + : TestWorkflowEnvironment.newInstance(testEnvironmentOptions); + + try { + String taskQueue = + String.format("WorkflowTest-%s-%s", context.getDisplayName(), context.getUniqueId()); + String nexusEndpointName = String.format("WorkflowTestNexusEndpoint-%s", UUID.randomUUID()); + boolean createNexusEndpoint = + !doNotSetupNexusEndpoint && nexusServiceImplementations.length > 0; + Worker worker = testEnvironment.newWorker(taskQueue, workerOptions); + workflowTypes.forEach( + (wft, o) -> { + if (createNexusEndpoint) { + o = applyNexusServiceOptions(o, nexusServiceImplementations, nexusEndpointName); + } + worker.registerWorkflowImplementationTypes(o, wft); + }); + worker.registerActivitiesImplementations(activityImplementations); + worker.registerNexusServiceImplementation(nexusServiceImplementations); + + if (!doNotStart) { + testEnvironment.start(); + } + if (createNexusEndpoint) { + setNexusEndpoint( + context, testEnvironment.createNexusEndpoint(nexusEndpointName, taskQueue)); + } + + setTestEnvironment(context, testEnvironment); + setWorker(context, worker); + setWorkflowOptions(context, WorkflowOptions.newBuilder().setTaskQueue(taskQueue).build()); + } catch (RuntimeException | Error failure) { + try { + testEnvironment.close(); + } catch (RuntimeException | Error cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } + throw failure; + } } protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { @@ -234,7 +256,7 @@ protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { .setWorkflowClientOptions(workflowClientOptions) .setActivityClientOptions(activityClientOptions) .setWorkerFactoryOptions(workerFactoryOptions) - .setUseExternalService(useExternalService) + .setUseExternalService(serviceType == ServiceType.EXTERNAL) .setUseTimeskipping(useTimeskipping) .setTarget(target) .setInitialTimeMillis(initialTimeMillis) @@ -255,8 +277,10 @@ public void afterEach(ExtensionContext context) { @Override public void testFailed(ExtensionContext context, Throwable cause) { - TestWorkflowEnvironment testEnvironment = getTestEnvironment(context); - System.err.println("Workflow execution histories:\n" + testEnvironment.getDiagnostics()); + if (serviceType == ServiceType.IN_MEMORY) { + TestWorkflowEnvironment testEnvironment = getTestEnvironment(context); + System.err.println("Workflow execution histories:\n" + testEnvironment.getDiagnostics()); + } } private TestWorkflowEnvironment getTestEnvironment(ExtensionContext context) { @@ -311,7 +335,9 @@ public static class Builder { private Map, WorkflowImplementationOptions> workflowTypes = new HashMap<>(); private Object[] activityImplementations = NO_ACTIVITIES; private Object[] nexusServiceImplementations = NO_NEXUS_SERVICES; - private boolean useExternalService = false; + private ServiceType serviceType = ServiceType.IN_MEMORY; + private TemporalDevServerOptions devServerOptions = + TemporalDevServerOptions.getDefaultInstance(); private String target = null; private boolean doNotStart = false; private boolean doNotSetupNexusEndpoint = false; @@ -449,14 +475,46 @@ public Builder useExternalService() { * @see WorkflowServiceStubsOptions.Builder#setTarget(String) */ public Builder useExternalService(String target) { - this.useExternalService = true; + this.serviceType = ServiceType.EXTERNAL; this.target = target; return this; } + /** + * Uses an owned local Temporal dev server instead of the in-memory or external service. + * + *

The extension closes the server after each test. Dev-server tests do not support time + * skipping. + */ + @Experimental + public Builder useDevServer() { + return useDevServer(TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Uses an owned local Temporal dev server with the supplied options. + * + *

{@code
+     * TestWorkflowExtension.newBuilder()
+     *     .useDevServer(TemporalDevServerOptions.newBuilder().setUiEnabled(true).build())
+     *     .setWorkflowTypes(MyWorkflowImpl.class)
+     *     .build();
+     * }
+ */ + @Experimental + public Builder useDevServer(@Nonnull TemporalDevServerOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.serviceType = ServiceType.DEV_SERVER; + this.target = null; + this.devServerOptions = options; + return this; + } + /** Switches to internal in-memory Temporal service implementation (default). */ public Builder useInternalService() { - this.useExternalService = false; + this.serviceType = ServiceType.IN_MEMORY; this.target = null; return this; } diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java index c5dbce712a..1ad42e4e8f 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestWorkflowRule.java @@ -14,6 +14,7 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; import io.temporal.client.WorkflowStub; +import io.temporal.common.Experimental; import io.temporal.common.SearchAttributeKey; import io.temporal.common.interceptors.WorkerInterceptor; import io.temporal.internal.common.env.DebugModeUtils; @@ -65,6 +66,7 @@ public class TestWorkflowRule implements TestRule { private final String namespace; private final boolean useExternalService; + private final boolean useDevServer; private final boolean doNotStart; private final boolean doNotSetupNexusEndpoint; @Nullable private final Timeout globalTimeout; @@ -93,7 +95,10 @@ public class TestWorkflowRule implements TestRule { new TestWatcher() { @Override protected void failed(Throwable e, Description description) { - System.err.println("WORKFLOW EXECUTION HISTORIES:\n" + testEnvironment.getDiagnostics()); + if (!useExternalService && !useDevServer) { + System.err.println( + "WORKFLOW EXECUTION HISTORIES:\n" + testEnvironment.getDiagnostics()); + } } }; @@ -101,6 +106,7 @@ private TestWorkflowRule(Builder builder) { this.doNotStart = builder.doNotStart; this.doNotSetupNexusEndpoint = builder.doNotSetupNexusEndpoint; this.useExternalService = builder.useExternalService; + this.useDevServer = builder.useDevServer; this.namespace = (builder.namespace == null) ? RegisterTestNamespace.NAMESPACE : builder.namespace; this.workflowTypes = (builder.workflowTypes == null) ? new Class[0] : builder.workflowTypes; @@ -141,8 +147,11 @@ private TestWorkflowRule(Builder builder) { this.metricsScope = builder.metricsScope; this.searchAttributes = builder.searchAttributes; + TestEnvironmentOptions testEnvironmentOptions = createTestEnvOptions(builder.initialTimeMillis); this.testEnvironment = - TestWorkflowEnvironment.newInstance(createTestEnvOptions(builder.initialTimeMillis)); + useDevServer + ? TestWorkflowEnvironment.startLocal(testEnvironmentOptions, builder.devServerOptions) + : TestWorkflowEnvironment.newInstance(testEnvironmentOptions); } protected TestEnvironmentOptions createTestEnvOptions(long initialTimeMillis) { @@ -169,6 +178,9 @@ public static class Builder { private String namespace; private String target; private boolean useExternalService; + private boolean useDevServer; + private TemporalDevServerOptions devServerOptions = + TemporalDevServerOptions.getDefaultInstance(); private boolean doNotStart; private boolean doNotSetupNexusEndpoint; private long initialTimeMillis; @@ -261,11 +273,59 @@ public Builder setActivityImplementations(Object... activityImplementations) { /** * Switches between in-memory and external temporal service implementations. * + *

External-service and dev-server modes are mutually exclusive. Calling this method clears + * any selection made by {@link #useDevServer()} or {@link + * #useDevServer(TemporalDevServerOptions)}; whichever method is called last determines the + * service used by the rule. + * * @param useExternalService use external service if true. *

Default is false. */ public Builder setUseExternalService(boolean useExternalService) { this.useExternalService = useExternalService; + this.useDevServer = false; + return this; + } + + /** + * Uses an owned local Temporal dev server instead of the in-memory or external service. + * + *

The rule closes the server during normal teardown. Dev-server tests do not support time + * skipping. + * + *

Dev-server and external-service modes are mutually exclusive. Calling this method clears + * any selection made by {@link #setUseExternalService(boolean)}; whichever method is called + * last determines the service used by the rule. + */ + @Experimental + public Builder useDevServer() { + return useDevServer(TemporalDevServerOptions.getDefaultInstance()); + } + + /** + * Uses an owned local Temporal dev server with the supplied options. + * + *

Dev-server and external-service modes are mutually exclusive. Calling this method clears + * any selection made by {@link #setUseExternalService(boolean)}; whichever method is called + * last determines the service used by the rule. + * + *

{@code
+     * TestWorkflowRule.newBuilder()
+     *     .useDevServer(
+     *         TemporalDevServerOptions.newBuilder().setDownloadVersion("v1.7.2").build())
+     *     .setWorkflowTypes(MyWorkflowImpl.class)
+     *     .build();
+     * }
+ */ + @Experimental + public Builder useDevServer(@Nonnull TemporalDevServerOptions options) { + if (options == null) { + throw new NullPointerException("options"); + } + this.useDevServer = true; + this.useExternalService = false; + this.target = null; + this.devServerOptions = options; return this; } @@ -403,9 +463,23 @@ public Statement apply(Statement base, Description description) { new Statement() { @Override public void evaluate() throws Throwable { - start(); - base.evaluate(); - shutdown(); + Throwable testFailure = null; + try { + start(); + base.evaluate(); + } catch (Throwable failure) { + testFailure = failure; + throw failure; + } finally { + try { + shutdown(); + } catch (Throwable cleanupFailure) { + if (testFailure == null) { + throw cleanupFailure; + } + testFailure.addSuppressed(cleanupFailure); + } + } } }; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java new file mode 100644 index 0000000000..7001f0361b --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestPreparation.java @@ -0,0 +1,14 @@ +package io.temporal.testing.internal; + +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import javax.annotation.Nonnull; + +/** Download-only entry point used by sdk-java's {@code prepareDevServerTests} task. */ +public final class DevServerTestPreparation { + private DevServerTestPreparation() {} + + public static void main(@Nonnull String[] args) { + System.out.println( + "Prepared Temporal CLI at " + SdkJavaTestServerProfile.prepare().toAbsolutePath()); + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java new file mode 100644 index 0000000000..55003fab0c --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/DevServerTestProcess.java @@ -0,0 +1,22 @@ +package io.temporal.testing.internal; + +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import javax.annotation.Nonnull; + +/** Process entry point that owns sdk-java's repository dev server for a Gradle invocation. */ +public final class DevServerTestProcess { + private DevServerTestProcess() {} + + public static void main(@Nonnull String[] args) throws Exception { + try { + SdkJavaTestServerProfile.start(); + System.out.println("READY"); + System.out.flush(); + while (System.in.read() != -1) { + // The Gradle shared service keeps stdin open for the lifetime of the build. + } + } finally { + SdkJavaTestServerProfile.shutdown(); + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java index b81a9d363c..6c68d5f2b5 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java @@ -3,6 +3,8 @@ import io.temporal.internal.common.env.EnvironmentVariableUtils; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowRule; +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import javax.annotation.Nonnull; public class ExternalServiceTestConfigurator { private static boolean USE_EXTERNAL_SERVICE = @@ -13,7 +15,7 @@ public class ExternalServiceTestConfigurator { EnvironmentVariableUtils.readBooleanFlag("USE_VIRTUAL_THREADS"); public static boolean isUseExternalService() { - return USE_EXTERNAL_SERVICE; + return USE_EXTERNAL_SERVICE || SdkJavaTestServerProfile.isActive(); } public static boolean isUseVirtualThreads() { @@ -21,27 +23,33 @@ public static boolean isUseVirtualThreads() { } public static String getTemporalServiceAddress() { + if (SdkJavaTestServerProfile.isActive()) { + return SdkJavaTestServerProfile.getTarget(); + } return USE_EXTERNAL_SERVICE ? (TEMPORAL_SERVICE_ADDRESS != null ? TEMPORAL_SERVICE_ADDRESS : "127.0.0.1:7233") : null; } - public static TestWorkflowRule.Builder configure(TestWorkflowRule.Builder testWorkflowRule) { - if (USE_EXTERNAL_SERVICE) { + public static TestWorkflowRule.Builder configure( + @Nonnull TestWorkflowRule.Builder testWorkflowRule) { + if (isUseExternalService()) { testWorkflowRule.setUseExternalService(true); - if (TEMPORAL_SERVICE_ADDRESS != null) { - testWorkflowRule.setTarget(TEMPORAL_SERVICE_ADDRESS); + String target = getTemporalServiceAddress(); + if (target != null) { + testWorkflowRule.setTarget(target); } } return testWorkflowRule; } public static TestEnvironmentOptions.Builder configure( - TestEnvironmentOptions.Builder testEnvironmentOptions) { - if (USE_EXTERNAL_SERVICE) { + @Nonnull TestEnvironmentOptions.Builder testEnvironmentOptions) { + if (isUseExternalService()) { testEnvironmentOptions.setUseExternalService(true); - if (TEMPORAL_SERVICE_ADDRESS != null) { - testEnvironmentOptions.setTarget(TEMPORAL_SERVICE_ADDRESS); + String target = getTemporalServiceAddress(); + if (target != null) { + testEnvironmentOptions.setTarget(target); } } return testEnvironmentOptions; diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java new file mode 100644 index 0000000000..18215bd2f8 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -0,0 +1,165 @@ +package io.temporal.testing.internal.devserver; + +import io.temporal.testing.TemporalDevServer; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.File; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import javax.annotation.Nonnull; + +/** Configuration and lifecycle used only by sdk-java's Gradle test profile. */ +public final class SdkJavaTestServerProfile { + public static final String ACTIVE_PROPERTY = "io.temporal.testing.internal.devServerProfile"; + + // This is intentionally the sole Temporal CLI version used by sdk-java repository tests. + private static final String TEST_CLI_VERSION = "1.7.4-standalone-nexus-operations"; + private static final String TEST_NAMESPACE = "UnitTest"; + private static final String DATABASE_FILENAME = "temporal.sqlite"; + + private static TemporalDevServer server; + private static boolean shutdownHookRegistered; + + private SdkJavaTestServerProfile() {} + + public static boolean isActive() { + return Boolean.parseBoolean(System.getProperty(ACTIVE_PROPERTY, "false")); + } + + public static String getTarget() { + if (!isActive()) { + return null; + } + return "localhost:7233"; + } + + public static synchronized String start() { + if (server == null) { + File workingDirectory = workingDirectory(); + cleanDatabase(workingDirectory); + try { + server = TemporalDevServer.start(TEST_NAMESPACE, serverOptions(workingDirectory)); + } catch (RuntimeException | Error failure) { + cleanDatabase(workingDirectory); + throw failure; + } + if (!shutdownHookRegistered) { + Runtime.getRuntime() + .addShutdownHook( + new Thread(SdkJavaTestServerProfile::shutdown, "sdk-java-dev-server-shutdown")); + shutdownHookRegistered = true; + } + } + return server.getTarget(); + } + + public static Path prepare() { + return TemporalDevServerDownloader.prepare(downloadOptions()); + } + + public static synchronized void shutdown() { + if (server != null) { + server.close(); + server = null; + } + cleanDatabase(workingDirectory()); + } + + private static TemporalDevServerOptions serverOptions(@Nonnull File workingDirectory) { + return TemporalDevServerOptions.newBuilder(downloadOptions()) + .setIp("127.0.0.1") + .setPort(7233) + .setUiEnabled(false) + .setDatabaseFilename(DATABASE_FILENAME) + .setWorkingDirectory(workingDirectory.getAbsolutePath()) + .setLogFile(new File(workingDirectory, "server.log").getAbsolutePath()) + .setExtraArgs(repositoryServerArguments()) + .build(); + } + + private static TemporalDevServerOptions downloadOptions() { + return TemporalDevServerOptions.newBuilder().setDownloadVersion("v" + TEST_CLI_VERSION).build(); + } + + private static List repositoryServerArguments() { + return Arrays.asList( + "--http-port", + "7243", + "--sqlite-pragma", + "journal_mode=WAL", + "--sqlite-pragma", + "synchronous=OFF", + "--search-attribute", + "CustomKeywordField=Keyword", + "--search-attribute", + "CustomStringField=Text", + "--search-attribute", + "CustomTextField=Text", + "--search-attribute", + "CustomIntField=Int", + "--search-attribute", + "CustomDatetimeField=Datetime", + "--search-attribute", + "CustomDoubleField=Double", + "--search-attribute", + "CustomBoolField=Bool", + "--dynamic-config-value", + "system.enableActivityEagerExecution=true", + "--dynamic-config-value", + "history.MaxBufferedQueryCount=10000", + "--dynamic-config-value", + "frontend.workerVersioningDataAPIs=true", + "--dynamic-config-value", + "history.enableRequestIdRefLinks=true", + "--dynamic-config-value", + "frontend.WorkerHeartbeatsEnabled=true", + "--dynamic-config-value", + "frontend.ListWorkersEnabled=true", + "--dynamic-config-value", + "frontend.enableCancelWorkerPollsOnShutdown=true", + "--dynamic-config-value", + "component.callbacks.allowedAddresses=[{\"Pattern\":\"localhost:7243\",\"AllowInsecure\":true}]", + "--dynamic-config-value", + "callback.allowedAddresses=[{\"Pattern\":\"localhost:7243\",\"AllowInsecure\":true}]", + "--dynamic-config-value", + "frontend.activityAPIsEnabled=true", + "--dynamic-config-value", + "activity.enableStandalone=true", + "--dynamic-config-value", + "activity.enableCallbacks=true", + "--dynamic-config-value", + "activity.startDelayEnabled=true", + "--dynamic-config-value", + "nexusoperation.enableStandalone=true", + "--dynamic-config-value", + "history.enableChasm=true", + "--dynamic-config-value", + "history.enableCHASMSignalBacklinks=true", + "--dynamic-config-value", + "history.enableUpdateCallbacks=true", + "--dynamic-config-value", + "history.enableCHASMCallbacks=true", + "--dynamic-config-value", + "history.enableTransitionHistory=true", + "--dynamic-config-value", + "frontend.enableCancelWorkerPollsOnShutdown=true", + "--dynamic-config-value", + "frontend.workerCommandsEnabled=true", + "--dynamic-config-value", + "system.enableCancelActivityWorkerCommand=true"); + } + + private static File workingDirectory() { + return new File("build", "temporal-cli/server"); + } + + private static void cleanDatabase(@Nonnull File workingDirectory) { + for (String name : + Arrays.asList(DATABASE_FILENAME, DATABASE_FILENAME + "-shm", DATABASE_FILENAME + "-wal")) { + File file = new File(workingDirectory, name); + if (file.exists() && !file.delete()) { + System.err.println("Unable to delete Temporal dev-server database file " + file); + } + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java new file mode 100644 index 0000000000..02a97a1837 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloader.java @@ -0,0 +1,369 @@ +package io.temporal.testing.internal.devserver; + +import com.google.gson.Gson; +import io.temporal.serviceclient.Version; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.zip.GZIPInputStream; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import org.apache.commons.compress.archivers.ArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream; + +/** Internal downloader and executable cache for the Temporal CLI. */ +public final class TemporalDevServerDownloader { + private static final String DOWNLOAD_BASE_URL_PROPERTY = + "io.temporal.testing.devServerDownloadBaseUrl"; + private static final String DEFAULT_DOWNLOAD_BASE_URL = "https://temporal.download"; + // FileLock coordinates separate JVMs; this monitor prevents overlapping locks within one JVM. + private static final ConcurrentMap JVM_LOCKS = new ConcurrentHashMap<>(); + + private TemporalDevServerDownloader() {} + + public static Path prepare(@Nonnull TemporalDevServerOptions options) { + String existingPath = options.getExistingPath(); + if (existingPath != null) { + Path executable = new File(existingPath).toPath().toAbsolutePath().normalize(); + if (!Files.isRegularFile(executable)) { + throw new IllegalStateException( + "Temporal CLI executable does not exist or is not a file: " + executable); + } + if (!isWindows() && !Files.isExecutable(executable)) { + throw new IllegalStateException("Temporal CLI executable is not executable: " + executable); + } + return executable; + } + + Platform platform = Platform.current(); + Path cacheDirectory = cacheDirectory(options, platform); + Path executable = cacheDirectory.resolve(platform.executableName); + Object jvmLock = + JVM_LOCKS.computeIfAbsent( + cacheDirectory.toAbsolutePath().normalize().toString(), ignored -> new Object()); + synchronized (jvmLock) { + try { + Files.createDirectories(cacheDirectory); + Path lockPath = cacheDirectory.resolve(".download.lock"); + try (FileChannel lockChannel = + FileChannel.open( + lockPath, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.WRITE); + FileLock ignored = lockChannel.lock()) { + if (isUsableCacheEntry(executable, options.getDownloadCacheTtl())) { + return executable; + } + if (!options.isDownloadEnabled()) { + throw new IllegalStateException( + "Temporal CLI " + + options.getDownloadVersion() + + " for " + + platform.classifier() + + " is not cached at " + + executable + + " and downloading is disabled."); + } + Files.deleteIfExists(executable); + DownloadInfo info = getDownloadInfo(options, platform); + downloadAndExtract(info, executable, cacheDirectory); + return executable; + } + } catch (IOException e) { + throw new IllegalStateException( + "Failed preparing Temporal CLI " + options.getDownloadVersion(), e); + } + } + } + + static Path cacheDirectory( + @Nonnull TemporalDevServerOptions options, @Nonnull Platform platform) { + String destination = options.getDownloadDestination(); + Path root = + destination == null + ? new File(System.getProperty("java.io.tmpdir"), "temporal-dev-server").toPath() + : new File(destination).toPath(); + String version = + "default".equals(options.getDownloadVersion()) + ? "default-sdk-java-" + safePathPart(Version.LIBRARY_VERSION) + : safePathPart(options.getDownloadVersion()); + return root.toAbsolutePath().normalize().resolve(version).resolve(platform.classifier()); + } + + private static boolean isUsableCacheEntry(@Nonnull Path executable, @Nullable Duration ttl) + throws IOException { + if (!Files.isRegularFile(executable)) { + return false; + } + if (!isWindows() && !Files.isExecutable(executable)) { + return false; + } + if (ttl == null) { + return true; + } + long ageMillis = + Math.max(0, System.currentTimeMillis() - Files.getLastModifiedTime(executable).toMillis()); + return ageMillis <= ttl.toMillis(); + } + + private static DownloadInfo getDownloadInfo( + @Nonnull TemporalDevServerOptions options, @Nonnull Platform platform) throws IOException { + String version = encodeQueryValue(options.getDownloadVersion()).replace("+", "%20"); + StringBuilder url = + new StringBuilder( + System.getProperty(DOWNLOAD_BASE_URL_PROPERTY, DEFAULT_DOWNLOAD_BASE_URL) + + "/cli/" + + version + + "?platform=" + + encodeQueryValue(platform.platform) + + "&arch=" + + encodeQueryValue(platform.architecture) + + "&format=tar.gz"); + if ("default".equals(options.getDownloadVersion())) { + url.append("&sdk-name=sdk-java"); + url.append("&sdk-version=").append(encodeQueryValue(Version.LIBRARY_VERSION)); + } + HttpURLConnection connection = openFollowingRedirects(url.toString()); + try { + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + throw new IOException( + "temporal.download returned HTTP " + status + " for " + connection.getURL()); + } + try (InputStreamReader reader = + new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8)) { + DownloadInfo info = new Gson().fromJson(reader, DownloadInfo.class); + if (info == null || isBlank(info.archiveUrl) || isBlank(info.fileToExtract)) { + throw new IOException("temporal.download returned incomplete download metadata"); + } + return info; + } + } finally { + connection.disconnect(); + } + } + + private static void downloadAndExtract( + @Nonnull DownloadInfo info, @Nonnull Path executable, @Nonnull Path cacheDirectory) + throws IOException { + Path archive = + cacheDirectory.resolve("archive-" + UUID.randomUUID().toString() + ".downloading"); + Path extracted = + cacheDirectory.resolve(executable.getFileName() + "." + UUID.randomUUID() + ".extracting"); + try { + HttpURLConnection connection = openFollowingRedirects(info.archiveUrl); + try { + int status = connection.getResponseCode(); + if (status < 200 || status >= 300) { + throw new IOException( + "CLI archive download returned HTTP " + status + " for " + connection.getURL()); + } + try (InputStream input = new BufferedInputStream(connection.getInputStream()); + OutputStream output = + new BufferedOutputStream(new FileOutputStream(archive.toFile()))) { + copy(input, output); + } + } finally { + connection.disconnect(); + } + + extractRequestedFile(archive, info.fileToExtract, extracted); + if (!isWindows() && !extracted.toFile().setExecutable(true, true)) { + throw new IOException("Unable to make Temporal CLI executable: " + extracted); + } + atomicMove(extracted, executable); + } finally { + Files.deleteIfExists(archive); + Files.deleteIfExists(extracted); + } + } + + static void extractRequestedFile( + @Nonnull Path archive, @Nonnull String requestedName, @Nonnull Path destination) + throws IOException { + try (BufferedInputStream input = + new BufferedInputStream(new FileInputStream(archive.toFile()))) { + input.mark(4); + int first = input.read(); + int second = input.read(); + input.reset(); + if (first == 'P' && second == 'K') { + try (ZipArchiveInputStream zip = new ZipArchiveInputStream(input)) { + extractEntry(zip, requestedName, destination); + } + } else { + try (TarArchiveInputStream tar = new TarArchiveInputStream(new GZIPInputStream(input))) { + extractEntry(tar, requestedName, destination); + } + } + } + } + + private static void extractEntry( + @Nonnull org.apache.commons.compress.archivers.ArchiveInputStream archive, + @Nonnull String requestedName, + @Nonnull Path destination) + throws IOException { + String normalizedRequested = normalizeArchiveName(requestedName); + ArchiveEntry entry; + while ((entry = archive.getNextEntry()) != null) { + if (!entry.isDirectory() + && normalizeArchiveName(entry.getName()).equals(normalizedRequested)) { + try (OutputStream output = + new BufferedOutputStream(new FileOutputStream(destination.toFile()))) { + copy(archive, output); + } + return; + } + } + throw new IOException("CLI archive did not contain " + requestedName); + } + + private static String normalizeArchiveName(@Nonnull String name) { + String normalized = name.replace('\\', '/'); + while (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + return normalized; + } + + private static HttpURLConnection openFollowingRedirects(@Nonnull String url) throws IOException { + String next = url; + for (int redirects = 0; redirects <= 5; redirects++) { + HttpURLConnection connection = (HttpURLConnection) URI.create(next).toURL().openConnection(); + connection.setConnectTimeout(15_000); + connection.setReadTimeout(60_000); + connection.setRequestProperty("Accept", "application/json, application/octet-stream"); + connection.setRequestProperty("User-Agent", "temporal-sdk-java/" + Version.LIBRARY_VERSION); + connection.setInstanceFollowRedirects(false); + int status = connection.getResponseCode(); + if (status != HttpURLConnection.HTTP_MOVED_PERM + && status != HttpURLConnection.HTTP_MOVED_TEMP + && status != HttpURLConnection.HTTP_SEE_OTHER + && status != 307 + && status != 308) { + return connection; + } + String location = connection.getHeaderField("Location"); + if (location == null) { + return connection; + } + next = URI.create(next).resolve(location).toString(); + connection.disconnect(); + } + throw new IOException("Too many redirects downloading " + url); + } + + private static void atomicMove(@Nonnull Path source, @Nonnull Path destination) + throws IOException { + try { + Files.move( + source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, destination, StandardCopyOption.REPLACE_EXISTING); + } + } + + private static void copy(@Nonnull InputStream input, @Nonnull OutputStream output) + throws IOException { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } + + private static String encodeQueryValue(@Nonnull String value) { + try { + return URLEncoder.encode(value, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new AssertionError(e); + } + } + + private static String safePathPart(@Nonnull String value) { + return value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private static boolean isBlank(@Nullable String value) { + return value == null || value.trim().isEmpty(); + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("windows"); + } + + private static final class DownloadInfo { + private String archiveUrl; + private String fileToExtract; + } + + static final class Platform { + private final String platform; + private final String architecture; + private final String executableName; + + private Platform( + @Nonnull String platform, @Nonnull String architecture, @Nonnull String executableName) { + this.platform = platform; + this.architecture = architecture; + this.executableName = executableName; + } + + static Platform current() { + String os = System.getProperty("os.name").toLowerCase(Locale.ROOT); + String platform; + String executableName; + if (os.contains("mac") || os.contains("darwin")) { + platform = "darwin"; + executableName = "temporal"; + } else if (os.contains("windows")) { + platform = "windows"; + executableName = "temporal.exe"; + } else if (os.contains("linux")) { + platform = "linux"; + executableName = "temporal"; + } else { + throw new IllegalStateException("Unsupported operating system: " + os); + } + + String machine = System.getProperty("os.arch").toLowerCase(Locale.ROOT); + String architecture; + if (machine.equals("x86_64") || machine.equals("amd64")) { + architecture = "amd64"; + } else if (machine.equals("aarch64") || machine.equals("arm64")) { + architecture = "arm64"; + } else { + throw new IllegalStateException("Unsupported architecture: " + machine); + } + return new Platform(platform, architecture, executableName); + } + + String classifier() { + return platform + "_" + architecture; + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java new file mode 100644 index 0000000000..58cd6c9ba1 --- /dev/null +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/TemporalDevServerLauncher.java @@ -0,0 +1,304 @@ +package io.temporal.testing.internal.devserver; + +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.StatusRuntimeException; +import io.grpc.health.v1.HealthCheckRequest; +import io.grpc.health.v1.HealthCheckResponse; +import io.grpc.health.v1.HealthGrpc; +import io.temporal.api.workflowservice.v1.DescribeNamespaceRequest; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** Internal ProcessBuilder-based Temporal dev-server launcher. */ +public final class TemporalDevServerLauncher { + private static final int LOG_TAIL_LINES = 200; + private static final long GRACEFUL_SHUTDOWN_SECONDS = 10; + + private TemporalDevServerLauncher() {} + + public static RunningServer start( + @Nonnull String namespace, @Nonnull TemporalDevServerOptions options) { + Path executable = TemporalDevServerDownloader.prepare(options); + int port = options.getPort() == null ? reservePort(options.getIp()) : options.getPort(); + String target = targetHost(options.getIp()) + ":" + port; + List command = buildCommand(executable, namespace, options, port); + + Process process = null; + File logFile = options.getLogFile() == null ? null : new File(options.getLogFile()); + try { + ProcessBuilder processBuilder = new ProcessBuilder(command).redirectErrorStream(true); + if (options.getWorkingDirectory() != null) { + File workingDirectory = new File(options.getWorkingDirectory()); + if (!workingDirectory.isDirectory() && !workingDirectory.mkdirs()) { + throw new IOException("Unable to create working directory " + workingDirectory); + } + processBuilder.directory(workingDirectory); + } + configureOutput(processBuilder, logFile); + process = processBuilder.start(); + waitUntilReady(process, target, namespace, options, command, logFile); + return new RunningServer(target, process); + } catch (Throwable failure) { + stopProcess(process); + if (failure instanceof Error) { + throw (Error) failure; + } + if (failure instanceof IllegalStateException) { + throw (IllegalStateException) failure; + } + throw startupFailure("Unable to start Temporal dev server", command, logFile, failure); + } + } + + private static void configureOutput( + @Nonnull ProcessBuilder processBuilder, @Nullable File logFile) throws IOException { + if (logFile == null) { + processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT); + return; + } + File parent = logFile.getAbsoluteFile().getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + throw new IOException("Unable to create log directory " + parent); + } + processBuilder.redirectOutput(ProcessBuilder.Redirect.to(logFile)); + } + + static List buildCommand( + @Nonnull Path executable, + @Nonnull String namespace, + @Nonnull TemporalDevServerOptions options, + int port) { + List command = new ArrayList<>(); + command.add(executable.toAbsolutePath().toString()); + command.add("server"); + command.add("start-dev"); + command.add("--port"); + command.add(Integer.toString(port)); + command.add("--namespace"); + command.add(namespace); + command.add("--ip"); + command.add(options.getIp()); + command.add("--log-format"); + command.add(options.getLogFormat()); + command.add("--log-level"); + command.add(options.getLogLevel()); + // Keep these defaults in sync with sdk-core's TemporalDevServerConfig. + command.add("--dynamic-config-value"); + command.add("frontend.enableServerVersionCheck=false"); + command.add("--dynamic-config-value"); + command.add("frontend.enableUpdateWorkflowExecution=true"); + command.add("--dynamic-config-value"); + command.add("frontend.enableUpdateWorkflowExecutionAsyncAccepted=true"); + if (options.getDatabaseFilename() != null) { + command.add("--db-filename"); + command.add(options.getDatabaseFilename()); + } + if (options.getUiPort() != null) { + command.add("--ui-port"); + command.add(Integer.toString(options.getUiPort())); + } else if (options.isUiEnabled()) { + command.add("--ui-port"); + command.add(Integer.toString(Math.min(65535, port + 1000))); + } else { + command.add("--headless"); + } + command.addAll(options.getExtraArgs()); + return command; + } + + private static void waitUntilReady( + @Nonnull Process process, + @Nonnull String target, + @Nonnull String namespace, + @Nonnull TemporalDevServerOptions options, + @Nonnull List command, + @Nullable File logFile) { + long timeoutNanos = options.getStartupTimeout().toNanos(); + long startNanos = System.nanoTime(); + long deadlineNanos = + Long.MAX_VALUE - startNanos < timeoutNanos ? Long.MAX_VALUE : startNanos + timeoutNanos; + ManagedChannel channel = + ManagedChannelBuilder.forTarget(target).usePlaintext().directExecutor().build(); + Throwable lastFailure = null; + try { + while (System.nanoTime() < deadlineNanos) { + if (!process.isAlive()) { + throw startupFailure( + "Temporal dev server exited prematurely with code " + process.exitValue(), + command, + logFile, + lastFailure); + } + long remainingNanos = deadlineNanos - System.nanoTime(); + long rpcNanos = Math.max(1, Math.min(TimeUnit.SECONDS.toNanos(1), remainingNanos)); + try { + HealthCheckResponse health = + HealthGrpc.newBlockingStub(channel) + .withDeadlineAfter(rpcNanos, TimeUnit.NANOSECONDS) + .check( + HealthCheckRequest.newBuilder() + .setService(WorkflowServiceGrpc.SERVICE_NAME) + .build()); + if (health.getStatus() != HealthCheckResponse.ServingStatus.SERVING) { + throw new IllegalStateException("gRPC health service is " + health.getStatus()); + } + WorkflowServiceGrpc.newBlockingStub(channel) + .withDeadlineAfter(rpcNanos, TimeUnit.NANOSECONDS) + .describeNamespace( + DescribeNamespaceRequest.newBuilder().setNamespace(namespace).build()); + return; + } catch (StatusRuntimeException | IllegalStateException e) { + lastFailure = e; + } + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw startupFailure( + "Interrupted while waiting for Temporal dev server", command, logFile, e); + } + } + throw startupFailure( + "Temporal dev server did not become ready within " + options.getStartupTimeout(), + command, + logFile, + lastFailure); + } finally { + channel.shutdownNow(); + try { + channel.awaitTermination(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + private static IllegalStateException startupFailure( + @Nonnull String message, + @Nonnull List command, + @Nullable File logFile, + @Nullable Throwable cause) { + return new IllegalStateException( + message + + "\nCommand: " + + renderCommand(command) + + "\nTemporal dev-server output tail:\n" + + readOutputTail(logFile), + cause); + } + + private static String readOutputTail(@Nullable File logFile) { + if (logFile == null) { + return ""; + } + if (!logFile.isFile()) { + return ""; + } + Deque tail = new ArrayDeque<>(); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(new FileInputStream(logFile), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + tail.addLast(line); + while (tail.size() > LOG_TAIL_LINES) { + tail.removeFirst(); + } + } + } catch (IOException e) { + return ""; + } + if (tail.isEmpty()) { + return ""; + } + return String.join(System.lineSeparator(), tail); + } + + private static String renderCommand(@Nonnull List command) { + StringBuilder rendered = new StringBuilder(); + for (String part : command) { + if (rendered.length() > 0) { + rendered.append(' '); + } + if (part.indexOf(' ') >= 0) { + rendered.append('"').append(part.replace("\"", "\\\"")).append('"'); + } else { + rendered.append(part); + } + } + return rendered.toString(); + } + + private static int reservePort(@Nonnull String ip) { + try (ServerSocket socket = new ServerSocket(0, 0, InetAddress.getByName(ip))) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } catch (IOException e) { + throw new IllegalStateException("Unable to reserve a port on " + ip, e); + } + } + + private static String targetHost(@Nonnull String ip) { + if ("0.0.0.0".equals(ip) || "::".equals(ip) || "0:0:0:0:0:0:0:0".equals(ip)) { + return "127.0.0.1"; + } + return ip.indexOf(':') >= 0 ? "[" + ip + "]" : ip; + } + + private static void stopProcess(@Nullable Process process) { + if (process == null || !process.isAlive()) { + return; + } + process.destroy(); + try { + if (!process.waitFor(GRACEFUL_SHUTDOWN_SECONDS, TimeUnit.SECONDS)) { + process.destroyForcibly(); + process.waitFor(GRACEFUL_SHUTDOWN_SECONDS, TimeUnit.SECONDS); + } + } catch (InterruptedException e) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + } + + public static final class RunningServer implements AutoCloseable { + private final String target; + private final Process process; + private final AtomicBoolean closed = new AtomicBoolean(); + + private RunningServer(@Nonnull String target, @Nonnull Process process) { + this.target = target; + this.process = process; + } + + public String getTarget() { + return target; + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + stopProcess(process); + } + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java new file mode 100644 index 0000000000..c8266c6bb1 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerIntegrationTest.java @@ -0,0 +1,208 @@ +package io.temporal.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; +import io.temporal.common.interceptors.WorkflowClientInterceptorBase; +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; +import org.junit.runner.Description; +import org.junit.runners.model.Statement; + +/** Integration coverage for dev-server ownership using sdk-java's pinned real Temporal CLI. */ +@EnabledIfSystemProperty(named = SdkJavaTestServerProfile.ACTIVE_PROPERTY, matches = "true") +class TemporalDevServerIntegrationTest { + private static Path temporalCli; + + @TempDir Path tempDirectory; + + @BeforeAll + static void prepareTemporalCli() { + temporalCli = SdkJavaTestServerProfile.prepare(); + } + + @Test + void standaloneServerBecomesReadyAndCloseIsIdempotent() throws Exception { + TemporalDevServer server = TemporalDevServer.start("MyNamespace", realServerOptions().build()); + String target = server.getTarget(); + + assertEquals("MyNamespace", server.getNamespace()); + assertTrue(canConnect(target)); + + server.close(); + server.close(); + + assertTrue(awaitClosed(target)); + } + + @Test + void prematureExitIncludesCommandAndCliOutput() { + TemporalDevServerOptions options = + realServerOptions().setExtraArgs("--definitely-not-a-real-temporal-cli-argument").build(); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> TemporalDevServer.start("default", options)); + + assertTrue(failure.getMessage().contains("exited prematurely")); + assertTrue(failure.getMessage().contains("--definitely-not-a-real-temporal-cli-argument")); + assertTrue(failure.getMessage().contains("Temporal dev-server output tail")); + } + + @Test + void startupTimeoutStopsCliProcess() throws Exception { + int port = availablePort(); + TemporalDevServerOptions options = + realServerOptions().setPort(port).setStartupTimeout(Duration.ofNanos(1)).build(); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> TemporalDevServer.start("default", options)); + + assertTrue(failure.getMessage().contains("did not become ready")); + assertTrue(awaitClosed("127.0.0.1:" + port)); + } + + @Test + void environmentOwnsServerAndUsesEnvironmentNamespace() throws Exception { + String firstTarget; + try (TestWorkflowEnvironment environment = + TestWorkflowEnvironment.startLocal(realServerOptions().build())) { + assertEquals("default", environment.getNamespace()); + firstTarget = environment.getWorkflowServiceStubs().getOptions().getTarget(); + assertTrue(canConnect(firstTarget)); + } + assertTrue(awaitClosed(firstTarget)); + + TestEnvironmentOptions testOptions = + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setNamespace("Authoritative").build()) + .build(); + String combinedTarget; + try (TestWorkflowEnvironment environment = + TestWorkflowEnvironment.startLocal(testOptions, realServerOptions().build())) { + assertEquals("Authoritative", environment.getNamespace()); + combinedTarget = environment.getWorkflowServiceStubs().getOptions().getTarget(); + assertTrue(canConnect(combinedTarget)); + } + assertTrue(awaitClosed(combinedTarget)); + } + + @Test + void constructionFailureStopsPartiallyStartedServer() throws Exception { + int port = availablePort(); + TestEnvironmentOptions testOptions = + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setInterceptors( + new WorkflowClientInterceptorBase() { + @Override + public WorkflowClientCallsInterceptor workflowClientCallsInterceptor( + WorkflowClientCallsInterceptor next) { + throw new DeliberateTestFailure(); + } + }) + .build()) + .build(); + + assertThrows( + DeliberateTestFailure.class, + () -> + TestWorkflowEnvironment.startLocal( + testOptions, realServerOptions().setPort(port).build())); + + assertTrue(awaitClosed("127.0.0.1:" + port)); + } + + @Test + void junit4RuleClosesServerAfterSuccessAndFailure() throws Throwable { + TestWorkflowRule successfulRule = + TestWorkflowRule.newBuilder() + .useDevServer(realServerOptions().build()) + .setWorkflowTypes() + .build(); + String successTarget = successfulRule.getWorkflowServiceStubs().getOptions().getTarget(); + successfulRule + .apply( + new Statement() { + @Override + public void evaluate() {} + }, + Description.createTestDescription(getClass(), "successfulRule")) + .evaluate(); + assertTrue(awaitClosed(successTarget)); + + TestWorkflowRule failingRule = + TestWorkflowRule.newBuilder() + .useDevServer(realServerOptions().build()) + .setWorkflowTypes() + .build(); + String failureTarget = failingRule.getWorkflowServiceStubs().getOptions().getTarget(); + assertThrows( + DeliberateTestFailure.class, + () -> + failingRule + .apply( + new Statement() { + @Override + public void evaluate() { + throw new DeliberateTestFailure(); + } + }, + Description.createTestDescription(getClass(), "failingRule")) + .evaluate()); + assertTrue(awaitClosed(failureTarget)); + } + + private TemporalDevServerOptions.Builder realServerOptions() { + return TemporalDevServerOptions.newBuilder() + .setExistingPath(temporalCli.toString()) + .setStartupTimeout(Duration.ofSeconds(60)) + .setLogFile(tempDirectory.resolve("server-" + System.nanoTime() + ".log").toString()); + } + + private static int availablePort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static boolean awaitClosed(String target) throws InterruptedException { + for (int i = 0; i < 50; i++) { + if (!canConnect(target)) { + return true; + } + TimeUnit.MILLISECONDS.sleep(100); + } + return false; + } + + private static boolean canConnect(String target) { + int separator = target.lastIndexOf(':'); + String host = target.substring(0, separator); + int port = Integer.parseInt(target.substring(separator + 1)); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), 250); + return true; + } catch (IOException e) { + return false; + } + } + + private static final class DeliberateTestFailure extends RuntimeException {} +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java new file mode 100644 index 0000000000..06bed97cf0 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/TemporalDevServerOptionsTest.java @@ -0,0 +1,61 @@ +package io.temporal.testing; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class TemporalDevServerOptionsTest { + @Test + void copiesAndDefensivelyCopiesExtraArguments() { + List args = new ArrayList<>(Arrays.asList("--one", "value")); + TemporalDevServerOptions original = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("arbitrary-fixed-version") + .setExtraArgs(args) + .build(); + args.add("--mutated"); + + TemporalDevServerOptions copy = + TemporalDevServerOptions.newBuilder(original).setUiEnabled(true).build(); + + assertEquals(Arrays.asList("--one", "value"), original.getExtraArgs()); + assertEquals(original.getExtraArgs(), copy.getExtraArgs()); + assertNotSame(original.getExtraArgs(), copy.getExtraArgs()); + assertThrows(UnsupportedOperationException.class, () -> copy.getExtraArgs().add("no")); + assertTrue(copy.isUiEnabled()); + assertFalse(original.isUiEnabled()); + } + + @Test + void validatesValues() { + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setDownloadVersion(" ").build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setPort(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setUiPort(65536).build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setStartupTimeout(Duration.ZERO).build()); + assertThrows( + IllegalArgumentException.class, + () -> + TemporalDevServerOptions.newBuilder() + .setDownloadCacheTtl(Duration.ofSeconds(-1)) + .build()); + assertThrows( + IllegalArgumentException.class, + () -> TemporalDevServerOptions.newBuilder().setExtraArgs("bad\nargument").build()); + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java b/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java new file mode 100644 index 0000000000..de35b16970 --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/internal/devserver/TemporalDevServerDownloaderTest.java @@ -0,0 +1,240 @@ +package io.temporal.testing.internal.devserver; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import io.temporal.testing.TemporalDevServerOptions; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicInteger; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class TemporalDevServerDownloaderTest { + private static final String BASE_URL_PROPERTY = "io.temporal.testing.devServerDownloadBaseUrl"; + + @TempDir Path tempDirectory; + private HttpServer httpServer; + + @AfterEach + void tearDown() { + System.clearProperty(BASE_URL_PROPERTY); + if (httpServer != null) { + httpServer.stop(0); + } + } + + @Test + void extractsRequestedFileFromTarGz() throws Exception { + byte[] contents = "#!/bin/sh\necho fake\n".getBytes(StandardCharsets.UTF_8); + Path archive = tempDirectory.resolve("synthetic.tar.gz"); + Files.write(archive, tarGz("nested/temporal", contents)); + Path extracted = tempDirectory.resolve("extracted"); + + TemporalDevServerDownloader.extractRequestedFile(archive, "nested/temporal", extracted); + + assertEquals(new String(contents, StandardCharsets.UTF_8), readString(extracted)); + } + + @Test + void fixedVersionDownloadsOnceAndConcurrentPreparationSharesCache() throws Exception { + AtomicInteger metadataRequests = new AtomicInteger(); + AtomicInteger archiveRequests = new AtomicInteger(); + byte[] executable = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8); + startDownloadServer("fixed-test", executable, metadataRequests, archiveRequests); + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("fixed-test") + .setDownloadDestination(tempDirectory.resolve("cache").toString()) + .build(); + + ExecutorService executor = Executors.newFixedThreadPool(8); + try { + List> calls = new ArrayList<>(); + for (int i = 0; i < 8; i++) { + calls.add(() -> TemporalDevServerDownloader.prepare(options)); + } + List> futures = executor.invokeAll(calls); + Path expected = futures.get(0).get(); + for (Future future : futures) { + assertEquals(expected, future.get()); + } + assertEquals(1, metadataRequests.get()); + assertEquals(1, archiveRequests.get()); + + assertEquals(expected, TemporalDevServerDownloader.prepare(options)); + assertEquals(1, metadataRequests.get()); + assertEquals(1, archiveRequests.get()); + } finally { + executor.shutdownNow(); + } + } + + @Test + void defaultResolutionIncludesSdkAndFixedResolutionDoesNot() throws Exception { + AtomicInteger defaultRequests = new AtomicInteger(); + AtomicInteger archiveRequests = new AtomicInteger(); + byte[] executable = "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8); + httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext( + "/cli/default", + exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + assertTrue(query.contains("sdk-name=sdk-java")); + assertTrue(query.contains("sdk-version=")); + defaultRequests.incrementAndGet(); + sendJsonMetadata(exchange); + }); + httpServer.createContext( + "/archive", + exchange -> { + archiveRequests.incrementAndGet(); + send(exchange, 200, tarGz("temporal", executable)); + }); + httpServer.start(); + System.setProperty(BASE_URL_PROPERTY, baseUrl()); + + TemporalDevServerDownloader.prepare( + TemporalDevServerOptions.newBuilder() + .setDownloadDestination(tempDirectory.resolve("default-cache").toString()) + .build()); + + assertEquals(1, defaultRequests.get()); + assertEquals(1, archiveRequests.get()); + } + + @Test + void downloadDisabledFailsClearlyWhenExecutableIsAbsent() { + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("not-present") + .setDownloadDestination(tempDirectory.resolve("disabled").toString()) + .setDownloadEnabled(false) + .build(); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, () -> TemporalDevServerDownloader.prepare(options)); + + assertTrue(failure.getMessage().contains("downloading is disabled")); + assertTrue(failure.getMessage().contains("not-present")); + } + + @Test + void expiredCacheEntryIsDownloadedAgain() throws Exception { + AtomicInteger metadataRequests = new AtomicInteger(); + AtomicInteger archiveRequests = new AtomicInteger(); + startDownloadServer( + "ttl-test", + "#!/bin/sh\nexit 0\n".getBytes(StandardCharsets.UTF_8), + metadataRequests, + archiveRequests); + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("ttl-test") + .setDownloadDestination(tempDirectory.resolve("ttl-cache").toString()) + .setDownloadCacheTtl(Duration.ofSeconds(1)) + .build(); + Path executable = TemporalDevServerDownloader.prepare(options); + Files.setLastModifiedTime(executable, FileTime.fromMillis(System.currentTimeMillis() - 5_000)); + + assertEquals(executable, TemporalDevServerDownloader.prepare(options)); + assertEquals(2, metadataRequests.get()); + assertEquals(2, archiveRequests.get()); + } + + @Test + void cachePathContainsVersionAndPlatform() { + TemporalDevServerOptions options = + TemporalDevServerOptions.newBuilder() + .setDownloadVersion("a/version") + .setDownloadDestination(tempDirectory.toString()) + .build(); + TemporalDevServerDownloader.Platform platform = TemporalDevServerDownloader.Platform.current(); + + Path cache = TemporalDevServerDownloader.cacheDirectory(options, platform); + + assertTrue(cache.toString().contains("a_version")); + assertTrue(cache.endsWith(platform.classifier())); + } + + private void startDownloadServer( + String version, + byte[] executable, + AtomicInteger metadataRequests, + AtomicInteger archiveRequests) + throws IOException { + httpServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + httpServer.createContext( + "/cli/" + version, + exchange -> { + String query = exchange.getRequestURI().getRawQuery(); + assertTrue(query.contains("platform=")); + assertTrue(query.contains("arch=")); + assertTrue(!query.contains("sdk-name=")); + metadataRequests.incrementAndGet(); + sendJsonMetadata(exchange); + }); + httpServer.createContext( + "/archive", + exchange -> { + archiveRequests.incrementAndGet(); + send(exchange, 200, tarGz("temporal", executable)); + }); + httpServer.start(); + System.setProperty(BASE_URL_PROPERTY, baseUrl()); + } + + private void sendJsonMetadata(HttpExchange exchange) throws IOException { + String json = "{\"archiveUrl\":\"" + baseUrl() + "/archive\",\"fileToExtract\":\"temporal\"}"; + send(exchange, 200, json.getBytes(StandardCharsets.UTF_8)); + } + + private String baseUrl() { + return "http://127.0.0.1:" + httpServer.getAddress().getPort(); + } + + private static void send(HttpExchange exchange, int status, byte[] body) throws IOException { + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + } + + private static byte[] tarGz(String name, byte[] contents) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (GzipCompressorOutputStream gzip = new GzipCompressorOutputStream(bytes); + TarArchiveOutputStream tar = new TarArchiveOutputStream(gzip)) { + TarArchiveEntry entry = new TarArchiveEntry(name); + entry.setMode(0755); + entry.setSize(contents.length); + tar.putArchiveEntry(entry); + tar.write(contents); + tar.closeArchiveEntry(); + tar.finish(); + } + return bytes.toByteArray(); + } + + private static String readString(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } +} diff --git a/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java b/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java new file mode 100644 index 0000000000..ee672eff9e --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/junit5/TestWorkflowExtensionDevServerIntegrationTest.java @@ -0,0 +1,73 @@ +package io.temporal.testing.junit5; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.temporal.testing.TemporalDevServerOptions; +import io.temporal.testing.TestWorkflowEnvironment; +import io.temporal.testing.TestWorkflowExtension; +import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Integration coverage for extension ownership using sdk-java's pinned real Temporal CLI. */ +@EnabledIfSystemProperty(named = SdkJavaTestServerProfile.ACTIVE_PROPERTY, matches = "true") +class TestWorkflowExtensionDevServerIntegrationTest { + private static String target; + + @RegisterExtension + static final TestWorkflowExtension EXTENSION = + TestWorkflowExtension.newBuilder().useDevServer(realServerOptions()).build(); + + @Test + void extensionUsesOwnedDevServer(TestWorkflowEnvironment environment) { + assertEquals("UnitTest", environment.getNamespace()); + assertTrue(environment.isStarted()); + target = environment.getWorkflowServiceStubs().getOptions().getTarget(); + assertTrue(canConnect(target)); + } + + @AfterAll + static void serverWasClosedByExtension() throws InterruptedException { + assertTrue(awaitClosed(target)); + } + + private static TemporalDevServerOptions realServerOptions() { + if (!SdkJavaTestServerProfile.isActive()) { + return TemporalDevServerOptions.newBuilder().setExistingPath("profile-disabled").build(); + } + return TemporalDevServerOptions.newBuilder() + .setExistingPath(SdkJavaTestServerProfile.prepare().toString()) + .setStartupTimeout(Duration.ofSeconds(60)) + .build(); + } + + private static boolean awaitClosed(String target) throws InterruptedException { + for (int i = 0; i < 50; i++) { + if (!canConnect(target)) { + return true; + } + TimeUnit.MILLISECONDS.sleep(100); + } + return false; + } + + private static boolean canConnect(String target) { + int separator = target.lastIndexOf(':'); + String host = target.substring(0, separator); + int port = Integer.parseInt(target.substring(separator + 1)); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), 250); + return true; + } catch (IOException e) { + return false; + } + } +} From e5d7dbe041fd6457bd657da8ff5bc8d7533072d3 Mon Sep 17 00:00:00 2001 From: Alex Stanfield <13949480+chaptersix@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:41:25 -0500 Subject: [PATCH 055/107] Document server-default schedule catchup window (#2950) --- .../client/schedules/SchedulePolicy.java | 6 +++-- .../client/schedules/ScheduleTest.java | 2 ++ .../client/ScheduleProtoUtilTest.java | 25 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/ScheduleProtoUtilTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/schedules/SchedulePolicy.java b/temporal-sdk/src/main/java/io/temporal/client/schedules/SchedulePolicy.java index ac7bc1acad..e555983ac6 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/schedules/SchedulePolicy.java +++ b/temporal-sdk/src/main/java/io/temporal/client/schedules/SchedulePolicy.java @@ -38,7 +38,8 @@ public Builder setOverlap(ScheduleOverlapPolicy overlap) { /** * Set the amount of time in the past to execute missed actions after a Temporal server is - * unavailable. + * unavailable. If unset, the request omits this value and the Temporal Server applies its + * default (currently one year). */ public Builder setCatchupWindow(Duration catchupWindow) { this.catchupWindow = catchupWindow; @@ -81,7 +82,8 @@ public ScheduleOverlapPolicy getOverlap() { /** * Gets the amount of time in the past to execute missed actions after a Temporal server is - * unavailable. + * unavailable. A {@code null} value is omitted from requests so the Temporal Server applies its + * default (currently one year). * * @return the schedules catchup window */ diff --git a/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleTest.java b/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleTest.java index 4a3e941bc4..f7f5f50daf 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/schedules/ScheduleTest.java @@ -90,6 +90,8 @@ public void createSchedule() { ScheduleHandle handle = client.createSchedule(scheduleId, schedule, options); ScheduleDescription description = handle.describe(); Assert.assertEquals(scheduleId, description.getId()); + Assert.assertEquals( + Duration.ofDays(365), description.getSchedule().getPolicy().getCatchupWindow()); // Verify the schedule description has the correct (i.e. no) memo Assert.assertNull(description.getMemo("memokey1", String.class)); // Try to create a schedule that already exists diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/ScheduleProtoUtilTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/ScheduleProtoUtilTest.java new file mode 100644 index 0000000000..d0304d5425 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/ScheduleProtoUtilTest.java @@ -0,0 +1,25 @@ +package io.temporal.internal.client; + +import io.temporal.client.schedules.SchedulePolicy; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Test; + +public class ScheduleProtoUtilTest { + private final ScheduleProtoUtil util = new ScheduleProtoUtil(null, null); + + @Test + public void policyToProtoOmitsDefaultCatchupWindow() { + Assert.assertFalse(util.policyToProto(SchedulePolicy.newBuilder().build()).hasCatchupWindow()); + } + + @Test + public void policyToProtoIncludesExplicitCatchupWindow() { + Assert.assertEquals( + 300L, + util.policyToProto( + SchedulePolicy.newBuilder().setCatchupWindow(Duration.ofMinutes(5)).build()) + .getCatchupWindow() + .getSeconds()); + } +} From 508dedc5dbf808a9994a62132a1035e5130dde38 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Mon, 10 Aug 2026 13:27:01 -0400 Subject: [PATCH 056/107] feat(extstore): implement core extstore logic that uses payload visitor to batch and store/retrieve payloads. (#2976) * feat(extstore): implement core extstore logic that uses payload visitor to batch and store/retrieve payloads. * remove legacy payload detection. * update comments. * fix extstore reference checking logic. * fix(extstore): rename message/payload converter to transformer. address pr feedback. --- .../ExternalStorageMessageTransformer.java | 75 +++ .../ExternalStoragePayloadTransformer.java | 292 ++++++++++++ .../storage/ExternalStorageReferences.java | 88 ++++ .../StorageDriverRetrieveContextImpl.java | 21 + .../StorageDriverStoreContextImpl.java | 33 ++ .../payload/visitor/PayloadVisitor.java | 2 +- .../visitor/PayloadVisitorOptions.java | 6 +- .../payload/visitor/PayloadVisitors.java | 2 +- .../payload/storage/StorageDriver.java | 6 + .../storage/StorageDriverRetrieveContext.java | 19 +- .../storage/StorageDriverStoreContext.java | 17 +- ...ExternalStorageMessageTransformerTest.java | 161 +++++++ ...ExternalStoragePayloadTransformerTest.java | 441 ++++++++++++++++++ .../ExternalStorageReferencesTest.java | 134 ++++++ .../storage/ExternalStorageOptionsTest.java | 1 + 15 files changed, 1288 insertions(+), 10 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverRetrieveContextImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverStoreContextImpl.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageReferencesTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java new file mode 100644 index 0000000000..7385f99009 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java @@ -0,0 +1,75 @@ +package io.temporal.internal.payload.storage; + +import com.google.protobuf.Message; +import io.temporal.common.CancellationToken; +import io.temporal.internal.payload.visitor.PayloadVisitorOptions; +import io.temporal.internal.payload.visitor.PayloadVisitors; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nullable; + +/** + * Transforms payload lists reachable from a proto message by delegating each visited list to {@link + * ExternalStoragePayloadTransformer}. + * + *

Search attributes stay inline because the server indexes and validates their payload values. + * + *

The {@link Message.Builder} overloads transform in place; the {@link Message} overloads copy + * through a builder and complete with the copy. + */ +final class ExternalStorageMessageTransformer { + private final ExternalStoragePayloadTransformer payloadTransformer; + private final int payloadVisitConcurrency; + + ExternalStorageMessageTransformer( + ExternalStoragePayloadTransformer payloadTransformer, int payloadVisitConcurrency) { + this.payloadTransformer = payloadTransformer; + this.payloadVisitConcurrency = payloadVisitConcurrency; + } + + CompletableFuture store( + T message, + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + return PayloadVisitors.visit(message, storeOptions(target, cancellationToken)); + } + + CompletableFuture store( + Message.Builder builder, + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + return PayloadVisitors.visit(builder, storeOptions(target, cancellationToken)); + } + + CompletableFuture retrieve( + T message, CancellationToken cancellationToken) { + return PayloadVisitors.visit(message, retrieveOptions(cancellationToken)); + } + + CompletableFuture retrieve( + Message.Builder builder, CancellationToken cancellationToken) { + return PayloadVisitors.visit(builder, retrieveOptions(cancellationToken)); + } + + private PayloadVisitorOptions storeOptions( + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + return PayloadVisitorOptions.newBuilder( + (visitedTarget, payloads) -> + payloadTransformer.store(payloads, visitedTarget, cancellationToken)) + .setInitialContext(target) + .setConcurrency(payloadVisitConcurrency) + .setSkipSearchAttributes(true) + .build(); + } + + private PayloadVisitorOptions retrieveOptions( + CancellationToken cancellationToken) { + return PayloadVisitorOptions.newBuilder( + (context, payloads) -> payloadTransformer.retrieve(payloads, cancellationToken)) + .setConcurrency(payloadVisitConcurrency) + .setSkipSearchAttributes(true) + .build(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java new file mode 100644 index 0000000000..6e0d4d770c --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java @@ -0,0 +1,292 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.CancellationToken; +import io.temporal.internal.common.ListUtils; +import io.temporal.internal.concurrent.structured.TaskScope; +import io.temporal.payload.storage.ExternalStorageOptions; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverSelector; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import javax.annotation.Nullable; + +/** + * Transforms one payload list between inline payloads and external-storage references by routing + * entries to storage drivers. + */ +final class ExternalStoragePayloadTransformer { + private final Map driversByName; + private final StorageDriverSelector selector; + private final int payloadSizeThreshold; + + static ExternalStoragePayloadTransformer fromOptions(ExternalStorageOptions options) { + Map driversByName = new LinkedHashMap<>(); + for (StorageDriver driver : options.getDrivers()) { + driversByName.put(driver.getName(), driver); + } + return new ExternalStoragePayloadTransformer( + driversByName, options.getDriverSelector(), options.getPayloadSizeThreshold()); + } + + private ExternalStoragePayloadTransformer( + Map driversByName, + StorageDriverSelector selector, + int payloadSizeThreshold) { + this.driversByName = driversByName; + this.selector = selector; + this.payloadSizeThreshold = payloadSizeThreshold; + } + + CompletableFuture> store( + List payloads, + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + StorageDriverStoreContext context = + new StorageDriverStoreContextImpl(target, cancellationToken); + Map> batches; + try { + batches = buildStoreBatches(payloads, context); + } catch (RuntimeException e) { + return failedFuture(e); + } + if (batches.isEmpty()) { + return CompletableFuture.completedFuture(payloads); + } + return runStoreDrivers(batches, target, cancellationToken) + .thenApply(referencePayloads -> applyPayloadReplacements(payloads, referencePayloads)); + } + + private Map> buildStoreBatches( + List payloads, StorageDriverStoreContext context) { + Map> batches = new LinkedHashMap<>(); + for (int i = 0; i < payloads.size(); i++) { + Payload payload = payloads.get(i); + if (payloadSizeThreshold > 0 && payload.getSerializedSize() < payloadSizeThreshold) { + continue; + } + StorageDriver driver = selector.selectDriver(context, payload); + if (driver == null) { + continue; + } + if (driversByName.get(driver.getName()) != driver) { + throw new IllegalStateException( + "Storage driver selector returned a driver not registered with this external storage: '" + + driver.getName() + + "'"); + } + batches.computeIfAbsent(driver.getName(), name -> new Batch<>(driver)).add(i, payload); + } + return batches; + } + + private CompletableFuture>> runStoreDrivers( + Map> batches, + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + return withDriverScope( + cancellationToken, + scope -> { + StorageDriverStoreContext context = + new StorageDriverStoreContextImpl(target, scope.token()); + for (Batch batch : batches.values()) { + scope + .attach(batch.driver.store(context, batch.values())) + .map(claims -> createReferencePayloads(batch, claims)); + } + return scope.awaitAll(ListUtils::flatten); + }); + } + + /** + * Runs {@code body} in a scope that is also cancelled by {@code cancellationToken}, so a caller + * abandoning the operation trips the token the drivers observe. + */ + private static CompletableFuture>> withDriverScope( + CancellationToken cancellationToken, + Function< + TaskScope>>, + CompletableFuture>>> + body) { + return TaskScope.withScope( + (TaskScope>> scope) -> { + CancellationToken.Registration registration = + cancellationToken.onCancel(scope::cancelAll); + CompletableFuture>> result; + try { + result = body.apply(scope); + } catch (Throwable t) { + scope.cancelAll(); + result = failedFuture(t); + } + // The registration outlives body(), so it is released only once the work settles. + return result.whenComplete((ignored, error) -> registration.close()); + }); + } + + private static List> createReferencePayloads( + Batch batch, List claims) { + if (claims == null || claims.size() != batch.size()) { + throw new IllegalStateException( + String.format( + "Storage driver '%s' returned %d claims for %d payloads", + batch.driver.getName(), claims == null ? 0 : claims.size(), batch.size())); + } + List> replacements = new ArrayList<>(claims.size()); + for (int batchIndex = 0; batchIndex < claims.size(); batchIndex++) { + StorageDriverClaim claim = claims.get(batchIndex); + if (claim == null) { + throw new IllegalStateException( + String.format( + "Storage driver '%s' returned a null claim at index %d", + batch.driver.getName(), batchIndex)); + } + IndexedValue indexedPayload = batch.get(batchIndex); + replacements.add( + new IndexedValue<>( + indexedPayload.originalIndex, + ExternalStorageReferences.toReferencePayload( + batch.driver.getName(), claim, indexedPayload.value.getSerializedSize()))); + } + return replacements; + } + + CompletableFuture> retrieve( + List payloads, CancellationToken cancellationToken) { + Map> batches; + try { + batches = buildRetrieveBatches(payloads); + } catch (RuntimeException e) { + return failedFuture(e); + } + if (batches.isEmpty()) { + return CompletableFuture.completedFuture(payloads); + } + return runRetrieveDrivers(batches, cancellationToken) + .thenApply(retrievedPayloads -> applyPayloadReplacements(payloads, retrievedPayloads)); + } + + private Map> buildRetrieveBatches(List payloads) { + Map> batches = new LinkedHashMap<>(); + for (int i = 0; i < payloads.size(); i++) { + Payload payload = payloads.get(i); + ExternalStorageReferences.ParsedReference reference = + ExternalStorageReferences.tryParseReference(payload); + if (reference == null) { + continue; + } + StorageDriver driver = driversByName.get(reference.driverName); + if (driver == null) { + throw new IllegalStateException( + "No storage driver registered with name '" + reference.driverName + "'"); + } + batches + .computeIfAbsent(reference.driverName, name -> new Batch<>(driver)) + .add(i, reference.claim); + } + return batches; + } + + private CompletableFuture>> runRetrieveDrivers( + Map> batches, + CancellationToken cancellationToken) { + return withDriverScope( + cancellationToken, + scope -> { + StorageDriverRetrieveContext context = + new StorageDriverRetrieveContextImpl(scope.token()); + for (Batch batch : batches.values()) { + scope + .attach(batch.driver.retrieve(context, batch.values())) + .map(payloads -> mapPayloadsToOriginalPositions(batch, payloads)); + } + return scope.awaitAll(ListUtils::flatten); + }); + } + + private static List> mapPayloadsToOriginalPositions( + Batch batch, List payloads) { + if (payloads == null || payloads.size() != batch.size()) { + throw new IllegalStateException( + String.format( + "Storage driver '%s' returned %d payloads for %d claims", + batch.driver.getName(), payloads == null ? 0 : payloads.size(), batch.size())); + } + List> replacements = new ArrayList<>(payloads.size()); + for (int batchIndex = 0; batchIndex < payloads.size(); batchIndex++) { + Payload payload = payloads.get(batchIndex); + if (payload == null) { + throw new IllegalStateException( + String.format( + "Storage driver '%s' returned a null payload at index %d", + batch.driver.getName(), batchIndex)); + } + replacements.add(new IndexedValue<>(batch.get(batchIndex).originalIndex, payload)); + } + return replacements; + } + + private static CompletableFuture failedFuture(Throwable t) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(t); + return future; + } + + private static List applyPayloadReplacements( + List payloads, List> replacements) { + Payload[] updatedPayloads = payloads.toArray(new Payload[0]); + for (IndexedValue replacement : replacements) { + updatedPayloads[replacement.originalIndex] = replacement.value; + } + return Arrays.asList(updatedPayloads); + } + + private static final class IndexedValue { + final int originalIndex; + final T value; + + IndexedValue(int originalIndex, T value) { + this.originalIndex = originalIndex; + this.value = value; + } + } + + private static final class Batch { + final StorageDriver driver; + private final List> indexedValues = new ArrayList<>(); + + Batch(StorageDriver driver) { + this.driver = driver; + } + + void add(int originalIndex, T value) { + indexedValues.add(new IndexedValue<>(originalIndex, value)); + } + + int size() { + return indexedValues.size(); + } + + IndexedValue get(int batchIndex) { + return indexedValues.get(batchIndex); + } + + List values() { + List values = new ArrayList<>(indexedValues.size()); + for (IndexedValue indexedValue : indexedValues) { + values.add(indexedValue.value); + } + return values; + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java new file mode 100644 index 0000000000..3a68c6bb66 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java @@ -0,0 +1,88 @@ +package io.temporal.internal.payload.storage; + +import com.google.protobuf.ByteString; +import com.google.protobuf.util.JsonFormat; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.sdk.v1.ExternalStorageReference; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.payload.storage.StorageDriverClaim; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +final class ExternalStorageReferences { + private static final String ENCODING_PROTOBUF_JSON = "json/protobuf"; + private static final String REFERENCE_MESSAGE_TYPE = + ExternalStorageReference.getDescriptor().getFullName(); + + private static final JsonFormat.Printer PRINTER = JsonFormat.printer(); + private static final JsonFormat.Parser PARSER = JsonFormat.parser().ignoringUnknownFields(); + + static final class ParsedReference { + final String driverName; + final StorageDriverClaim claim; + + ParsedReference(String driverName, StorageDriverClaim claim) { + this.driverName = driverName; + this.claim = claim; + } + } + + static Payload toReferencePayload( + @Nonnull String driverName, + @Nonnull StorageDriverClaim claim, + long originalPayloadSizeBytes) { + ExternalStorageReference reference = + ExternalStorageReference.newBuilder() + .setDriverName(driverName) + .putAllClaimData(claim.getClaimData()) + .build(); + String json; + try { + json = PRINTER.print(reference); + } catch (Exception e) { + throw new IllegalStateException("Failed to serialize external storage reference", e); + } + return Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8(ENCODING_PROTOBUF_JSON)) + .putMetadata( + EncodingKeys.METADATA_MESSAGE_TYPE_KEY, ByteString.copyFromUtf8(REFERENCE_MESSAGE_TYPE)) + .setData(ByteString.copyFromUtf8(json)) + .addExternalPayloads( + Payload.ExternalPayloadDetails.newBuilder() + .setSizeBytes(originalPayloadSizeBytes) + .build()) + .build(); + } + + /** + * Returns the reference encoded in {@code payload}, or null if the payload is not an external + * storage reference this SDK understands. + * + *

Only the encoding and message type identify a reference. {@code external_payloads} records + * the original size for the server's benefit and is not part of the exchange contract, so a + * producer that omits it still yields a readable reference. + */ + static @Nullable ParsedReference tryParseReference(@Nonnull Payload payload) { + if (!hasMetadata(payload, EncodingKeys.METADATA_ENCODING_KEY, ENCODING_PROTOBUF_JSON) + || !hasMetadata(payload, EncodingKeys.METADATA_MESSAGE_TYPE_KEY, REFERENCE_MESSAGE_TYPE)) { + return null; + } + ExternalStorageReference.Builder builder = ExternalStorageReference.newBuilder(); + try { + PARSER.merge(payload.getData().toStringUtf8(), builder); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to parse external storage reference", e); + } + ExternalStorageReference reference = builder.build(); + return new ParsedReference( + reference.getDriverName(), new StorageDriverClaim(reference.getClaimDataMap())); + } + + private static boolean hasMetadata(Payload payload, String key, String expected) { + ByteString value = payload.getMetadataMap().get(key); + return value != null && expected.equals(value.toStringUtf8()); + } + + private ExternalStorageReferences() {} +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverRetrieveContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverRetrieveContextImpl.java new file mode 100644 index 0000000000..84374b2cd0 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverRetrieveContextImpl.java @@ -0,0 +1,21 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.common.CancellationToken; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import javax.annotation.Nonnull; + +final class StorageDriverRetrieveContextImpl implements StorageDriverRetrieveContext { + private final CancellationToken cancellationToken; + + StorageDriverRetrieveContextImpl(CancellationToken cancellationToken) { + this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken"); + } + + @Nonnull + @Override + public CancellationToken getCancellationToken() { + return cancellationToken; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverStoreContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverStoreContextImpl.java new file mode 100644 index 0000000000..c28ea0f634 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverStoreContextImpl.java @@ -0,0 +1,33 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.common.CancellationToken; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +final class StorageDriverStoreContextImpl implements StorageDriverStoreContext { + private final @Nullable StorageDriverTargetInfo target; + private final CancellationToken cancellationToken; + + StorageDriverStoreContextImpl( + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + this.target = target; + this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken"); + } + + @Nullable + @Override + public StorageDriverTargetInfo getTarget() { + return target; + } + + @Nonnull + @Override + public CancellationToken getCancellationToken() { + return cancellationToken; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java index 8872a44cde..70a2c1cc44 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitor.java @@ -17,6 +17,6 @@ * @param type of the contextual value supplied to each visit */ @FunctionalInterface -interface PayloadVisitor { +public interface PayloadVisitor { CompletableFuture> visit(C context, List payloads); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java index c834c2ec8e..4eac39be46 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java @@ -9,7 +9,7 @@ * * @param type of the contextual value supplied to the visitor */ -final class PayloadVisitorOptions { +public final class PayloadVisitorOptions { private final @Nonnull PayloadVisitor payloadVisitor; private final @Nullable MessageVisitor messageVisitor; private final @Nullable C initialContext; @@ -36,7 +36,7 @@ public PayloadVisitor getPayloadVisitor() { } @Nullable - public MessageVisitor getMessageVisitor() { + MessageVisitor getMessageVisitor() { return messageVisitor; } @@ -69,7 +69,7 @@ private Builder(@Nonnull PayloadVisitor payloadVisitor) { this.payloadVisitor = Objects.requireNonNull(payloadVisitor, "payloadVisitor"); } - public Builder setMessageVisitor(@Nullable MessageVisitor messageVisitor) { + Builder setMessageVisitor(@Nullable MessageVisitor messageVisitor) { this.messageVisitor = messageVisitor; return this; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java index 69e9924123..c6f6179fac 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitors.java @@ -5,7 +5,7 @@ import javax.annotation.Nonnull; /** Visits every payload within a proto message. */ -final class PayloadVisitors { +public final class PayloadVisitors { private PayloadVisitors() {} /** Visits the payloads in {@code builder} in place. */ diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java index 3239250759..01d851fbe6 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java @@ -29,6 +29,9 @@ public interface StorageDriver { /** * Stores {@code payloads} and returns one {@link StorageDriverClaim} per payload, in the same * order. The returned list must be the same length as {@code payloads}. + * + *

Observe {@link StorageDriverStoreContext#getCancellationToken()} to learn when the SDK has + * abandoned this operation, and abandon in-flight requests accordingly. */ @Nonnull CompletableFuture> store( @@ -37,6 +40,9 @@ CompletableFuture> store( /** * Retrieves the payloads identified by {@code claims} and returns one {@link Payload} per claim, * in the same order. The returned list must be the same length as {@code claims}. + * + *

Observe {@link StorageDriverRetrieveContext#getCancellationToken()} to learn when the SDK + * has abandoned this operation, and abandon in-flight requests accordingly. */ @Nonnull CompletableFuture> retrieve( diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java index 77f11c750d..ae2b1ffbe6 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverRetrieveContext.java @@ -1,12 +1,25 @@ package io.temporal.payload.storage; +import io.temporal.common.CancellationToken; import io.temporal.common.Experimental; +import java.util.concurrent.CancellationException; +import javax.annotation.Nonnull; /** * Context passed to {@link StorageDriver#retrieve}. * - *

Implemented by the SDK and passed to the driver. Driver authors do not implement this in - * production code, only when constructing instances for their own tests. + *

The SDK supplies the instance a driver receives. Members added here in later releases will + * carry a default, so an existing driver-side implementation keeps compiling and behaves as though + * the new member were absent. */ @Experimental -public interface StorageDriverRetrieveContext {} +public interface StorageDriverRetrieveContext { + /** + * Token cancelled when the SDK abandons this retrieve operation. Defaults to a token that is + * never cancelled. + */ + @Nonnull + default CancellationToken getCancellationToken() { + return CancellationToken.none(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java index 723655bf12..f001adc576 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverStoreContext.java @@ -1,13 +1,17 @@ package io.temporal.payload.storage; +import io.temporal.common.CancellationToken; import io.temporal.common.Experimental; +import java.util.concurrent.CancellationException; +import javax.annotation.Nonnull; import javax.annotation.Nullable; /** * Context passed to {@link StorageDriver#store} and {@link StorageDriverSelector}. * - *

Implemented by the SDK and passed to the driver. Driver authors do not implement this in - * production code, only when constructing instances for their own tests. + *

The SDK supplies the instance a driver receives. Members added here in later releases will + * carry a default, so an existing driver-side implementation keeps compiling and behaves as though + * the new member were absent. */ @Experimental public interface StorageDriverStoreContext { @@ -17,4 +21,13 @@ public interface StorageDriverStoreContext { */ @Nullable StorageDriverTargetInfo getTarget(); + + /** + * Token cancelled when the SDK abandons this store operation. Defaults to a token that is never + * cancelled. + */ + @Nonnull + default CancellationToken getCancellationToken() { + return CancellationToken.none(); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java new file mode 100644 index 0000000000..f17bcff47a --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java @@ -0,0 +1,161 @@ +package io.temporal.internal.payload.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.common.v1.SearchAttributes; +import io.temporal.common.CancellationToken; +import io.temporal.payload.storage.ExternalStorageOptions; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +/** Tests external storage message conversion. */ +public class ExternalStorageMessageTransformerTest { + + @Test + public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageMessageTransformer transformer = transformer(driver, 0); + Payloads message = + Payloads.newBuilder().addPayloads(payload("a")).addPayloads(payload("b")).build(); + + Payloads stored = transformer.store(message, null, CancellationToken.none()).get(); + + assertNotNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(0))); + assertNotNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(1))); + + Payloads retrieved = transformer.retrieve(stored, CancellationToken.none()).get(); + assertEquals(message, retrieved); + } + + @Test + public void walksNestedPayloads() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageMessageTransformer transformer = transformer(driver, 0); + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setInput(Payloads.newBuilder().addPayloads(payload("deep")))) + .build(); + + Command stored = transformer.store(command, null, CancellationToken.none()).get(); + + Payload nested = stored.getScheduleActivityTaskCommandAttributes().getInput().getPayloads(0); + assertNotNull(ExternalStorageReferences.tryParseReference(nested)); + assertEquals(command, transformer.retrieve(stored, CancellationToken.none()).get()); + } + + @Test + public void payloadBelowThresholdLeavesMessageUnchanged() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageMessageTransformer transformer = transformer(driver, 1024); + Payloads message = Payloads.newBuilder().addPayloads(payload("small")).build(); + + Payloads stored = transformer.store(message, null, CancellationToken.none()).get(); + + assertNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(0))); + assertEquals(message, stored); + assertTrue(driver.storeBatchSizes.isEmpty()); + } + + @Test + public void searchAttributesAreNotOffloaded() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageMessageTransformer transformer = transformer(driver, 0); + Command command = + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setInput(Payloads.newBuilder().addPayloads(payload("input"))) + .setSearchAttributes( + SearchAttributes.newBuilder() + .putIndexedFields("k", payload("indexed-value")))) + .build(); + + Command stored = transformer.store(command, null, CancellationToken.none()).get(); + + StartChildWorkflowExecutionCommandAttributes attrs = + stored.getStartChildWorkflowExecutionCommandAttributes(); + assertNotNull(ExternalStorageReferences.tryParseReference(attrs.getInput().getPayloads(0))); + Payload indexed = attrs.getSearchAttributes().getIndexedFieldsOrThrow("k"); + assertNull(ExternalStorageReferences.tryParseReference(indexed)); + assertEquals(payload("indexed-value"), indexed); + } + + private static ExternalStorageMessageTransformer transformer( + StorageDriver driver, int threshold) { + ExternalStoragePayloadTransformer payloadTransformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(threshold) + .build()); + return new ExternalStorageMessageTransformer(payloadTransformer, 4); + } + + private static Payload payload(String data) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(data)).build(); + } + + private static final class InMemoryDriver implements StorageDriver { + private final String name; + private final Map objects = new HashMap<>(); + final List storeBatchSizes = new ArrayList<>(); + private int counter = 0; + + InMemoryDriver(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.inmemory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + storeBatchSizes.add(payloads.size()); + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = name + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java new file mode 100644 index 0000000000..f1632ca81e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java @@ -0,0 +1,441 @@ +package io.temporal.internal.payload.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.CancellationToken; +import io.temporal.internal.concurrent.structured.CancelSource; +import io.temporal.payload.storage.ExternalStorageOptions; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverSelector; +import io.temporal.payload.storage.StorageDriverStoreContext; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; + +/** Tests external storage payload-list conversion. */ +public class ExternalStoragePayloadTransformerTest { + + @Test + public void storesAndRetrievesRoundTrip() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStoragePayloadTransformer transformer = transformer(driver, 0); + List input = Arrays.asList(payload("a"), payload("b")); + + List stored = transformer.store(input, null, CancellationToken.none()).get(); + + assertEquals(2, stored.size()); + assertNotNull(ExternalStorageReferences.tryParseReference(stored.get(0))); + assertNotNull(ExternalStorageReferences.tryParseReference(stored.get(1))); + assertEquals(Collections.singletonList(2), driver.storeBatchSizes); + assertEquals( + input.get(0).getSerializedSize(), stored.get(0).getExternalPayloads(0).getSizeBytes()); + + List retrieved = transformer.retrieve(stored, CancellationToken.none()).get(); + assertEquals(input, retrieved); + assertEquals(Collections.singletonList(2), driver.retrieveBatchSizes); + } + + @Test + public void payloadBelowThresholdStaysInline() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStoragePayloadTransformer transformer = transformer(driver, 100); + Payload small = payload("x"); + Payload large = payload(repeat("y", 200)); + + List stored = + transformer.store(Arrays.asList(small, large), null, CancellationToken.none()).get(); + + assertNull(ExternalStorageReferences.tryParseReference(stored.get(0))); + assertEquals(small, stored.get(0)); + assertNotNull(ExternalStorageReferences.tryParseReference(stored.get(1))); + assertEquals(Collections.singletonList(1), driver.storeBatchSizes); + } + + @Test + public void selectorReturningNullKeepsInline() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStoragePayloadTransformer transformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDriver(driver) + .setDriverSelector((context, payload) -> null) + .setPayloadSizeThreshold(0) + .build()); + + List stored = + transformer + .store(Collections.singletonList(payload("a")), null, CancellationToken.none()) + .get(); + + assertEquals(payload("a"), stored.get(0)); + assertTrue(driver.storeBatchSizes.isEmpty()); + } + + @Test + public void multipleDriversBatchPerDriverAndPreserveOrder() throws Exception { + InMemoryDriver d1 = new InMemoryDriver("d1"); + InMemoryDriver d2 = new InMemoryDriver("d2"); + Map byPrefix = new HashMap<>(); + byPrefix.put("1", d1); + byPrefix.put("2", d2); + StorageDriverSelector selector = + (context, payload) -> byPrefix.get(payload.getData().toStringUtf8().substring(0, 1)); + ExternalStoragePayloadTransformer transformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDrivers(Arrays.asList(d1, d2)) + .setDriverSelector(selector) + .setPayloadSizeThreshold(0) + .build()); + List input = Arrays.asList(payload("1-a"), payload("2-b"), payload("1-c")); + + List stored = transformer.store(input, null, CancellationToken.none()).get(); + + assertEquals(Collections.singletonList(2), d1.storeBatchSizes); + assertEquals(Collections.singletonList(1), d2.storeBatchSizes); + assertEquals(input, transformer.retrieve(stored, CancellationToken.none()).get()); + } + + @Test + public void arityMismatchFails() { + StorageDriver driver = + new FakeDriver("d1") { + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + return CompletableFuture.completedFuture(Collections.emptyList()); + } + }; + ExternalStoragePayloadTransformer transformer = transformer(driver, 0); + + Throwable cause = + causeOf( + transformer.store( + Collections.singletonList(payload("a")), null, CancellationToken.none())); + assertTrue(cause instanceof IllegalStateException); + assertTrue(cause.getMessage().contains("returned 0 claims for 1 payloads")); + } + + @Test + public void nullClaimFromDriverFails() { + StorageDriver driver = + new FakeDriver("d1") { + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + return CompletableFuture.completedFuture(Collections.singletonList(null)); + } + }; + ExternalStoragePayloadTransformer transformer = transformer(driver, 0); + + Throwable cause = + causeOf( + transformer.store( + Collections.singletonList(payload("a")), null, CancellationToken.none())); + assertTrue(cause instanceof IllegalStateException); + assertTrue(cause.getMessage().contains("returned a null claim at index 0")); + } + + @Test + public void nullPayloadFromDriverFails() { + StorageDriver driver = + new FakeDriver("d1") { + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + return CompletableFuture.completedFuture(Collections.singletonList(null)); + } + }; + ExternalStoragePayloadTransformer transformer = transformer(driver, 0); + Payload reference = + ExternalStorageReferences.toReferencePayload( + "d1", new StorageDriverClaim(Collections.singletonMap("key", "k")), 1L); + + Throwable cause = + causeOf( + transformer.retrieve(Collections.singletonList(reference), CancellationToken.none())); + assertTrue(cause instanceof IllegalStateException); + assertTrue(cause.getMessage().contains("returned a null payload at index 0")); + } + + @Test + public void unknownDriverOnRetrieveFails() { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStoragePayloadTransformer transformer = transformer(driver, 0); + Payload reference = + ExternalStorageReferences.toReferencePayload( + "ghost", new StorageDriverClaim(Collections.singletonMap("key", "k")), 1L); + + Throwable cause = + causeOf( + transformer.retrieve(Collections.singletonList(reference), CancellationToken.none())); + assertTrue(cause instanceof IllegalStateException); + assertTrue(cause.getMessage().contains("No storage driver registered with name 'ghost'")); + } + + @Test + public void selectorReturningUnregisteredDriverFails() { + InMemoryDriver registered = new InMemoryDriver("d1"); + InMemoryDriver stranger = new InMemoryDriver("d2"); + ExternalStoragePayloadTransformer transformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDriver(registered) + .setDriverSelector((context, payload) -> stranger) + .setPayloadSizeThreshold(0) + .build()); + + Throwable cause = + causeOf( + transformer.store( + Collections.singletonList(payload("a")), null, CancellationToken.none())); + assertTrue(cause instanceof IllegalStateException); + assertTrue(cause.getMessage().contains("not registered")); + } + + @Test + public void firstErrorRequestsCancellationOfOutstandingDriverCalls() { + CompletableFuture> inFlight = new CompletableFuture<>(); + CompletableFuture> failing = new CompletableFuture<>(); + AtomicBoolean cancellationRequested = new AtomicBoolean(false); + StorageDriver slow = + new FakeDriver("d1") { + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + context.getCancellationToken().onCancel(() -> cancellationRequested.set(true)); + return inFlight; + } + }; + StorageDriver doomed = controlledStore("d2", failing); + Map byPrefix = new HashMap<>(); + byPrefix.put("1", slow); + byPrefix.put("2", doomed); + ExternalStoragePayloadTransformer transformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDrivers(Arrays.asList(slow, doomed)) + .setDriverSelector( + (context, payload) -> + byPrefix.get(payload.getData().toStringUtf8().substring(0, 1))) + .setPayloadSizeThreshold(0) + .build()); + + CompletableFuture> result = + transformer.store( + Arrays.asList(payload("1-a"), payload("2-b")), null, CancellationToken.none()); + assertFalse(result.isDone()); + + failing.completeExceptionally(new RuntimeException("boom")); + + assertTrue(result.isCompletedExceptionally()); + assertTrue(cancellationRequested.get()); + assertTrue(inFlight.isCancelled()); + } + + @Test + public void callerCancellationRequestsCancellationOfInFlightStore() { + CompletableFuture> inFlight = new CompletableFuture<>(); + AtomicBoolean cancellationRequested = new AtomicBoolean(false); + StorageDriver slow = + new FakeDriver("d1") { + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + context.getCancellationToken().onCancel(() -> cancellationRequested.set(true)); + return inFlight; + } + }; + CancelSource caller = new CancelSource<>(CancellationException::new); + + CompletableFuture> result = + transformer(slow, 0).store(Collections.singletonList(payload("a")), null, caller.token()); + assertFalse(result.isDone()); + + caller.cancel(); + + assertTrue(cancellationRequested.get()); + assertTrue(inFlight.isCancelled()); + assertTrue(result.isCompletedExceptionally()); + } + + @Test + public void callerCancellationRequestsCancellationOfInFlightRetrieve() { + CompletableFuture> inFlight = new CompletableFuture<>(); + AtomicBoolean cancellationRequested = new AtomicBoolean(false); + StorageDriver slow = + new FakeDriver("d1") { + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + context.getCancellationToken().onCancel(() -> cancellationRequested.set(true)); + return inFlight; + } + }; + CancelSource caller = new CancelSource<>(CancellationException::new); + Payload reference = + ExternalStorageReferences.toReferencePayload( + "d1", new StorageDriverClaim(Collections.singletonMap("key", "k")), 1L); + + CompletableFuture> result = + transformer(slow, 0).retrieve(Collections.singletonList(reference), caller.token()); + assertFalse(result.isDone()); + + caller.cancel(); + + assertTrue(cancellationRequested.get()); + assertTrue(inFlight.isCancelled()); + assertTrue(result.isCompletedExceptionally()); + } + + @Test + public void selectorObservesCallerCancellationToken() { + InMemoryDriver driver = new InMemoryDriver("d1"); + CancelSource caller = new CancelSource<>(CancellationException::new); + AtomicReference> observed = new AtomicReference<>(); + ExternalStoragePayloadTransformer transformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDriver(driver) + .setDriverSelector( + (context, payload) -> { + observed.set(context.getCancellationToken()); + return driver; + }) + .setPayloadSizeThreshold(0) + .build()); + + transformer.store(Collections.singletonList(payload("a")), null, caller.token()); + + assertSame(caller.token(), observed.get()); + } + + private static ExternalStoragePayloadTransformer transformer( + StorageDriver driver, int threshold) { + return ExternalStoragePayloadTransformer.fromOptions( + ExternalStorageOptions.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(threshold) + .build()); + } + + private static Payload payload(String data) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(data)).build(); + } + + private static String repeat(String s, int n) { + StringBuilder sb = new StringBuilder(s.length() * n); + for (int i = 0; i < n; i++) { + sb.append(s); + } + return sb.toString(); + } + + private static Throwable causeOf(CompletableFuture future) { + try { + future.get(); + fail("expected failure"); + return null; + } catch (ExecutionException e) { + return e.getCause(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } + + private static StorageDriver controlledStore( + String name, CompletableFuture> future) { + return new FakeDriver(name) { + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + return future; + } + }; + } + + private static class FakeDriver implements StorageDriver { + private final String name; + + FakeDriver(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.fake"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + } + + private static class InMemoryDriver extends FakeDriver { + final Map objects = new HashMap<>(); + final List storeBatchSizes = new ArrayList<>(); + final List retrieveBatchSizes = new ArrayList<>(); + private int counter = 0; + + InMemoryDriver(String name) { + super(name); + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + storeBatchSizes.add(payloads.size()); + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = getName() + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + retrieveBatchSizes.add(claims.size()); + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageReferencesTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageReferencesTest.java new file mode 100644 index 0000000000..3f7b6b948a --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageReferencesTest.java @@ -0,0 +1,134 @@ +package io.temporal.internal.payload.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.sdk.v1.ExternalStorageReference; +import io.temporal.common.converter.EncodingKeys; +import io.temporal.payload.storage.StorageDriverClaim; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Test; + +/** Tests external storage reference encoding and decoding. */ +public class ExternalStorageReferencesTest { + + @Test + public void currentFormatRoundTrips() { + Map claimData = new HashMap<>(); + claimData.put("bucket", "my-bucket"); + claimData.put("key", "abc123"); + StorageDriverClaim claim = new StorageDriverClaim(claimData); + + Payload reference = ExternalStorageReferences.toReferencePayload("driver-1", claim, 4096L); + + assertEquals(1, reference.getExternalPayloadsCount()); + assertEquals(4096L, reference.getExternalPayloads(0).getSizeBytes()); + assertEquals( + "json/protobuf", + reference.getMetadataMap().get(EncodingKeys.METADATA_ENCODING_KEY).toStringUtf8()); + + ExternalStorageReferences.ParsedReference parsed = + ExternalStorageReferences.tryParseReference(reference); + assertNotNull(parsed); + assertEquals("driver-1", parsed.driverName); + assertEquals(claim, parsed.claim); + } + + @Test + public void inlinePayloadIsNotAReference() { + Payload inline = + Payload.newBuilder() + .putMetadata(EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8("json/plain")) + .setData(ByteString.copyFromUtf8("\"hello\"")) + .build(); + assertNull(ExternalStorageReferences.tryParseReference(inline)); + } + + /** + * {@code external_payloads} records the original size for the server and is not part of the + * exchange contract, so a producer that omits it still writes a readable reference. + */ + @Test + public void referenceWithoutExternalPayloadsIsStillAReference() { + Payload reference = + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8("json/protobuf")) + .putMetadata( + EncodingKeys.METADATA_MESSAGE_TYPE_KEY, + ByteString.copyFromUtf8(ExternalStorageReference.getDescriptor().getFullName())) + .setData(ByteString.copyFromUtf8("{\"driverName\":\"driver-1\"}")) + .build(); + + ExternalStorageReferences.ParsedReference parsed = + ExternalStorageReferences.tryParseReference(reference); + assertNotNull(parsed); + assertEquals("driver-1", parsed.driverName); + } + + @Test + public void payloadWithReferenceMessageTypeButForeignEncodingIsNotAReference() { + Payload foreign = + Payload.newBuilder() + .putMetadata(EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8("json/plain")) + .putMetadata( + EncodingKeys.METADATA_MESSAGE_TYPE_KEY, + ByteString.copyFromUtf8(ExternalStorageReference.getDescriptor().getFullName())) + .setData(ByteString.copyFromUtf8("{\"driverName\":\"driver-1\"}")) + .addExternalPayloads( + Payload.ExternalPayloadDetails.newBuilder().setSizeBytes(4096L).build()) + .build(); + + assertNull(ExternalStorageReferences.tryParseReference(foreign)); + } + + @Test + public void payloadWithExternalPayloadsButForeignMessageTypeIsNotAReference() { + Payload foreign = + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8("json/protobuf")) + .putMetadata( + EncodingKeys.METADATA_MESSAGE_TYPE_KEY, + ByteString.copyFromUtf8("some.other.sdk.v1.ExternalStorageReference")) + .setData(ByteString.copyFromUtf8("{\"foo\":1}")) + .addExternalPayloads( + Payload.ExternalPayloadDetails.newBuilder().setSizeBytes(4096L).build()) + .build(); + + assertNull(ExternalStorageReferences.tryParseReference(foreign)); + } + + /** + * References written by other SDKs must stay readable, so parsing tolerates snake_case field + * names and fields added to the proto after this release. + */ + @Test + public void parsesReferenceWrittenByAnotherSdk() { + Payload reference = + Payload.newBuilder() + .putMetadata( + EncodingKeys.METADATA_ENCODING_KEY, ByteString.copyFromUtf8("json/protobuf")) + .putMetadata( + EncodingKeys.METADATA_MESSAGE_TYPE_KEY, + ByteString.copyFromUtf8(ExternalStorageReference.getDescriptor().getFullName())) + .setData( + ByteString.copyFromUtf8( + "{\"driver_name\":\"driver-1\",\"claim_data\":{\"key\":\"abc123\"}," + + "\"field_added_later\":\"ignored\"}")) + .addExternalPayloads( + Payload.ExternalPayloadDetails.newBuilder().setSizeBytes(4096L).build()) + .build(); + + ExternalStorageReferences.ParsedReference parsed = + ExternalStorageReferences.tryParseReference(reference); + assertNotNull(parsed); + assertEquals("driver-1", parsed.driverName); + assertEquals(new StorageDriverClaim(Collections.singletonMap("key", "abc123")), parsed.claim); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java index bc2ed1bc5b..2c7ffc782f 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java @@ -11,6 +11,7 @@ import java.util.concurrent.CompletableFuture; import org.junit.Test; +/** Tests external storage option validation and defaults. */ public class ExternalStorageOptionsTest { private static StorageDriverStoreContext storeContext(StorageDriverTargetInfo target) { From 82281c612206aa9b544ea2c4bbb33c302d6abe50 Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Mon, 10 Aug 2026 17:59:07 -0500 Subject: [PATCH 057/107] Release v1.38.0 (#2997) --- releases/v1.38.0 | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 releases/v1.38.0 diff --git a/releases/v1.38.0 b/releases/v1.38.0 new file mode 100644 index 0000000000..11056ba244 --- /dev/null +++ b/releases/v1.38.0 @@ -0,0 +1,18 @@ +2026-07-21 - 6d09a341 - Auto-enroll pollers into autoscaling on PollerAutoscalingAutoEnroll (Java SDK) (#2953) +2026-07-22 - 26ca1a4a - docs: update contributing guide (#2948) +2026-07-23 - 4804646f - Fix boolean returned by isUsingVirtualThreadsOnWorkflowWorker (#2957) +2026-07-23 - b0f197e7 - VLN-1609: fix checkout-below-v7 (#2933) +2026-07-24 - 4c0e493f - Bump jacoco and simplify code coverage CI (#2963) +2026-07-24 - e8da68ba - Integrate Temporal API 1.63.4 (#2969) +2026-07-24 - f68c9bc7 - Make eager activity reservation limit configurable (#2970) +2026-07-24 - fd8b29a2 - Update PotentialDeadlockException to account for scheduling delay (#2964) +2026-07-27 - 5d6feeb3 - Fix: Propagate WorkflowOptions.priority through signalWithStart (#2966) +2026-07-27 - d664c37c - Update ScheduleRange validation for negative end (#2971) +2026-07-28 - 2f6dcac6 - Add structured concurrency wrapper for CompletableFuture (#2939) +2026-07-31 - 69a5d3fa - Do not send versioning info on worker command channel polls (#2987) +2026-07-31 - b1b2d586 - NEXUS-485: Support Workflow Update as a Nexus Operation (#2945) +2026-08-02 - 1dabe5d7 - Add Standalone Activities to Temporal Nexus Operation Handler (#2918) +2026-08-04 - 8fd8cc33 - Add dev server downloader & runner to testing package (#2982) +2026-08-04 - 92800ca0 - Remove experimental markers from user metadata fields (#2958) +2026-08-10 - 508dedc5 - feat(extstore): implement core extstore logic that uses payload visitor to batch and store/retrieve payloads. (#2976) +2026-08-10 - e5d7dbe0 - Document server-default schedule catchup window (#2950) From cd2b543e22adf9153e98df2bf106661b829fcf09 Mon Sep 17 00:00:00 2001 From: Gregory Michael Travis Date: Wed, 12 Aug 2026 17:16:57 -0400 Subject: [PATCH 058/107] Upgrade temporal-api to v1.63.5 (#3003) --- temporal-serviceclient/src/main/proto | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index f53963d448..3ebdff42a9 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit f53963d4489c8a73aa30bd7091fe758f9896c08c +Subproject commit 3ebdff42a9f07ac484b415fe8ff0b483b4ce3340 From f973250244bda236a7abe906c08817f5c3ede814 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 12 Aug 2026 16:31:12 -0700 Subject: [PATCH 059/107] Add @TemporalOperation annotation for Nexus operations (#2928) Add Temporal Operation --- build.gradle | 2 +- .../workflow/KotlinTemporalOperationTest.kt | 83 +++++ .../internal/nexus/NexusTaskHandlerImpl.java | 2 +- .../nexus/TemporalOperationProcessor.java | 179 +++++++++ .../temporal/nexus/TemporalNexusClient.java | 40 +- .../io/temporal/nexus/TemporalOperation.java | 54 +++ .../nexus/TemporalOperationHandler.java | 46 ++- .../nexus/TemporalOperationProcessorTest.java | 343 ++++++++++++++++++ .../nexus/GenericHandlerCancelTest.java | 28 +- .../nexus/GenericHandlerDoubleStartTest.java | 47 ++- .../nexus/GenericHandlerSyncResultTest.java | 14 +- .../nexus/GenericHandlerTypedProcTest.java | 142 ++++---- .../GenericHandlerTypedStartWorkflowTest.java | 142 ++++---- ...enericHandlerUntypedStartWorkflowTest.java | 33 +- .../TemporalOperationAnnotationTest.java | 175 +++++++++ .../template/WorkersTemplate.java | 6 +- .../testing/internal/TestServiceUtils.java | 5 +- 17 files changed, 1089 insertions(+), 252 deletions(-) create mode 100644 temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java create mode 100644 temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperation.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java diff --git a/build.gradle b/build.gradle index e9bbe424c0..feb580323b 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ ext { grpcVersion = '1.76.0' // [1.38.0,) Needed for io.grpc.protobuf.services.HealthStatusManager jacksonVersion = '2.15.4' // [2.9.0,) jackson3Version = '3.0.4' - nexusVersion = '0.5.0-alpha' + nexusVersion = '0.6.0-alpha' // we don't upgrade to 1.10.x because it requires kotlin 1.6. Users may use 1.10.x in their environments though. micrometerVersion = project.hasProperty("edgeDepsTest") ? '1.13.6' : '1.9.9' // [1.0.0,) diff --git a/temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt new file mode 100644 index 0000000000..a009138538 --- /dev/null +++ b/temporal-kotlin/src/test/kotlin/io/temporal/workflow/KotlinTemporalOperationTest.kt @@ -0,0 +1,83 @@ + +package io.temporal.workflow + +import io.nexusrpc.Operation +import io.nexusrpc.Service +import io.nexusrpc.handler.ServiceImpl +import io.temporal.client.WorkflowClientOptions +import io.temporal.client.WorkflowOptions +import io.temporal.common.converter.DefaultDataConverter +import io.temporal.common.converter.JacksonJsonPayloadConverter +import io.temporal.common.converter.KotlinObjectMapperFactory +import io.temporal.nexus.TemporalNexusClient +import io.temporal.nexus.TemporalOperation +import io.temporal.nexus.TemporalOperationResult +import io.temporal.nexus.TemporalOperationStartContext +import io.temporal.testing.internal.SDKTestWorkflowRule +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import java.time.Duration + +class KotlinTemporalOperationTest { + + @Rule + @JvmField + var testWorkflowRule: SDKTestWorkflowRule = SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(CallerWorkflowImpl::class.java) + .setNexusServiceImplementation(KotlinSugarServiceImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setDataConverter(DefaultDataConverter(JacksonJsonPayloadConverter(KotlinObjectMapperFactory.new()))) + .build() + ) + .build() + + @Service + interface KotlinSugarService { + @Operation + fun greet(input: String): String + } + + @ServiceImpl(service = KotlinSugarService::class) + class KotlinSugarServiceImpl { + @TemporalOperation + fun greet( + ctx: TemporalOperationStartContext, + client: TemporalNexusClient, + input: String + ): TemporalOperationResult { + return TemporalOperationResult.sync("kotlin-$input") + } + } + + @WorkflowInterface + interface CallerWorkflow { + @WorkflowMethod + fun execute(arg: String): String + } + + class CallerWorkflowImpl : CallerWorkflow { + override fun execute(arg: String): String { + val stub = Workflow.newNexusServiceStub( + KotlinSugarService::class.java, + NexusServiceOptions { + setOperationOptions( + NexusOperationOptions { + setScheduleToCloseTimeout(Duration.ofSeconds(10)) + } + ) + } + ) + return stub.greet(arg) + } + } + + @Test + fun temporalOperationSugar_endToEnd() { + val client = testWorkflowRule.workflowClient + val options = WorkflowOptions.newBuilder().setTaskQueue(testWorkflowRule.taskQueue).build() + val workflowStub = client.newWorkflowStub(CallerWorkflow::class.java, options) + assertEquals("kotlin-hi", workflowStub.execute("hi")) + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index 92af2f6a84..4d40183c27 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -394,7 +394,7 @@ private void registerNexusService(Object nexusService) { if (nexusService instanceof Class) { throw new IllegalArgumentException("Nexus service object instance expected, not the class"); } - ServiceImplInstance instance = ServiceImplInstance.fromInstance(nexusService); + ServiceImplInstance instance = TemporalOperationProcessor.process(nexusService); InternalUtils.checkMethodName(instance); if (serviceImplInstances.put(instance.getDefinition().getName(), instance) != null) { throw new TypeAlreadyRegisteredException( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java new file mode 100644 index 0000000000..4edfa72083 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/TemporalOperationProcessor.java @@ -0,0 +1,179 @@ +package io.temporal.internal.nexus; + +import com.google.common.collect.ImmutableList; +import com.google.common.primitives.Primitives; +import io.nexusrpc.OperationDefinition; +import io.nexusrpc.OperationException; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.MethodExtension; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImplInstance; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.stream.Collectors; + +/** + * Entry point for registering a Nexus service instance whose class may contain {@link + * TemporalOperation}-annotated methods. Delegates to {@link + * ServiceImplInstance#fromInstance(Object, java.util.List)} with a single {@link MethodExtension} + * that recognizes {@link TemporalOperation} alongside the built-in {@link OperationImpl}. + */ +public final class TemporalOperationProcessor { + + private static final ImmutableList EXTENSIONS = + ImmutableList.of(new TemporalOperationExtension()); + + private TemporalOperationProcessor() {} + + public static ServiceImplInstance process(Object instance) { + return ServiceImplInstance.fromInstance(instance, EXTENSIONS); + } + + /** Recognizes {@link TemporalOperation}-annotated methods during nexusrpc service scanning. */ + private static final class TemporalOperationExtension implements MethodExtension { + @Override + public OperationHandler extract( + Object instance, Method method, OperationDefinition operationDefinition) { + if (method.getDeclaredAnnotation(TemporalOperation.class) == null) { + return null; + } + + validateSignature(method); + + validateTypes(method, operationDefinition); + + MethodHandle handle; + try { + handle = MethodHandles.lookup().unreflect(method).bindTo(instance); + } catch (IllegalAccessException e) { + throw new RuntimeException( + "Failed to obtain method handle for @TemporalOperation method " + method.getName(), e); + } + + TemporalOperationHandler.StartHandler startHandler = + (ctx, client, input) -> invokeStartHandler(handle, ctx, client, input); + + return new TemporalOperationHandler(startHandler) {}; + } + } + + private static void validateSignature(Method method) { + if (!Modifier.isPublic(method.getModifiers())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + method.getName() + " must be public"); + } + if (Modifier.isStatic(method.getModifiers())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + method.getName() + " must not be static"); + } + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length != 3 + || !TemporalOperationStartContext.class.equals(paramTypes[0]) + || !TemporalNexusClient.class.equals(paramTypes[1])) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " must accept (TemporalOperationStartContext, TemporalNexusClient, I); got " + + describeSignature(method)); + } + if (!TemporalOperationResult.class.equals(method.getReturnType())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " must return TemporalOperationResult; got " + + method.getGenericReturnType().getTypeName() + + ". Use @OperationImpl for custom handler shapes."); + } + for (Class declared : method.getExceptionTypes()) { + if (!RuntimeException.class.isAssignableFrom(declared) + && !Error.class.isAssignableFrom(declared) + && !OperationException.class.isAssignableFrom(declared) + && !HandlerException.class.isAssignableFrom(declared)) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " may only declare OperationException or HandlerException as checked" + + " exceptions; found " + + declared.getName()); + } + } + } + + private static void validateTypes(Method method, OperationDefinition operationDefinition) { + Type expectedInputType = operationDefinition.getInputType(); + Type declaredInputType = method.getGenericParameterTypes()[2]; + if (!typesMatch(declaredInputType, expectedInputType)) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " input type mismatch: expected " + + expectedInputType.getTypeName() + + " but got " + + declaredInputType.getTypeName()); + } + Type returnType = method.getGenericReturnType(); + if (!(returnType instanceof ParameterizedType)) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " must use parameterized TemporalOperationResult, not the raw type."); + } + Type resultTypeArg = ((ParameterizedType) returnType).getActualTypeArguments()[0]; + if (!typesMatch(resultTypeArg, operationDefinition.getOutputType())) { + throw new IllegalArgumentException( + "@TemporalOperation method " + + method.getName() + + " output type mismatch: expected " + + operationDefinition.getOutputType().getTypeName() + + " but got " + + resultTypeArg.getTypeName()); + } + } + + // Package-private for testing. + @SuppressWarnings("unchecked") + static TemporalOperationResult invokeStartHandler( + MethodHandle handle, + TemporalOperationStartContext ctx, + TemporalNexusClient client, + Object input) + throws OperationException { + try { + return (TemporalOperationResult) handle.invoke(ctx, client, input); + } catch (RuntimeException | Error | OperationException e) { + // HandlerException is a RuntimeException and falls into the RuntimeException arm. + throw e; + } catch (Throwable t) { + // Unreachable: validateSignature rejects @TemporalOperation methods that declare + // any other checked exception, so MethodHandle.invoke cannot surface one here. + throw new AssertionError("@TemporalOperation method threw unexpected checked exception", t); + } + } + + private static boolean typesMatch(Type declared, Type expected) { + if (declared.equals(expected)) { + return true; + } + if (declared instanceof Class && expected instanceof Class) { + return Primitives.wrap((Class) declared).equals(Primitives.wrap((Class) expected)); + } + return false; + } + + private static String describeSignature(Method method) { + return Arrays.stream(method.getParameterTypes()) + .map(Class::getSimpleName) + .collect(Collectors.joining(", ", "(", ")")); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java index 0d9d1f302a..0f5616985d 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java @@ -14,21 +14,22 @@ * Nexus-aware client wrapping {@link WorkflowClient}. Provides methods for interacting with * Temporal from within a Nexus operation handler. * - *

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

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

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

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

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

The declaring {@link ServiceImpl}-annotated class must be {@code public} so the annotated + * method can be accessed at registration time. The method must: + * + *

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

Workflow-run example: + * + *

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

For custom cancel, or any other handler composition, use {@link OperationImpl} with a {@link + * TemporalOperationHandler} subclass that overrides {@link + * TemporalOperationHandler#cancelWorkflowRun}. Both annotations can coexist on the same {@link + * ServiceImpl} class, but never on the same method. + */ +@Experimental +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface TemporalOperation {} diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java index 9f14a1346a..9cb88229a4 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalOperationHandler.java @@ -16,28 +16,36 @@ * provides a composable way to map Temporal operations (start workflow, start activity, etc.) to * Nexus operations. * - *

Usage example: + *

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

Custom-cancel example: * *

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

This class supports subclassing to customize cancel behavior. Override {@link - * #cancelWorkflowRun} to change how workflow-run (token type {@code t:1}) cancellations are - * handled, or {@link #cancelActivityExecution} to change how activity-execution (token type {@code - * t:2}) cancellations are handled. The {@link #start} and {@link #cancel} methods should not be - * overridden — they contain the core dispatch logic. - * * @param the input type * @param the result type */ @@ -54,7 +62,7 @@ public class TemporalOperationHandler implements OperationHandler { public interface StartHandler { TemporalOperationResult apply( TemporalOperationStartContext context, TemporalNexusClient client, T input) - throws OperationException; + throws OperationException, HandlerException; } private final StartHandler startHandler; @@ -65,7 +73,8 @@ protected TemporalOperationHandler(StartHandler startHandler) { /** * Creates a {@link TemporalOperationHandler} from a start handler. Subclass and override {@link - * #cancelWorkflowRun} or {@link #cancelActivityExecution} to customize cancel behavior. + * #cancelWorkflowRun}, {@link #cancelUpdateWorkflow}, or {@link #cancelActivityExecution} to + * customize cancel behavior. * * @param startHandler the handler to invoke on start operation requests * @return an operation handler backed by the given start handler @@ -76,7 +85,8 @@ public static TemporalOperationHandler create(StartHandler st @Override public final OperationStartResult start( - OperationContext ctx, OperationStartDetails details, T input) throws OperationException { + OperationContext ctx, OperationStartDetails details, T input) + throws OperationException, HandlerException { InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); TemporalNexusClient client = new TemporalNexusClientImpl(nexusCtx.getWorkflowClient(), ctx, details); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java new file mode 100644 index 0000000000..8900000b92 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/TemporalOperationProcessorTest.java @@ -0,0 +1,343 @@ +package io.temporal.internal.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; +import org.junit.Assert; +import org.junit.Test; + +public class TemporalOperationProcessorTest { + + @Service + public interface SingleOpService { + @Operation + String op(String input); + } + + @Service + public interface CompositeGenericService { + @Operation + java.util.List compose(java.util.Map input); + } + + @Service + public interface VoidIoService { + @Operation + Void op(); + } + + @Test + public void happyPath_registersTemporalOperation() { + TemporalOperationProcessor.process(new ValidSugar()); + // No exception → registration succeeded. End-to-end behavior is covered in + // TemporalOperationAnnotationTest. + } + + @Test + public void happyPath_compositeGenerics() { + // Validation must traverse parameterized types (List, Map). + TemporalOperationProcessor.process(new CompositeOk()); + } + + @Test + public void happyPath_voidInputAndOutput() { + // A no-input @Operation (Void op()) must register: declared Void param matches Void input, + // and a Void result type matches the operation's Void output. + TemporalOperationProcessor.process(new VoidIo()); + } + + @Test + public void rejects_compositeGenericInputMismatch() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new CompositeBadInput())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("input type mismatch")); + } + + @Test + public void rejects_compositeGenericOutputMismatch() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new CompositeBadOutput())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("output type mismatch")); + } + + @Test + public void rejects_rawReturnType() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new RawReturnType())); + Assert.assertTrue( + e.getMessage(), e.getMessage().contains("must use parameterized TemporalOperationResult")); + } + + @Test + public void rejects_nonPublicMethod() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new NonPublicMethod())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("must be public")); + } + + @Test + public void rejects_staticMethod() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new StaticMethod())); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("must not be static")); + } + + @Test + public void invokeStartHandler_readsInstanceField() throws Exception { + // The method handle must be bound to the @ServiceImpl instance so the user's method can + // read constructor-injected dependencies (workflow client, config, etc.) off `this`. + StatefulHandler instance = new StatefulHandler("injected"); + java.lang.reflect.Method m = + StatefulHandler.class.getMethod( + "op", TemporalOperationStartContext.class, TemporalNexusClient.class, String.class); + java.lang.invoke.MethodHandle handle = + java.lang.invoke.MethodHandles.lookup().unreflect(m).bindTo(instance); + TemporalOperationResult result = + TemporalOperationProcessor.invokeStartHandler(handle, null, null, "input"); + Assert.assertEquals("injected:input", result.getSyncResult()); + } + + @Test + public void invokeStartHandler_propagatesRuntimeExceptionUnwrapped() throws Exception { + // A RuntimeException thrown from a user @TemporalOperation method must arrive at the + // caller without an InvocationTargetException or RuntimeException wrapper inserted by the + // dispatch path. + ThrowingHandler instance = new ThrowingHandler(); + java.lang.reflect.Method m = + ThrowingHandler.class.getMethod( + "op", TemporalOperationStartContext.class, TemporalNexusClient.class, String.class); + java.lang.invoke.MethodHandle handle = + java.lang.invoke.MethodHandles.lookup().unreflect(m).bindTo(instance); + IllegalStateException thrown = + Assert.assertThrows( + IllegalStateException.class, + () -> TemporalOperationProcessor.invokeStartHandler(handle, null, null, "in")); + Assert.assertEquals("user-thrown", thrown.getMessage()); + // First user frame is at the top — no reflective wrapper class in between. + Assert.assertEquals(ThrowingHandler.class.getName(), thrown.getStackTrace()[0].getClassName()); + } + + @Test + public void rejects_badReturnType() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new BadReturnType())); + Assert.assertTrue( + e.getMessage(), e.getMessage().contains("must return TemporalOperationResult")); + } + + @Test + public void rejects_badArity() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new BadArity())); + Assert.assertTrue( + e.getMessage(), + e.getMessage() + .contains("must accept (TemporalOperationStartContext, TemporalNexusClient, I)")); + } + + @Test + public void rejects_checkedExceptionInThrows() { + IllegalArgumentException e = + assertInvalid(() -> TemporalOperationProcessor.process(new DeclaresCheckedException())); + Assert.assertTrue( + e.getMessage(), + e.getMessage() + .contains("may only declare OperationException or HandlerException as checked")); + Assert.assertTrue(e.getMessage(), e.getMessage().contains(java.io.IOException.class.getName())); + } + + @Test + public void allows_runtimeExceptionInThrows() { + // Unchecked exceptions in the throws clause are fine — they don't constrain callers. + TemporalOperationProcessor.process(new DeclaresRuntimeException()); + } + + @Test + public void allows_nexusrpcCheckedExceptionsInThrows() { + // OperationException and HandlerException are the nexusrpc-sanctioned checked exceptions on + // OperationHandler.start, so we let them through. + TemporalOperationProcessor.process(new DeclaresNexusrpcCheckedExceptions()); + } + + @Test + public void operationImpl_takesPrecedenceOverTemporalOperation() { + Assert.assertEquals( + 1, TemporalOperationProcessor.process(new DualAnnotated()).getOperationHandlers().size()); + } + + private static IllegalArgumentException assertInvalid(Runnable action) { + RuntimeException wrapper = Assert.assertThrows(RuntimeException.class, action::run); + Assert.assertTrue(wrapper.getCause() instanceof IllegalArgumentException); + return (IllegalArgumentException) wrapper.getCause(); + } + + // ----- Fixtures ----- + + @ServiceImpl(service = SingleOpService.class) + public static class ValidSugar { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class BadReturnType { + @TemporalOperation + public String op(TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return input; + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class BadArity { + @TemporalOperation + public TemporalOperationResult op(TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = CompositeGenericService.class) + public static class CompositeOk { + @TemporalOperation + public TemporalOperationResult> compose( + TemporalOperationStartContext ctx, + TemporalNexusClient client, + java.util.Map input) { + return TemporalOperationResult.sync(java.util.Collections.emptyList()); + } + } + + @ServiceImpl(service = CompositeGenericService.class) + public static class CompositeBadInput { + // Map value type is String instead of Integer. + @TemporalOperation + public TemporalOperationResult> compose( + TemporalOperationStartContext ctx, + TemporalNexusClient client, + java.util.Map input) { + return TemporalOperationResult.sync(java.util.Collections.emptyList()); + } + } + + @ServiceImpl(service = CompositeGenericService.class) + public static class CompositeBadOutput { + // Result list element type is Integer instead of String. + @TemporalOperation + public TemporalOperationResult> compose( + TemporalOperationStartContext ctx, + TemporalNexusClient client, + java.util.Map input) { + return TemporalOperationResult.sync(java.util.Collections.emptyList()); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @ServiceImpl(service = SingleOpService.class) + public static class RawReturnType { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class NonPublicMethod { + @TemporalOperation + TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class StaticMethod { + @TemporalOperation + public static TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class StatefulHandler { + private final String prefix; + + public StatefulHandler(String prefix) { + this.prefix = prefix; + } + + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync(prefix + ":" + input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class ThrowingHandler { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + throw new IllegalStateException("user-thrown"); + } + } + + @ServiceImpl(service = VoidIoService.class) + public static class VoidIo { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, Void input) { + return TemporalOperationResult.sync(null); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class DeclaresCheckedException { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) + throws java.io.IOException { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class DeclaresRuntimeException { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) + throws IllegalStateException { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class DeclaresNexusrpcCheckedExceptions { + @TemporalOperation + public TemporalOperationResult op( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) + throws io.nexusrpc.OperationException, io.nexusrpc.handler.HandlerException { + return TemporalOperationResult.sync(input); + } + } + + @ServiceImpl(service = SingleOpService.class) + public static class DualAnnotated { + @TemporalOperation + @OperationImpl + public OperationHandler op() { + return new TemporalOperationHandler( + (ctx, client, input) -> TemporalOperationResult.sync(input)) {}; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java index 4773e6c521..1882995c7a 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerCancelTest.java @@ -2,8 +2,6 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.api.enums.v1.EventType; import io.temporal.client.WorkflowFailedException; @@ -11,7 +9,10 @@ import io.temporal.client.WorkflowStub; import io.temporal.failure.CanceledFailure; import io.temporal.internal.Signal; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import java.time.Duration; @@ -121,17 +122,16 @@ public interface TestNexusCancelService { @ServiceImpl(service = TestNexusCancelService.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> - client.startWorkflow( - WaitForCancelWorkflowInterface.class, - WaitForCancelWorkflowInterface::execute, - input, - WorkflowOptions.newBuilder() - .setWorkflowId("generic-cancel-test-" + context.getService()) - .build())); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + return client.startWorkflow( + WaitForCancelWorkflowInterface.class, + WaitForCancelWorkflowInterface::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("generic-cancel-test-" + context.getService()) + .build()); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java index 9d0c4e6c9f..1ce58248a4 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerDoubleStartTest.java @@ -3,14 +3,15 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; import io.nexusrpc.handler.HandlerException; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowFailedException; import io.temporal.client.WorkflowOptions; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.NexusOperationFailure; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestWorkflows; @@ -94,27 +95,25 @@ public interface TestNexusServiceDoubleStart { @ServiceImpl(service = TestNexusServiceDoubleStart.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> { - // First start should succeed but the workflow blocks indefinitely - client.startWorkflow( - BlockingWorkflow.class, - BlockingWorkflow::execute, - input, - WorkflowOptions.newBuilder() - .setWorkflowId("double-start-first-" + context.getService()) - .build()); - // Second start should throw - return client.startWorkflow( - BlockingWorkflow.class, - BlockingWorkflow::execute, - input, - WorkflowOptions.newBuilder() - .setWorkflowId("double-start-second-" + context.getService()) - .build()); - }); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + // First start should succeed but the workflow blocks indefinitely + client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("double-start-first-" + context.getService()) + .build()); + // Second start should throw + return client.startWorkflow( + BlockingWorkflow.class, + BlockingWorkflow::execute, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("double-start-second-" + context.getService()) + .build()); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java index 01eda16b01..65f871752e 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerSyncResultTest.java @@ -2,11 +2,11 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestWorkflows; @@ -55,10 +55,10 @@ public interface TestNexusSyncService { @ServiceImpl(service = TestNexusSyncService.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> TemporalOperationResult.sync("sync-" + input)); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync("sync-" + input); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java index ae3d01be32..bcbeb41457 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedProcTest.java @@ -2,11 +2,12 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowOptions; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; @@ -60,75 +61,72 @@ public interface TestNexusServiceProc { @ServiceImpl(service = TestNexusServiceProc.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> { - String prefix = "generic-handler-test-proc" + input + "-"; - String workflowId = prefix + context.getService() + "-" + context.getOperation(); - WorkflowOptions options = - WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); - switch (input) { - case 0: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc.class, - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc::proc, - options); - case 1: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc::proc1, - "input", - options); - case 2: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc::proc2, - "input", - 2, - options); - case 3: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc::proc3, - "input", - 2, - 3, - options); - case 4: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc::proc4, - "input", - 2, - 3, - 4, - options); - case 5: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc::proc5, - "input", - 2, - 3, - 4, - 5, - options); - case 6: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc.class, - TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc::proc6, - "input", - 2, - 3, - 4, - 5, - 6, - options); - default: - throw new IllegalArgumentException("unexpected input: " + input); - } - }); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, Integer input) { + String prefix = "generic-handler-test-proc" + input + "-"; + String workflowId = prefix + context.getService() + "-" + context.getOperation(); + WorkflowOptions options = WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); + switch (input) { + case 0: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc.class, + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowProc::proc, + options); + case 1: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test1ArgWorkflowProc::proc1, + "input", + options); + case 2: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test2ArgWorkflowProc::proc2, + "input", + 2, + options); + case 3: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test3ArgWorkflowProc::proc3, + "input", + 2, + 3, + options); + case 4: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test4ArgWorkflowProc::proc4, + "input", + 2, + 3, + 4, + options); + case 5: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test5ArgWorkflowProc::proc5, + "input", + 2, + 3, + 4, + 5, + options); + case 6: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc.class, + TestMultiArgWorkflowFunctions.Test6ArgWorkflowProc::proc6, + "input", + 2, + 3, + 4, + 5, + 6, + options); + default: + throw new IllegalArgumentException("unexpected input: " + input); + } } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java index 49662cc6af..3ff5f36b43 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerTypedStartWorkflowTest.java @@ -2,11 +2,12 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowOptions; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; @@ -61,75 +62,72 @@ public interface TestNexusServiceGeneric { @ServiceImpl(service = TestNexusServiceGeneric.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> { - String prefix = "generic-handler-test-func" + input + "-"; - String workflowId = prefix + context.getService() + "-" + context.getOperation(); - WorkflowOptions options = - WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); - switch (input) { - case 0: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc.class, - TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc::func, - options); - case 1: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc::func1, - "input", - options); - case 2: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc::func2, - "input", - 2, - options); - case 3: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc::func3, - "input", - 2, - 3, - options); - case 4: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc::func4, - "input", - 2, - 3, - 4, - options); - case 5: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc::func5, - "input", - 2, - 3, - 4, - 5, - options); - case 6: - return client.startWorkflow( - TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc.class, - TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc::func6, - "input", - 2, - 3, - 4, - 5, - 6, - options); - default: - throw new IllegalArgumentException("unexpected input: " + input); - } - }); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, Integer input) { + String prefix = "generic-handler-test-func" + input + "-"; + String workflowId = prefix + context.getService() + "-" + context.getOperation(); + WorkflowOptions options = WorkflowOptions.newBuilder().setWorkflowId(workflowId).build(); + switch (input) { + case 0: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc.class, + TestMultiArgWorkflowFunctions.TestNoArgsWorkflowFunc::func, + options); + case 1: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test1ArgWorkflowFunc::func1, + "input", + options); + case 2: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test2ArgWorkflowFunc::func2, + "input", + 2, + options); + case 3: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test3ArgWorkflowFunc::func3, + "input", + 2, + 3, + options); + case 4: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test4ArgWorkflowFunc::func4, + "input", + 2, + 3, + 4, + options); + case 5: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test5ArgWorkflowFunc::func5, + "input", + 2, + 3, + 4, + 5, + options); + case 6: + return client.startWorkflow( + TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc.class, + TestMultiArgWorkflowFunctions.Test6ArgWorkflowFunc::func6, + "input", + 2, + 3, + 4, + 5, + 6, + options); + default: + throw new IllegalArgumentException("unexpected input: " + input); + } } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java index 1f682d4534..17ebc2561e 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/GenericHandlerUntypedStartWorkflowTest.java @@ -2,11 +2,12 @@ import io.nexusrpc.Operation; import io.nexusrpc.Service; -import io.nexusrpc.handler.OperationHandler; -import io.nexusrpc.handler.OperationImpl; import io.nexusrpc.handler.ServiceImpl; import io.temporal.client.WorkflowOptions; -import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.*; import io.temporal.workflow.shared.TestMultiArgWorkflowFunctions; @@ -57,21 +58,17 @@ public interface TestNexusServiceUntyped { @ServiceImpl(service = TestNexusServiceUntyped.class) public class TestNexusServiceImpl { - @OperationImpl - public OperationHandler operation() { - return TemporalOperationHandler.create( - (context, client, input) -> - client.startWorkflow( - "func1", - String.class, - WorkflowOptions.newBuilder() - .setWorkflowId( - "generic-handler-untyped-" - + context.getService() - + "-" - + context.getOperation()) - .build(), - input)); + @TemporalOperation + public TemporalOperationResult operation( + TemporalOperationStartContext context, TemporalNexusClient client, String input) { + return client.startWorkflow( + "func1", + String.class, + WorkflowOptions.newBuilder() + .setWorkflowId( + "generic-handler-untyped-" + context.getService() + "-" + context.getOperation()) + .build(), + input); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java new file mode 100644 index 0000000000..21642a90ee --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/TemporalOperationAnnotationTest.java @@ -0,0 +1,175 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowOptions; +import io.temporal.nexus.TemporalNexusClient; +import io.temporal.nexus.TemporalOperation; +import io.temporal.nexus.TemporalOperationHandler; +import io.temporal.nexus.TemporalOperationResult; +import io.temporal.nexus.TemporalOperationStartContext; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +/** End-to-end coverage of the {@link TemporalOperation} sugar. */ +public class TemporalOperationAnnotationTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(SugarCaller.class, MixedCaller.class, SugarTargetWorkflowImpl.class) + .setNexusServiceImplementation( + new SugarServiceImpl(), new SyncSugarServiceImpl(), new MixedServiceImpl()) + .build(); + + @Test + public void workflowRunSugar_endToEnd() { + TestWorkflows.TestWorkflow1 stub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + Assert.assertEquals("workflow:wf-input", stub.execute("wf-input")); + } + + @Test + public void syncSugar_endToEnd() { + SyncCallerWorkflow stub = + testWorkflowRule.newWorkflowStubTimeoutOptions(SyncCallerWorkflow.class); + Assert.assertEquals("sync:hi", stub.execute("hi")); + } + + @Test + public void mixedClass_bothOperationsReachable() { + MixedCallerWorkflow stub = + testWorkflowRule.newWorkflowStubTimeoutOptions(MixedCallerWorkflow.class); + Assert.assertEquals("workflow:mixed-input|legacy:legacy-input", stub.execute("mixed-input")); + } + + // ----- Caller workflows ----- + + @WorkflowInterface + public interface SyncCallerWorkflow { + @WorkflowMethod + String execute(String arg); + } + + @WorkflowInterface + public interface MixedCallerWorkflow { + @WorkflowMethod + String execute(String arg); + } + + @WorkflowInterface + public interface SugarTargetWorkflow { + @WorkflowMethod + String run(String arg); + } + + public static class SugarTargetWorkflowImpl implements SugarTargetWorkflow { + @Override + public String run(String arg) { + return "workflow:" + arg; + } + } + + /** Drives the workflow-run sugar test via {@link TestWorkflows.TestWorkflow1}. */ + public static class SugarCaller implements TestWorkflows.TestWorkflow1, SyncCallerWorkflow { + @Override + public String execute(String input) { + NexusServiceOptions options = defaultServiceOptions(); + // SyncCallerWorkflow re-uses the same impl method via the SyncCallerWorkflow interface; + // distinguish by sentinel input so each test exercises one path. + if ("hi".equals(input)) { + return Workflow.newNexusServiceStub(SyncSugarService.class, options).greet(input); + } + return Workflow.newNexusServiceStub(SugarService.class, options).start(input); + } + } + + public static class MixedCaller implements MixedCallerWorkflow { + @Override + public String execute(String input) { + MixedService stub = Workflow.newNexusServiceStub(MixedService.class, defaultServiceOptions()); + return stub.workflowOp(input) + "|" + stub.legacyOp("legacy-input"); + } + } + + private static NexusServiceOptions defaultServiceOptions() { + return NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()) + .build(); + } + + // ----- Nexus services ----- + + @Service + public interface SugarService { + @Operation + String start(String input); + } + + @Service + public interface SyncSugarService { + @Operation + String greet(String input); + } + + @Service + public interface MixedService { + @Operation + String workflowOp(String input); + + @Operation + String legacyOp(String input); + } + + @ServiceImpl(service = SugarService.class) + public static class SugarServiceImpl { + @TemporalOperation + public TemporalOperationResult start( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return client.startWorkflow( + SugarTargetWorkflow.class, + SugarTargetWorkflow::run, + input, + WorkflowOptions.newBuilder().setWorkflowId("sugar-" + ctx.getRequestId()).build()); + } + } + + @ServiceImpl(service = SyncSugarService.class) + public static class SyncSugarServiceImpl { + @TemporalOperation + public TemporalOperationResult greet( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return TemporalOperationResult.sync("sync:" + input); + } + } + + @ServiceImpl(service = MixedService.class) + public static class MixedServiceImpl { + @TemporalOperation + public TemporalOperationResult workflowOp( + TemporalOperationStartContext ctx, TemporalNexusClient client, String input) { + return client.startWorkflow( + SugarTargetWorkflow.class, + SugarTargetWorkflow::run, + input, + WorkflowOptions.newBuilder().setWorkflowId("mixed-" + ctx.getRequestId()).build()); + } + + @OperationImpl + public OperationHandler legacyOp() { + return new TemporalOperationHandler( + (ctx, client, input) -> TemporalOperationResult.sync("legacy:" + input)) {}; + } + } +} diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java index 1ab6bc7a2b..6fb972cf0c 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java @@ -4,7 +4,6 @@ import com.google.common.base.Preconditions; import io.nexusrpc.ServiceDefinition; -import io.nexusrpc.handler.ServiceImplInstance; import io.opentracing.Tracer; import io.temporal.client.WorkflowClient; import io.temporal.common.Experimental; @@ -14,6 +13,7 @@ import io.temporal.common.metadata.POJOWorkflowImplMetadata; import io.temporal.common.metadata.POJOWorkflowMethodMetadata; import io.temporal.internal.common.env.ReflectionUtils; +import io.temporal.internal.nexus.TemporalOperationProcessor; import io.temporal.internal.sync.POJOWorkflowImplementationFactory; import io.temporal.spring.boot.ActivityImpl; import io.temporal.spring.boot.NexusServiceImpl; @@ -453,7 +453,7 @@ private void createWorkerFromAnExplicitConfig( AopUtils.getTargetClass(bean), taskQueue); worker.registerNexusServiceImplementation(bean); - ServiceDefinition definition = ServiceImplInstance.fromInstance(bean).getDefinition(); + ServiceDefinition definition = TemporalOperationProcessor.process(bean).getDefinition(); addRegisteredNexusServiceImpl(worker, beanName, bean.getClass().getName(), definition); }); } @@ -531,7 +531,7 @@ private void configureNexusServiceImplementationAutoDiscovery( worker, beanName, bean.getClass().getName(), - ServiceImplInstance.fromInstance(bean).getDefinition()); + TemporalOperationProcessor.process(bean).getDefinition()); if (log.isInfoEnabled()) { log.info( "Registering auto-discovered nexus service bean '{}' of class {} on a worker {} with a task queue '{}'", diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java b/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java index 3ce288b9c8..40c0cbeb0b 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/TestServiceUtils.java @@ -3,7 +3,6 @@ import static io.temporal.internal.common.InternalUtils.createNormalTaskQueue; import com.google.protobuf.ByteString; -import io.nexusrpc.handler.ServiceImplInstance; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.common.v1.WorkflowType; import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; @@ -15,6 +14,7 @@ import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.nexus.TemporalOperationProcessor; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.WorkflowImplementationOptions; import io.temporal.workflow.NexusServiceOptions; @@ -33,7 +33,8 @@ public static WorkflowImplementationOptions applyNexusServiceOptions( String endpoint) { Map newNexusServiceOptions = new HashMap<>(); for (Object nexusService : nexusServiceImplementations) { - String serviceName = ServiceImplInstance.fromInstance(nexusService).getDefinition().getName(); + String serviceName = + TemporalOperationProcessor.process(nexusService).getDefinition().getName(); NexusServiceOptions serviceOptionWithEndpoint = options.getNexusServiceOptions().get(serviceName); if (serviceOptionWithEndpoint == null) { From 87a3c568617289a9fad5d6fadc49f84fd59c6b57 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Thu, 13 Aug 2026 08:17:36 -0700 Subject: [PATCH 060/107] Fix exception ser. in update validator (#3001) --- .../temporal/internal/sync/SyncWorkflow.java | 3 + ...dateValidatorFailureSerializationTest.java | 121 ++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/updateTest/UpdateValidatorFailureSerializationTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflow.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflow.java index a9f1f1107d..9351f0f34e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflow.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflow.java @@ -162,6 +162,9 @@ public void handleUpdate( } catch (ReadOnlyException r) { // Rethrow instead on rejecting the update to fail the WFT throw r; + } catch (WorkflowExecutionException e) { + callbacks.reject(e.getFailure()); + return; } catch (Exception e) { callbacks.reject( workflowContext diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/updateTest/UpdateValidatorFailureSerializationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/updateTest/UpdateValidatorFailureSerializationTest.java new file mode 100644 index 0000000000..ba1c3d6689 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/updateTest/UpdateValidatorFailureSerializationTest.java @@ -0,0 +1,121 @@ +package io.temporal.workflow.updateTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowUpdateException; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.internal.SDKTestOptions; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.CompletablePromise; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.UpdateMethod; +import io.temporal.workflow.UpdateValidatorMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.UUID; +import org.junit.Rule; +import org.junit.Test; + +public class UpdateValidatorFailureSerializationTest { + + private static final String FAILURE_MESSAGE = "validator rejected"; + private static final String FAILURE_TYPE = "TestValidatorFailure"; + private static final String FAILURE_DETAIL = "failure detail"; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder().setWorkflowTypes(TestWorkflowImpl.class).build(); + + @Test + public void validatorPreservesApplicationFailure() { + TestWorkflow workflow = startWorkflow(); + + WorkflowUpdateException exception = + assertThrows(WorkflowUpdateException.class, () -> workflow.update(true)); + + assertApplicationFailure(exception); + workflow.complete(); + } + + private TestWorkflow startWorkflow() { + WorkflowOptions options = + SDKTestOptions.newWorkflowOptionsWithTimeouts(testWorkflowRule.getTaskQueue()).toBuilder() + .setWorkflowId(UUID.randomUUID().toString()) + .build(); + TestWorkflow workflow = + testWorkflowRule.getWorkflowClient().newWorkflowStub(TestWorkflow.class, options); + WorkflowClient.start(workflow::execute); + SDKTestWorkflowRule.waitForOKQuery(workflow); + return workflow; + } + + private static void assertApplicationFailure(WorkflowUpdateException exception) { + assertNotNull(exception.getCause()); + assertTrue(exception.getCause() instanceof ApplicationFailure); + + ApplicationFailure failure = (ApplicationFailure) exception.getCause(); + assertEquals(FAILURE_MESSAGE, failure.getOriginalMessage()); + assertEquals(FAILURE_TYPE, failure.getType()); + assertTrue(failure.isNonRetryable()); + assertEquals(1, failure.getDetails().getSize()); + assertEquals(FAILURE_DETAIL, failure.getDetails().get(0, String.class)); + assertNull(failure.getCause()); + } + + @WorkflowInterface + public interface TestWorkflow { + + @WorkflowMethod + void execute(); + + @QueryMethod + String getState(); + + @UpdateMethod + void update(boolean reject); + + @UpdateValidatorMethod(updateName = "update") + void validateUpdate(boolean reject); + + @UpdateMethod + void complete(); + } + + public static class TestWorkflowImpl implements TestWorkflow { + + private final CompletablePromise done = Workflow.newPromise(); + + @Override + public void execute() { + done.get(); + } + + @Override + public String getState() { + return "ready"; + } + + @Override + public void update(boolean reject) {} + + @Override + public void validateUpdate(boolean reject) { + if (reject) { + throw ApplicationFailure.newNonRetryableFailure( + FAILURE_MESSAGE, FAILURE_TYPE, FAILURE_DETAIL); + } + } + + @Override + public void complete() { + done.complete(null); + } + } +} From ade44bc4d6397ce8759715641bb12fa665b1c59c Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Thu, 13 Aug 2026 09:59:25 -0700 Subject: [PATCH 061/107] =?UTF-8?q?=F0=9F=92=A5=20Fix=20unbounded=20timeou?= =?UTF-8?q?t=20failure=20chain=20in=20local=20activity=20=20(#3006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix unbounded timeout failure chain --- .../internal/sync/SyncWorkflowContext.java | 3 +- ...ityRetryOverLocalBackoffThresholdTest.java | 54 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index a1d92fc971..cdf4818bb9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -502,7 +502,8 @@ public void executeLocalActivityOverLocalRetryThreshold( input, originalScheduledTime, laException.getLastAttempt() + 1, - laException.getFailure(), + // Carry the attempt failure, not the local ActivityFailure wrapper. + laException.getFailure().getCause(), result); return null; }); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityRetryOverLocalBackoffThresholdTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityRetryOverLocalBackoffThresholdTest.java index c03e5e4e01..5386caef88 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityRetryOverLocalBackoffThresholdTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityRetryOverLocalBackoffThresholdTest.java @@ -6,11 +6,13 @@ import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.EventType; import io.temporal.api.enums.v1.RetryState; +import io.temporal.api.enums.v1.TimeoutType; import io.temporal.api.history.v1.HistoryEvent; import io.temporal.client.WorkflowException; import io.temporal.client.WorkflowStub; import io.temporal.common.RetryOptions; import io.temporal.failure.ActivityFailure; +import io.temporal.failure.TimeoutFailure; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.worker.Worker; import io.temporal.workflow.Workflow; @@ -26,6 +28,8 @@ public class LocalActivityRetryOverLocalBackoffThresholdTest { + private static final int TIMEOUT_ATTEMPTS = 3; + @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder().setDoNotStart(true).build(); @@ -96,6 +100,35 @@ public void maxAttemptDecreasedOnRetryWakeUp() { controlledActivity.verifyAttempts(); } + @Test + public void repeatedTimeoutsDoNotBuildAnUnboundedFailureChain() { + Worker worker = testWorkflowRule.getWorker(); + ControlledActivityImpl activity = + new ControlledActivityImpl( + Collections.singletonList(ControlledActivityImpl.Outcome.SLEEP), TIMEOUT_ATTEMPTS, 1); + worker.registerActivitiesImplementations(activity); + worker.registerWorkflowImplementationTypes(TimingOutWorkflowImpl.class); + testWorkflowRule.getTestEnvironment().start(); + + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + + WorkflowException e = + assertThrows( + WorkflowException.class, () -> workflowStub.execute(testWorkflowRule.getTaskQueue())); + assertTrue(e.getCause() instanceof ActivityFailure); + ActivityFailure activityFailure = (ActivityFailure) e.getCause(); + assertEquals(RetryState.RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED, activityFailure.getRetryState()); + assertTrue(activityFailure.getCause() instanceof TimeoutFailure); + TimeoutFailure timeoutFailure = (TimeoutFailure) activityFailure.getCause(); + assertEquals(TimeoutType.TIMEOUT_TYPE_START_TO_CLOSE, timeoutFailure.getTimeoutType()); + assertTrue(timeoutFailure.getCause() instanceof TimeoutFailure); + TimeoutFailure previousTimeoutFailure = (TimeoutFailure) timeoutFailure.getCause(); + assertEquals(TimeoutType.TIMEOUT_TYPE_START_TO_CLOSE, previousTimeoutFailure.getTimeoutType()); + assertNull(previousTimeoutFailure.getCause()); + activity.verifyAttempts(); + } + public static class TestWorkflowImpl implements TestWorkflows.TestWorkflow1 { @Override @@ -144,4 +177,25 @@ public String execute(String taskQueue) { return "ignored"; } } + + public static class TimingOutWorkflowImpl implements TestWorkflows.TestWorkflow1 { + + @Override + public String execute(String taskQueue) { + LocalActivityOptions options = + LocalActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofMillis(10)) + .setLocalRetryThreshold(Duration.ofMillis(1)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(2)) + .setBackoffCoefficient(1) + .setMaximumAttempts(TIMEOUT_ATTEMPTS) + .build()) + .build(); + TestActivities.NoArgsReturnsStringActivity activity = + Workflow.newLocalActivityStub(TestActivities.NoArgsReturnsStringActivity.class, options); + return activity.execute(); + } + } } From 28619b84b8053de2e758e8f5d49b33ecf19279dc Mon Sep 17 00:00:00 2001 From: Kannan Date: Thu, 13 Aug 2026 12:32:53 -0700 Subject: [PATCH 062/107] Add test verifying worker command polls omit versioning metadata (#3004) * Add test verifying worker command polls omit versioning metadata Add a dedicated test that starts a worker with deployment options and verifies via gRPC interceptor that normal nexus polls carry deploymentOptions while worker-command polls do not. This strengthens the coverage from #2987 where the existing test used an unversioned worker, making the assertions pass trivially. Co-Authored-By: Claude Opus 4.6 * Fix: register nexus service and bump timeout - Register EchoNexusServiceImpl so the normal nexus poller starts (without it, NexusTaskHandlerImpl.start() returns false and no normal nexus polls are issued, making the positive assertion fail) - Bump test timeout from 15s to 30s for headroom Co-Authored-By: Claude Opus 4.6 * Fix existing test instead of adding a separate one Configure deployment options (without useVersioning) on the existing ActivityCancellationTokenIntegrationTest worker, register a nexus service so the normal poller starts, and assert both sides: normal polls carry deploymentOptions, worker-command polls do not. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- ...ivityCancellationTokenIntegrationTest.java | 53 ++++++++++++++++--- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java index d704e186b2..26dfc88da0 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/ActivityCancellationTokenIntegrationTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; import io.grpc.CallOptions; @@ -21,10 +22,13 @@ import io.temporal.api.workflowservice.v1.PollNexusTaskQueueRequest; import io.temporal.client.ActivityCanceledException; import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.WorkerDeploymentVersion; import io.temporal.failure.ActivityFailure; import io.temporal.failure.CanceledFailure; import io.temporal.serviceclient.WorkflowServiceStubsOptions; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkerDeploymentOptions; +import io.temporal.worker.WorkerOptions; import io.temporal.workflow.Async; import io.temporal.workflow.CancellationScope; import io.temporal.workflow.Promise; @@ -32,6 +36,7 @@ import io.temporal.workflow.Workflow; import io.temporal.workflow.WorkflowInterface; import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.EchoNexusServiceImpl; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -47,6 +52,8 @@ public class ActivityCancellationTokenIntegrationTest { private final List workerCommandPollRequests = new CopyOnWriteArrayList<>(); + private final List normalNexusPollRequests = + new CopyOnWriteArrayList<>(); @Rule public SDKTestWorkflowRule testWorkflowRule = @@ -55,7 +62,21 @@ public class ActivityCancellationTokenIntegrationTest { .setWorkflowServiceStubsOptions( WorkflowServiceStubsOptions.newBuilder() .addGrpcClientInterceptor( - new WorkerCommandPollRecordingInterceptor(workerCommandPollRequests)) + new NexusPollRecordingInterceptor( + workerCommandPollRequests, normalNexusPollRequests)) + .build()) + // Configure deployment options without useVersioning(true). This causes the SDK to + // send deploymentOptions on poll requests (with UNVERSIONED mode) without requiring + // server-side deployment setup. This is sufficient to verify that worker-command polls + // omit deploymentOptions while normal polls include them — the field presence is the + // same regardless of versioning mode. + .setWorkerOptions( + WorkerOptions.newBuilder() + .setDeploymentOptions( + WorkerDeploymentOptions.newBuilder() + .setVersion( + new WorkerDeploymentVersion("test-deployment", "test-build-id")) + .build()) .build()) .setWorkflowClientOptions( WorkflowClientOptions.newBuilder() @@ -63,6 +84,7 @@ public class ActivityCancellationTokenIntegrationTest { .build()) .setWorkflowTypes(TestCancellationWorkflowImpl.class) .setActivityImplementations(new NonHeartbeatingActivityImpl()) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) .build(); @Before @@ -95,19 +117,34 @@ public void activityObservesCancellationWithoutHeartbeat() { assertEquals("cancelled", workflow.execute(testWorkflowRule.getTaskQueue())); - assertFalse("Expected a worker command Nexus poll", workerCommandPollRequests.isEmpty()); + // Normal nexus polls must carry deployment options (positive control). + assertFalse("Expected at least one normal Nexus poll", normalNexusPollRequests.isEmpty()); + for (PollNexusTaskQueueRequest request : normalNexusPollRequests) { + assertTrue( + "Normal nexus poll should have deployment options", request.hasDeploymentOptions()); + } + + // Worker command polls must NOT carry any versioning metadata. + assertFalse( + "Expected at least one worker command Nexus poll", workerCommandPollRequests.isEmpty()); for (PollNexusTaskQueueRequest request : workerCommandPollRequests) { - assertFalse(request.hasDeploymentOptions()); - assertFalse(request.hasWorkerVersionCapabilities()); + assertFalse( + "Worker command poll should not have deployment options", request.hasDeploymentOptions()); + assertFalse( + "Worker command poll should not have worker version capabilities", + request.hasWorkerVersionCapabilities()); } } - private static class WorkerCommandPollRecordingInterceptor implements ClientInterceptor { + private static class NexusPollRecordingInterceptor implements ClientInterceptor { private final List workerCommandPollRequests; + private final List normalNexusPollRequests; - private WorkerCommandPollRecordingInterceptor( - List workerCommandPollRequests) { + private NexusPollRecordingInterceptor( + List workerCommandPollRequests, + List normalNexusPollRequests) { this.workerCommandPollRequests = workerCommandPollRequests; + this.normalNexusPollRequests = normalNexusPollRequests; } @Override @@ -121,6 +158,8 @@ public void sendMessage(ReqT message) { PollNexusTaskQueueRequest request = (PollNexusTaskQueueRequest) message; if (request.getTaskQueue().getKind() == TaskQueueKind.TASK_QUEUE_KIND_WORKER_COMMANDS) { workerCommandPollRequests.add(request); + } else if (request.getTaskQueue().getKind() == TaskQueueKind.TASK_QUEUE_KIND_NORMAL) { + normalNexusPollRequests.add(request); } } super.sendMessage(message); From 7cf35351b27a3a7af17b4fadd4d281893a96580f Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Thu, 13 Aug 2026 16:18:15 -0700 Subject: [PATCH 063/107] =?UTF-8?q?=F0=9F=92=A5=20Report=20Nexus=20input?= =?UTF-8?q?=20deserialization=20failures=20as=20non-retryable=20BAD=5FREQU?= =?UTF-8?q?EST=20(#3002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit io.nexusrpc:nexus-sdk 0.6.0-alpha no longer wraps serializer errors in a generic RuntimeException (nexus-rpc/sdk-java#47), so PayloadSerializer is now the place that decides the error type and retry behavior for them. Input that will never decode into the expected type, an unparsable Payload or a DataConverterException, is reported as a non-retryable HandlerException with error type BAD_REQUEST. Any other failure on the way to the value propagates untouched so it keeps the handling a failure from an operation handler would get; in particular a PayloadCodec outage stays retryable rather than permanently failing an operation over a momentary blip. A HandlerException or ApplicationFailure raised by the data converter is also propagated as-is so the converter's own error type and retry behavior survive. An operation input type that is neither a Class nor a ParameterizedType is a handler definition problem rather than a bad request, so it is reported as a non-retryable INTERNAL. Result serialization is not translated. Note that as of the Nexus change above this is no longer the same as being unchanged: a non-retryable ApplicationFailure raised while serializing a result now reaches convertKnownFailures and becomes a non-retryable INTERNAL, where it used to be flattened to a retryable INTERNAL. BREAKING CHANGE: input a handler cannot deserialize previously failed with a retryable INTERNAL handler error and was retried until the operation's schedule-to-close timeout. It now fails immediately with a non-retryable BAD_REQUEST. --- .../internal/nexus/PayloadSerializer.java | 37 ++- .../nexus/NexusTaskHandlerImplTest.java | 38 +++ .../internal/nexus/PayloadSerializerTest.java | 154 +++++++++++ .../nexus/NexusFailureOldFormatTest.java | 8 +- ...utDeserializationErrorPropagationTest.java | 255 ++++++++++++++++++ ...OperationInputDeserializationFailTest.java | 136 ++++++++++ 6 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationFailTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java index e28f4a49ba..1e517dd03c 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java @@ -2,8 +2,11 @@ import com.google.protobuf.InvalidProtocolBufferException; import io.nexusrpc.Serializer; +import io.nexusrpc.handler.HandlerException; import io.temporal.api.common.v1.Payload; import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DataConverterException; +import io.temporal.failure.ApplicationFailure; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.Optional; @@ -13,9 +16,23 @@ * PayloadSerializer is a serializer that converts objects to and from {@link * io.nexusrpc.Serializer.Content} objects by using the {@link DataConverter} to convert objects to * and from {@link Payload} objects. + * + *

Nexus propagates serializer failures as-is, so the error type and retry behavior a handler + * reports for them is decided here. + * + *

Input that will never decode into the expected type is the caller's fault and is reported as a + * non-retryable {@link HandlerException.ErrorType#BAD_REQUEST}. Any other failure on the way to the + * value, a {@link io.temporal.payload.codec.PayloadCodec} outage for example, may well succeed on a + * retry, so it is left to the handling in {@link NexusTaskHandlerImpl} that a failure from an + * operation handler would get. + * + *

Serializing an operation result is not translated at all. Note that this still means a + * converter can choose the outcome: a non-retryable {@link ApplicationFailure} raised while + * serializing a result becomes a non-retryable {@code INTERNAL} handler error by way of {@link + * NexusTaskHandlerImpl}, and anything else keeps the retryable {@code INTERNAL} default. */ class PayloadSerializer implements Serializer { - DataConverter dataConverter; + private final DataConverter dataConverter; PayloadSerializer(DataConverter dataConverter) { this.dataConverter = dataConverter; @@ -39,10 +56,22 @@ public Content serialize(@Nullable Object o) { return dataConverter.fromPayload( payload, (Class) ((ParameterizedType) type).getRawType(), type); } else { - throw new IllegalArgumentException("Unsupported type: " + type); + // A problem with the operation definition rather than with the request, but no amount of + // retrying will introduce support for the type. + throw new HandlerException( + HandlerException.ErrorType.INTERNAL, + "Unsupported operation input type: " + type, + null, + HandlerException.RetryBehavior.NON_RETRYABLE); } - } catch (InvalidProtocolBufferException e) { - throw new RuntimeException(e); + } catch (HandlerException | ApplicationFailure e) { + // The data converter already picked an error type and retry behavior, keep them. + throw e; + } catch (InvalidProtocolBufferException | DataConverterException e) { + // These bytes will not become this type on a retry. Everything else propagates, so a + // transient failure such as a payload codec outage stays retryable. + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, "failed to deserialize input", e); } } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java index 8649cf7393..2ddfa7fda1 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerImplTest.java @@ -132,6 +132,36 @@ public void syncTimeoutTask() { () -> nexusTaskHandlerImpl.handle(new NexusTask(task, null, null), metricsScope)); } + @Test + public void startTaskWithUndeserializableInput() throws TimeoutException { + WorkflowClient client = mock(WorkflowClient.class); + NexusTaskHandlerImpl nexusTaskHandlerImpl = + new NexusTaskHandlerImpl( + client, NAMESPACE, TASK_QUEUE, dataConverter, new WorkerInterceptor[] {}); + nexusTaskHandlerImpl.registerNexusServiceImplementations( + new Object[] {new TestNexusServiceImpl2Echo()}); + nexusTaskHandlerImpl.start(); + + PollNexusTaskQueueResponse.Builder task = + PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setStartOperation( + StartOperationRequest.newBuilder() + .setOperation("operation") + .setService("TestNexusService2") + // The operation takes an Integer, so this input never deserializes + .setPayload(dataConverter.toPayload("not an integer").get()) + .build())); + + NexusTaskHandler.Result result = + nexusTaskHandlerImpl.handle(new NexusTask(task, null, null), metricsScope); + HandlerException e = result.getHandlerException(); + Assert.assertNotNull(e); + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, e.getErrorType()); + Assert.assertFalse(e.isRetryable()); + } + @Test public void startAsyncSyncOperation() throws TimeoutException { WorkflowClient client = mock(WorkflowClient.class); @@ -403,6 +433,14 @@ public OperationHandler operation() { } } + @ServiceImpl(service = TestNexusServices.TestNexusService2.class) + public class TestNexusServiceImpl2Echo { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync((ctx, details, i) -> i); + } + } + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) public class TestNexusServiceImplAsync { @OperationImpl diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java index 873e2e04a3..544fbb8738 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java @@ -1,12 +1,23 @@ package io.temporal.internal.nexus; import com.google.common.reflect.TypeToken; +import com.google.protobuf.InvalidProtocolBufferException; +import io.nexusrpc.handler.HandlerException; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DataConverterException; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.converter.EncodedValuesTest; +import io.temporal.failure.ApplicationFailure; +import io.temporal.payload.codec.PayloadCodecException; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Type; import java.util.Collections; import java.util.Map; +import java.util.Optional; +import javax.annotation.Nullable; import org.junit.Assert; import org.junit.Test; @@ -60,4 +71,147 @@ public void testProto() { PayloadSerializer.Content content = payloadSerializer.serialize(exec); Assert.assertEquals(exec, payloadSerializer.deserialize(content, WorkflowExecution.class)); } + + @Test + public void testDeserializeMalformedPayloadIsNonRetryableBadRequest() { + // Truncated varint, so these bytes are not a Payload and never will be. + PayloadSerializer.Content content = + PayloadSerializer.Content.newBuilder() + .setData(new byte[] {(byte) 0xff, (byte) 0xff, (byte) 0xff}) + .build(); + + HandlerException e = + Assert.assertThrows( + HandlerException.class, () -> payloadSerializer.deserialize(content, String.class)); + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, e.getErrorType()); + Assert.assertFalse(e.isRetryable()); + Assert.assertTrue(e.getCause() instanceof InvalidProtocolBufferException); + } + + @Test + public void testDeserializeWrongTypeIsNonRetryableBadRequest() { + PayloadSerializer.Content content = payloadSerializer.serialize("not an integer"); + + HandlerException e = + Assert.assertThrows( + HandlerException.class, () -> payloadSerializer.deserialize(content, Integer.class)); + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, e.getErrorType()); + Assert.assertFalse(e.isRetryable()); + Assert.assertTrue(e.getCause() instanceof DataConverterException); + } + + @Test + public void testDeserializeHandlerExceptionIsPropagatedAsIs() { + HandlerException original = + new HandlerException(HandlerException.ErrorType.NOT_FOUND, new RuntimeException("nope")); + PayloadSerializer serializer = failingSerializer(null, original); + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + + Assert.assertSame( + original, + Assert.assertThrows( + HandlerException.class, () -> serializer.deserialize(content, String.class))); + } + + @Test + public void testDeserializeApplicationFailureIsPropagatedAsIs() { + ApplicationFailure original = ApplicationFailure.newNonRetryableFailure("bad", "TestFailure"); + PayloadSerializer serializer = failingSerializer(null, original); + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + + Assert.assertSame( + original, + Assert.assertThrows( + ApplicationFailure.class, () -> serializer.deserialize(content, String.class))); + } + + @Test + public void testDeserializeTransientFailureIsNotTranslated() { + // A payload codec outage is not the caller's fault and may succeed on a retry, so it must not + // be flattened into a non-retryable BAD_REQUEST. + PayloadCodecException original = new PayloadCodecException("codec server unavailable"); + PayloadSerializer serializer = failingSerializer(null, original); + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + + Assert.assertSame( + original, + Assert.assertThrows( + PayloadCodecException.class, () -> serializer.deserialize(content, String.class))); + } + + @Test + public void testDeserializeUnsupportedTypeIsNonRetryableInternal() { + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + Type unsupported = + new GenericArrayType() { + @Override + public Type getGenericComponentType() { + return String.class; + } + }; + + HandlerException e = + Assert.assertThrows( + HandlerException.class, () -> payloadSerializer.deserialize(content, unsupported)); + // A handler definition problem, so not reported as the caller's fault, but still permanent. + Assert.assertEquals(HandlerException.ErrorType.INTERNAL, e.getErrorType()); + Assert.assertFalse(e.isRetryable()); + } + + @Test + public void testSerializeFailureIsPropagatedAsIs() { + // Result serialization failures are not translated, so they keep the default retryable + // INTERNAL handling applied by NexusTaskHandlerImpl. + RuntimeException original = new RuntimeException("cannot serialize"); + PayloadSerializer serializer = failingSerializer(original, null); + + Assert.assertSame( + original, Assert.assertThrows(RuntimeException.class, () -> serializer.serialize("test"))); + } + + @Test + public void testSerializeApplicationFailureIsPropagatedAsIs() { + // Result serialization is not translated either, which means a converter can still pick the + // outcome: NexusTaskHandlerImpl turns this into a non-retryable INTERNAL handler error. + ApplicationFailure original = ApplicationFailure.newNonRetryableFailure("bad", "TestFailure"); + PayloadSerializer serializer = failingSerializer(original, null); + + Assert.assertSame( + original, + Assert.assertThrows(ApplicationFailure.class, () -> serializer.serialize("test"))); + } + + /** A serializer whose underlying data converter fails in the requested direction. */ + private static PayloadSerializer failingSerializer( + @Nullable RuntimeException onSerialize, @Nullable RuntimeException onDeserialize) { + return new PayloadSerializer( + new DataConverter() { + @Override + public Optional toPayload(T value) { + if (onSerialize != null) { + throw onSerialize; + } + return dataConverter.toPayload(value); + } + + @Override + public T fromPayload(Payload payload, Class valueClass, Type valueType) { + if (onDeserialize != null) { + throw onDeserialize; + } + return dataConverter.fromPayload(payload, valueClass, valueType); + } + + @Override + public Optional toPayloads(Object... values) { + return dataConverter.toPayloads(values); + } + + @Override + public T fromPayloads( + int index, Optional content, Class valueType, Type valueGenericType) { + return dataConverter.fromPayloads(index, content, valueType, valueGenericType); + } + }); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusFailureOldFormatTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusFailureOldFormatTest.java index 2ef9a11b7c..78fa2e3aac 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusFailureOldFormatTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusFailureOldFormatTest.java @@ -6,7 +6,7 @@ import org.junit.runners.Suite; /** - * Runs the OperationFailMetric suite with the old failure format forced via system property. This + * Runs the Nexus failure suites with the old failure format forced via system property. This * verifies that the test server correctly handles the old format (UnsuccessfulOperationError and * HandlerError) even though it advertises support for the new format. * @@ -14,7 +14,11 @@ * responses regardless of server capabilities. */ @RunWith(Suite.class) -@Suite.SuiteClasses({OperationFailMetricTest.class}) +@Suite.SuiteClasses({ + OperationFailMetricTest.class, + OperationInputDeserializationFailTest.class, + OperationInputDeserializationErrorPropagationTest.class +}) public class NexusFailureOldFormatTest { private static String originalValue; diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java new file mode 100644 index 0000000000..1a97318521 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java @@ -0,0 +1,255 @@ +package io.temporal.workflow.nexus; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.failure.v1.Failure; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowFailedException; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.failure.TimeoutFailure; +import io.temporal.payload.codec.PayloadCodecException; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nonnull; +import org.junit.*; + +/** + * Verifies how a caller workflow sees failures a data converter raises while deserializing Nexus + * operation input. A {@link HandlerException} keeps the error type and retry behavior the converter + * chose, and an {@link ApplicationFailure} is wrapped by {@link + * io.temporal.internal.nexus.NexusTaskHandlerImpl} the same way one thrown from an operation + * handler is. Neither is rewritten to BAD_REQUEST, which is what happens to every other failure. + */ +public class OperationInputDeserializationErrorPropagationTest { + private static final String HANDLER_EXCEPTION = "handler-exception"; + private static final String NON_RETRYABLE_APPLICATION_FAILURE = + "non-retryable-application-failure"; + private static final String RETRYABLE_APPLICATION_FAILURE = "retryable-application-failure"; + private static final String CODEC_FAILURE = "codec-failure"; + + private static final AtomicInteger deserializeAttempts = new AtomicInteger(); + private static final AtomicInteger operationInvocations = new AtomicInteger(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setNexusServiceImplementation(new PoisonInputServiceImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setDataConverter(new PoisonInputDataConverter()) + .build()) + .build(); + + // Check if we're forcing old format via system property + private static boolean isUsingNewFormat() { + return !("true".equalsIgnoreCase(System.getProperty("temporal.nexus.forceOldFailureFormat"))); + } + + @Before + public void setUp() { + deserializeAttempts.set(0); + operationInvocations.set(0); + } + + private HandlerException executeAndGetHandlerException(String mode) { + TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow1.class); + WorkflowFailedException workflowException = + Assert.assertThrows(WorkflowFailedException.class, () -> workflowStub.execute(mode)); + Assert.assertTrue(workflowException.getCause() instanceof NexusOperationFailure); + NexusOperationFailure nexusFailure = (NexusOperationFailure) workflowException.getCause(); + Assert.assertTrue( + "expected a HandlerException, got " + nexusFailure.getCause(), + nexusFailure.getCause() instanceof HandlerException); + return (HandlerException) nexusFailure.getCause(); + } + + @Test + public void handlerExceptionKeepsItsErrorType() { + HandlerException handlerFailure = executeAndGetHandlerException(HANDLER_EXCEPTION); + + // NOT_FOUND rather than the BAD_REQUEST every other deserialization failure is reported as, + // so this also proves the converter's choice was not overwritten. + Assert.assertEquals(HandlerException.ErrorType.NOT_FOUND, handlerFailure.getErrorType()); + Assert.assertFalse(handlerFailure.isRetryable()); + + Assert.assertEquals(1, deserializeAttempts.get()); + Assert.assertEquals(0, operationInvocations.get()); + } + + @Test + public void nonRetryableApplicationFailureBecomesNonRetryableInternal() { + HandlerException handlerFailure = + executeAndGetHandlerException(NON_RETRYABLE_APPLICATION_FAILURE); + + Assert.assertEquals(HandlerException.ErrorType.INTERNAL, handlerFailure.getErrorType()); + Assert.assertEquals( + HandlerException.RetryBehavior.NON_RETRYABLE, handlerFailure.getRetryBehavior()); + Assert.assertFalse(handlerFailure.isRetryable()); + if (isUsingNewFormat()) { + Assert.assertEquals( + "Handler failed with non-retryable application error", handlerFailure.getMessage()); + } + Throwable cause = handlerFailure.getCause(); + Assert.assertNotNull(cause); + Assert.assertTrue(cause.getMessage().contains("intentional failure")); + + Assert.assertEquals(1, deserializeAttempts.get()); + Assert.assertEquals(0, operationInvocations.get()); + } + + @Test(timeout = 30000) + public void retryableApplicationFailureIsRetried() { + assertRetriedUntilTimeout(RETRYABLE_APPLICATION_FAILURE); + } + + /** + * A payload codec outage is not the caller's fault and may resolve on its own, so it must not be + * reported as a non-retryable BAD_REQUEST the way undeserializable input is. + */ + @Test(timeout = 30000) + public void codecFailureIsRetried() { + assertRetriedUntilTimeout(CODEC_FAILURE); + } + + private void assertRetriedUntilTimeout(String mode) { + TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow1.class); + WorkflowFailedException workflowException = + Assert.assertThrows(WorkflowFailedException.class, () -> workflowStub.execute(mode)); + + // Retried until the operation's schedule-to-close timeout, so the caller sees a timeout + // rather than the handler error itself. + Assert.assertTrue(workflowException.getCause() instanceof NexusOperationFailure); + NexusOperationFailure nexusFailure = (NexusOperationFailure) workflowException.getCause(); + Assert.assertTrue( + "expected a TimeoutFailure, got " + nexusFailure.getCause(), + nexusFailure.getCause() instanceof TimeoutFailure); + + Assert.assertTrue( + "expected more than one attempt, got " + deserializeAttempts.get(), + deserializeAttempts.get() > 1); + Assert.assertEquals(0, operationInvocations.get()); + } + + public static class TestNexus implements TestWorkflow1 { + @Override + public String execute(String mode) { + PoisonInputService service = + Workflow.newNexusServiceStub( + PoisonInputService.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(5)) + .build()) + .build()); + return service.operation(new FailureMode(mode)); + } + } + + /** Operation input type the data converter refuses to deserialize. */ + public static class FailureMode { + public String mode; + + public FailureMode() {} + + FailureMode(String mode) { + this.mode = mode; + } + } + + @Service + public interface PoisonInputService { + @Operation + String operation(FailureMode input); + } + + @ServiceImpl(service = PoisonInputService.class) + public static class PoisonInputServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, input) -> { + operationInvocations.incrementAndGet(); + return input.mode; + }); + } + } + + /** + * Delegates everything to the standard converter except deserializing a {@link FailureMode}, + * which fails with the exception that value names. + */ + private static class PoisonInputDataConverter implements DataConverter { + private final DataConverter delegate = DefaultDataConverter.STANDARD_INSTANCE; + + @Override + public Optional toPayload(T value) { + return delegate.toPayload(value); + } + + @Override + public Optional toPayloads(Object... values) { + return delegate.toPayloads(values); + } + + @Override + public T fromPayload(Payload payload, Class valueClass, Type valueType) { + if (valueClass == FailureMode.class) { + deserializeAttempts.incrementAndGet(); + throw failureFor(delegate.fromPayload(payload, FailureMode.class, FailureMode.class).mode); + } + return delegate.fromPayload(payload, valueClass, valueType); + } + + @Override + public T fromPayloads( + int index, Optional content, Class valueType, Type valueGenericType) { + return delegate.fromPayloads(index, content, valueType, valueGenericType); + } + + @Nonnull + @Override + public RuntimeException failureToException(@Nonnull Failure failure) { + return delegate.failureToException(failure); + } + + @Nonnull + @Override + public Failure exceptionToFailure(@Nonnull Throwable throwable) { + return delegate.exceptionToFailure(throwable); + } + + private static RuntimeException failureFor(String mode) { + switch (mode) { + case HANDLER_EXCEPTION: + return new HandlerException( + HandlerException.ErrorType.NOT_FOUND, new RuntimeException("intentional failure")); + case NON_RETRYABLE_APPLICATION_FAILURE: + return ApplicationFailure.newNonRetryableFailure("intentional failure", "TestFailure"); + case RETRYABLE_APPLICATION_FAILURE: + return ApplicationFailure.newFailure("intentional failure", "TestFailure"); + case CODEC_FAILURE: + return new PayloadCodecException("intentional failure"); + default: + throw new IllegalStateException("unknown failure mode: " + mode); + } + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationFailTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationFailTest.java new file mode 100644 index 0000000000..48fa45f77b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationFailTest.java @@ -0,0 +1,136 @@ +package io.temporal.workflow.nexus; + +import static io.temporal.testing.internal.SDKTestWorkflowRule.NAMESPACE; + +import com.google.common.collect.ImmutableMap; +import com.uber.m3.tally.RootScopeBuilder; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.client.WorkflowFailedException; +import io.temporal.common.reporter.TestStatsReporter; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.serviceclient.MetricsTag; +import io.temporal.testUtils.Eventually; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.MetricsType; +import io.temporal.worker.WorkerMetricsTag; +import io.temporal.workflow.*; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.*; + +/** + * Verifies that a caller workflow sees input a handler cannot deserialize as a non-retryable {@link + * HandlerException.ErrorType#BAD_REQUEST} rather than as a retryable internal error that is retried + * until the operation's schedule-to-close timeout. + */ +public class OperationInputDeserializationFailTest { + private static final AtomicInteger operationInvocations = new AtomicInteger(); + + private final TestStatsReporter reporter = new TestStatsReporter(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setMetricsScope( + new RootScopeBuilder() + .reporter(reporter) + .reportEvery(com.uber.m3.util.Duration.ofMillis(10))) + .build(); + + // Check if we're forcing old format via system property + private static boolean isUsingNewFormat() { + return !("true".equalsIgnoreCase(System.getProperty("temporal.nexus.forceOldFailureFormat"))); + } + + @Before + public void setUp() { + operationInvocations.set(0); + } + + @Test + public void inputDeserializationFailureIsNonRetryableBadRequest() { + TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow1.class); + + WorkflowFailedException workflowException = + Assert.assertThrows( + WorkflowFailedException.class, + () -> workflowStub.execute(testWorkflowRule.getNexusEndpoint().getSpec().getName())); + + Assert.assertTrue(workflowException.getCause() instanceof NexusOperationFailure); + NexusOperationFailure nexusFailure = (NexusOperationFailure) workflowException.getCause(); + Assert.assertEquals("TestNexusService2", nexusFailure.getService()); + Assert.assertEquals("operation", nexusFailure.getOperation()); + + // The caller sees the handler error itself. Had it stayed retryable, the operation would have + // been retried until the schedule-to-close timeout and surfaced as a timeout instead. + Assert.assertTrue(nexusFailure.getCause() instanceof HandlerException); + HandlerException handlerFailure = (HandlerException) nexusFailure.getCause(); + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, handlerFailure.getErrorType()); + Assert.assertFalse(handlerFailure.isRetryable()); + if (isUsingNewFormat()) { + Assert.assertTrue(handlerFailure.getMessage().contains("failed to deserialize input")); + } + + // Input is deserialized before the operation handler runs, so user code is never reached. + Assert.assertEquals(0, operationInvocations.get()); + + // Reported under the BAD_REQUEST failure type. The no-retry guarantee is the assertion above + // that the caller got the handler error rather than a timeout, since assertEventually returns + // on the first successful evaluation and would not see a later attempt. + Map execFailedTags = + ImmutableMap.builder() + .putAll(MetricsTag.defaultTags(NAMESPACE)) + .put(MetricsTag.WORKER_TYPE, WorkerMetricsTag.WorkerType.NEXUS_WORKER.getValue()) + .put(MetricsTag.TASK_QUEUE, testWorkflowRule.getTaskQueue()) + .put(MetricsTag.NEXUS_SERVICE, "TestNexusService2") + .put(MetricsTag.NEXUS_OPERATION, "operation") + .put( + MetricsTag.TASK_FAILURE_TYPE, + MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST) + .buildKeepingLast(); + Eventually.assertEventually( + Duration.ofSeconds(3), + () -> reporter.assertCounter(MetricsType.NEXUS_EXEC_FAILED_COUNTER, execFailedTags, 1)); + } + + public static class TestNexus implements TestWorkflow1 { + @Override + public String execute(String endpoint) { + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder() + .setEndpoint(endpoint) + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()) + .build(); + // An untyped stub lets the caller send an input the handler cannot deserialize, the way a + // caller built against an incompatible version of the service would. + NexusServiceStub serviceStub = + Workflow.newUntypedNexusServiceStub("TestNexusService2", serviceOptions); + // The operation takes an Integer. + return serviceStub.execute("operation", String.class, "not an integer"); + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService2.class) + public static class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, i) -> { + operationInvocations.incrementAndGet(); + return i; + }); + } + } +} From af2f8537727ed62e89bebdfb2da206084b270e59 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Mon, 17 Aug 2026 15:04:03 -0700 Subject: [PATCH 064/107] Report non-retryable PayloadValidationError as BAD_REQUEST (#3009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What changed A data converter can now signal that a Nexus operation's input is invalid by throwing a non-retryable `ApplicationFailure` of type `PayloadValidationError` while deserializing the input. `PayloadSerializer` translates such a failure into a `BAD_REQUEST` `HandlerException` with the message `invalid operation input`, retaining the original failure as its cause. Previously any `ApplicationFailure` from the data converter propagated and was turned into an `INTERNAL` handler error by `NexusTaskHandlerImpl`, which callers retry — so a caller sending invalid input was retried until timeout instead of failing fast. ## Unchanged - `ApplicationFailure` of any other type → propagated as before (`INTERNAL`) - A **retryable** `PayloadValidationError` → propagated as before (`INTERNAL`); non-retryable is required - `HandlerException` from the converter → passed through untouched - `InvalidProtocolBufferException` / `DataConverterException` → `BAD_REQUEST` as before - `serialize()` is untouched — `BAD_REQUEST` would be wrong for a result-encoding failure ## Tests - `PayloadSerializerTest`: positive case asserting `BAD_REQUEST`, non-retryable, the wrapper message and the preserved cause; negative cases for a different error type and for a retryable `PayloadValidationError`. - `OperationInputDeserializationErrorPropagationTest`: end-to-end through the test server, including a retryable case that proves the non-retryable guard by being retried. ## Cross-SDK Part of a coordinated change; equivalent PRs exist for Go, TypeScript, Python and .NET. The wrapper message wording is aligned across SDKs, adapted to each SDK's message style. --- .../internal/nexus/PayloadSerializer.java | 28 +++++++-- .../internal/nexus/PayloadSerializerTest.java | 59 +++++++++++++++++++ ...utDeserializationErrorPropagationTest.java | 50 +++++++++++++++- 3 files changed, 131 insertions(+), 6 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java index 1e517dd03c..97127768cb 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java @@ -21,10 +21,12 @@ * reports for them is decided here. * *

Input that will never decode into the expected type is the caller's fault and is reported as a - * non-retryable {@link HandlerException.ErrorType#BAD_REQUEST}. Any other failure on the way to the - * value, a {@link io.temporal.payload.codec.PayloadCodec} outage for example, may well succeed on a - * retry, so it is left to the handling in {@link NexusTaskHandlerImpl} that a failure from an - * operation handler would get. + * non-retryable {@link HandlerException.ErrorType#BAD_REQUEST}. A data converter can also opt into + * that treatment for input it decoded but rejected, by throwing a non-retryable {@link + * ApplicationFailure} of type {@value #PAYLOAD_VALIDATION_ERROR_TYPE}. Any other failure on the way + * to the value, a {@link io.temporal.payload.codec.PayloadCodec} outage for example, may well + * succeed on a retry, so it is left to the handling in {@link NexusTaskHandlerImpl} that a failure + * from an operation handler would get. * *

Serializing an operation result is not translated at all. Note that this still means a * converter can choose the outcome: a non-retryable {@link ApplicationFailure} raised while @@ -32,6 +34,13 @@ * NexusTaskHandlerImpl}, and anything else keeps the retryable {@code INTERNAL} default. */ class PayloadSerializer implements Serializer { + /** + * {@link ApplicationFailure#getType()} a data converter uses to say it understood the input but + * considers it invalid. When non-retryable, it is reported as {@link + * HandlerException.ErrorType#BAD_REQUEST} rather than as a handler-side {@code INTERNAL} error. + */ + static final String PAYLOAD_VALIDATION_ERROR_TYPE = "PayloadValidationError"; + private final DataConverter dataConverter; PayloadSerializer(DataConverter dataConverter) { @@ -64,7 +73,16 @@ public Content serialize(@Nullable Object o) { null, HandlerException.RetryBehavior.NON_RETRYABLE); } - } catch (HandlerException | ApplicationFailure e) { + } catch (ApplicationFailure e) { + if (e.isNonRetryable() && PAYLOAD_VALIDATION_ERROR_TYPE.equals(e.getType())) { + // The data converter decoded the input and rejected it, so this is the caller's fault + // rather than a handler-side error. + throw new HandlerException( + HandlerException.ErrorType.BAD_REQUEST, "invalid operation input", e); + } + // Otherwise the data converter already picked an error type and retry behavior, keep them. + throw e; + } catch (HandlerException e) { // The data converter already picked an error type and retry behavior, keep them. throw e; } catch (InvalidProtocolBufferException | DataConverterException e) { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java index 544fbb8738..380cbd7357 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java @@ -125,6 +125,65 @@ public void testDeserializeApplicationFailureIsPropagatedAsIs() { ApplicationFailure.class, () -> serializer.deserialize(content, String.class))); } + @Test + public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRequest() { + // The converter understood the input and rejected it, which makes this the caller's fault. + RuntimeException cause = new RuntimeException("field 'name' must not be empty"); + ApplicationFailure original = + ApplicationFailure.newNonRetryableFailureWithCause( + "invalid input", PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, cause); + PayloadSerializer serializer = failingSerializer(null, original); + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + + HandlerException e = + Assert.assertThrows( + HandlerException.class, () -> serializer.deserialize(content, String.class)); + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, e.getErrorType()); + Assert.assertFalse(e.isRetryable()); + Assert.assertEquals("invalid operation input", e.getMessage()); + // The converter's own message is not in the wrapper, so it has to survive on the cause. + Assert.assertTrue( + "expected an ApplicationFailure cause, got " + e.getCause(), + e.getCause() instanceof ApplicationFailure); + ApplicationFailure causeFailure = (ApplicationFailure) e.getCause(); + Assert.assertSame(original, causeFailure); + Assert.assertEquals(PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, causeFailure.getType()); + Assert.assertTrue(causeFailure.isNonRetryable()); + Assert.assertEquals("invalid input", causeFailure.getOriginalMessage()); + Assert.assertSame(cause, causeFailure.getCause()); + } + + @Test + public void testDeserializeNonRetryableOtherApplicationFailureTypeIsPropagatedAsIs() { + // Only the PayloadValidationError type opts into BAD_REQUEST, everything else keeps the + // non-retryable INTERNAL handling NexusTaskHandlerImpl applies. + ApplicationFailure original = + ApplicationFailure.newNonRetryableFailure("invalid input", "SomeOtherValidationError"); + PayloadSerializer serializer = failingSerializer(null, original); + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + + Assert.assertSame( + original, + Assert.assertThrows( + ApplicationFailure.class, () -> serializer.deserialize(content, String.class))); + } + + @Test + public void testDeserializeRetryablePayloadValidationErrorIsPropagatedAsIs() { + // A retryable failure may succeed on a retry, so the type alone must not make it a + // non-retryable BAD_REQUEST. + ApplicationFailure original = + ApplicationFailure.newFailure( + "invalid input", PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE); + PayloadSerializer serializer = failingSerializer(null, original); + PayloadSerializer.Content content = payloadSerializer.serialize("test"); + + Assert.assertSame( + original, + Assert.assertThrows( + ApplicationFailure.class, () -> serializer.deserialize(content, String.class))); + } + @Test public void testDeserializeTransientFailureIsNotTranslated() { // A payload codec outage is not the caller's fault and may succeed on a retry, so it must not diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java index 1a97318521..e3f4bc179c 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java @@ -32,13 +32,20 @@ * operation input. A {@link HandlerException} keeps the error type and retry behavior the converter * chose, and an {@link ApplicationFailure} is wrapped by {@link * io.temporal.internal.nexus.NexusTaskHandlerImpl} the same way one thrown from an operation - * handler is. Neither is rewritten to BAD_REQUEST, which is what happens to every other failure. + * handler is. Neither is rewritten to BAD_REQUEST, which is what happens to every other failure, + * with one exception: a non-retryable {@code PayloadValidationError} is the converter's way of + * saying the input itself was invalid, so it is reported as a non-retryable BAD_REQUEST. */ public class OperationInputDeserializationErrorPropagationTest { private static final String HANDLER_EXCEPTION = "handler-exception"; private static final String NON_RETRYABLE_APPLICATION_FAILURE = "non-retryable-application-failure"; private static final String RETRYABLE_APPLICATION_FAILURE = "retryable-application-failure"; + private static final String NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR = + "non-retryable-payload-validation-error"; + private static final String RETRYABLE_PAYLOAD_VALIDATION_ERROR = + "retryable-payload-validation-error"; + private static final String PAYLOAD_VALIDATION_ERROR_TYPE = "PayloadValidationError"; private static final String CODEC_FAILURE = "codec-failure"; private static final AtomicInteger deserializeAttempts = new AtomicInteger(); @@ -118,6 +125,41 @@ public void retryableApplicationFailureIsRetried() { assertRetriedUntilTimeout(RETRYABLE_APPLICATION_FAILURE); } + @Test + public void nonRetryablePayloadValidationErrorBecomesNonRetryableBadRequest() { + HandlerException handlerFailure = + executeAndGetHandlerException(NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR); + + // BAD_REQUEST rather than the INTERNAL every other non-retryable ApplicationFailure gets. + Assert.assertEquals(HandlerException.ErrorType.BAD_REQUEST, handlerFailure.getErrorType()); + Assert.assertFalse(handlerFailure.isRetryable()); + if (isUsingNewFormat()) { + Assert.assertEquals("invalid operation input", handlerFailure.getMessage()); + } + // The wrapper message does not carry the converter's own message, so it has to survive on the + // cause for the caller to see why the input was rejected. + Throwable cause = handlerFailure.getCause(); + Assert.assertNotNull(cause); + Assert.assertTrue( + "expected an ApplicationFailure cause, got " + cause, cause instanceof ApplicationFailure); + Assert.assertEquals("PayloadValidationError", ((ApplicationFailure) cause).getType()); + Assert.assertTrue( + "expected the converter's message on the cause, got " + cause.getMessage(), + cause.getMessage().contains("intentional failure")); + + Assert.assertEquals(1, deserializeAttempts.get()); + Assert.assertEquals(0, operationInvocations.get()); + } + + /** + * The PayloadValidationError type only opts into BAD_REQUEST when the failure is non-retryable, + * so a retryable one keeps being retried. + */ + @Test(timeout = 30000) + public void retryablePayloadValidationErrorIsRetried() { + assertRetriedUntilTimeout(RETRYABLE_PAYLOAD_VALIDATION_ERROR); + } + /** * A payload codec outage is not the caller's fault and may resolve on its own, so it must not be * reported as a non-retryable BAD_REQUEST the way undeserializable input is. @@ -245,6 +287,12 @@ private static RuntimeException failureFor(String mode) { return ApplicationFailure.newNonRetryableFailure("intentional failure", "TestFailure"); case RETRYABLE_APPLICATION_FAILURE: return ApplicationFailure.newFailure("intentional failure", "TestFailure"); + case NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR: + return ApplicationFailure.newNonRetryableFailure( + "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + case RETRYABLE_PAYLOAD_VALIDATION_ERROR: + return ApplicationFailure.newFailure( + "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); case CODEC_FAILURE: return new PayloadCodecException("intentional failure"); default: From a0021f845eda4141a1d4abd898a30ef054d070fb Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Thu, 20 Aug 2026 13:59:35 -0700 Subject: [PATCH 065/107] Add payload validation failure factory (#3021) - add io.temporal.common.converter.PayloadValidationException.newPayloadValidationException - return a non-retryable PayloadValidationError ApplicationFailure with violations as one details value - replace positive Nexus test constructions with the public factory while retaining retryable compatibility coverage --- .../converter/PayloadValidationException.java | 23 +++++++++++ .../PayloadValidationExceptionTest.java | 39 +++++++++++++++++++ .../internal/nexus/PayloadSerializerTest.java | 10 ++--- ...utDeserializationErrorPropagationTest.java | 12 +++--- 4 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java create mode 100644 temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java new file mode 100644 index 0000000000..459c2a4132 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java @@ -0,0 +1,23 @@ +package io.temporal.common.converter; + +import io.temporal.failure.ApplicationFailure; + +/** Factory for failures raised when converting a value that violates its payload schema. */ +public final class PayloadValidationException { + private static final String MESSAGE = "Payload validation failed"; + private static final String TYPE = "PayloadValidationError"; + + private PayloadValidationException() {} + + /** + * Creates a non-retryable failure containing payload validation details. + * + *

The details are stored as a single value and serialized by the configured {@link + * DataConverter}. + * + * @param details payload validation details + */ + public static ApplicationFailure newPayloadValidationException(Object details) { + return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, details); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java new file mode 100644 index 0000000000..bca471d72b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java @@ -0,0 +1,39 @@ +package io.temporal.common.converter; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.common.reflect.TypeToken; +import io.temporal.api.failure.v1.Failure; +import io.temporal.failure.ApplicationFailure; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.Test; + +public class PayloadValidationExceptionTest { + @Test + public void + newPayloadValidationExceptionReturnsNonRetryableApplicationFailureWithEncodedDetails() { + List> details = + Collections.singletonList(Collections.singletonMap("path", "$.customer.contact.email")); + + ApplicationFailure applicationFailure = + PayloadValidationException.newPayloadValidationException(details); + + assertEquals("PayloadValidationError", applicationFailure.getType()); + assertTrue(applicationFailure.isNonRetryable()); + assertEquals("Payload validation failed", applicationFailure.getOriginalMessage()); + assertEquals(1, applicationFailure.getDetails().getSize()); + + DataConverter dataConverter = DefaultDataConverter.STANDARD_INSTANCE; + Failure encodedFailure = dataConverter.exceptionToFailure(applicationFailure); + assertEquals(1, encodedFailure.getApplicationFailureInfo().getDetails().getPayloadsCount()); + + ApplicationFailure decodedFailure = + (ApplicationFailure) dataConverter.failureToException(encodedFailure); + TypeToken>> detailsType = + new TypeToken>>() {}; + assertEquals(details, decodedFailure.getDetails().get(0, List.class, detailsType.getType())); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java index 380cbd7357..4c528646df 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/PayloadSerializerTest.java @@ -10,6 +10,7 @@ import io.temporal.common.converter.DataConverterException; import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.converter.EncodedValuesTest; +import io.temporal.common.converter.PayloadValidationException; import io.temporal.failure.ApplicationFailure; import io.temporal.payload.codec.PayloadCodecException; import java.lang.reflect.GenericArrayType; @@ -128,10 +129,9 @@ public void testDeserializeApplicationFailureIsPropagatedAsIs() { @Test public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRequest() { // The converter understood the input and rejected it, which makes this the caller's fault. - RuntimeException cause = new RuntimeException("field 'name' must not be empty"); ApplicationFailure original = - ApplicationFailure.newNonRetryableFailureWithCause( - "invalid input", PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, cause); + PayloadValidationException.newPayloadValidationException( + Collections.singletonList(Collections.singletonMap("name", "must not be empty"))); PayloadSerializer serializer = failingSerializer(null, original); PayloadSerializer.Content content = payloadSerializer.serialize("test"); @@ -149,8 +149,8 @@ public void testDeserializeNonRetryablePayloadValidationErrorIsNonRetryableBadRe Assert.assertSame(original, causeFailure); Assert.assertEquals(PayloadSerializer.PAYLOAD_VALIDATION_ERROR_TYPE, causeFailure.getType()); Assert.assertTrue(causeFailure.isNonRetryable()); - Assert.assertEquals("invalid input", causeFailure.getOriginalMessage()); - Assert.assertSame(cause, causeFailure.getCause()); + Assert.assertEquals("Payload validation failed", causeFailure.getOriginalMessage()); + Assert.assertEquals(1, causeFailure.getDetails().getSize()); } @Test diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java index e3f4bc179c..ac826b94e2 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/OperationInputDeserializationErrorPropagationTest.java @@ -13,6 +13,7 @@ import io.temporal.client.WorkflowFailedException; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.converter.PayloadValidationException; import io.temporal.failure.ApplicationFailure; import io.temporal.failure.NexusOperationFailure; import io.temporal.failure.TimeoutFailure; @@ -22,6 +23,7 @@ import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; import java.lang.reflect.Type; import java.time.Duration; +import java.util.Collections; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.Nonnull; @@ -45,7 +47,6 @@ public class OperationInputDeserializationErrorPropagationTest { "non-retryable-payload-validation-error"; private static final String RETRYABLE_PAYLOAD_VALIDATION_ERROR = "retryable-payload-validation-error"; - private static final String PAYLOAD_VALIDATION_ERROR_TYPE = "PayloadValidationError"; private static final String CODEC_FAILURE = "codec-failure"; private static final AtomicInteger deserializeAttempts = new AtomicInteger(); @@ -145,7 +146,7 @@ public void nonRetryablePayloadValidationErrorBecomesNonRetryableBadRequest() { Assert.assertEquals("PayloadValidationError", ((ApplicationFailure) cause).getType()); Assert.assertTrue( "expected the converter's message on the cause, got " + cause.getMessage(), - cause.getMessage().contains("intentional failure")); + cause.getMessage().contains("Payload validation failed")); Assert.assertEquals(1, deserializeAttempts.get()); Assert.assertEquals(0, operationInvocations.get()); @@ -288,11 +289,10 @@ private static RuntimeException failureFor(String mode) { case RETRYABLE_APPLICATION_FAILURE: return ApplicationFailure.newFailure("intentional failure", "TestFailure"); case NON_RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return ApplicationFailure.newNonRetryableFailure( - "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + return PayloadValidationException.newPayloadValidationException( + Collections.singletonList("intentional validation failure")); case RETRYABLE_PAYLOAD_VALIDATION_ERROR: - return ApplicationFailure.newFailure( - "intentional failure", PAYLOAD_VALIDATION_ERROR_TYPE); + return ApplicationFailure.newFailure("intentional failure", "PayloadValidationError"); case CODEC_FAILURE: return new PayloadCodecException("intentional failure"); default: From ee9abf08c5172529be7b7768adb73d169bd39750 Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Fri, 21 Aug 2026 13:55:00 -0700 Subject: [PATCH 066/107] Omit null payload validation details (#3027) --- .../common/converter/PayloadValidationException.java | 7 +++++-- .../converter/PayloadValidationExceptionTest.java | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java index 459c2a4132..1b8095179c 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java +++ b/temporal-sdk/src/main/java/io/temporal/common/converter/PayloadValidationException.java @@ -12,12 +12,15 @@ private PayloadValidationException() {} /** * Creates a non-retryable failure containing payload validation details. * - *

The details are stored as a single value and serialized by the configured {@link - * DataConverter}. + *

Non-null details are stored as a single value and serialized by the configured {@link + * DataConverter}. If details are null, the failure has no detail values. * * @param details payload validation details */ public static ApplicationFailure newPayloadValidationException(Object details) { + if (details == null) { + return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE); + } return ApplicationFailure.newNonRetryableFailure(MESSAGE, TYPE, details); } } diff --git a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java index bca471d72b..0c8b592bc6 100644 --- a/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/common/converter/PayloadValidationExceptionTest.java @@ -12,6 +12,18 @@ import org.junit.Test; public class PayloadValidationExceptionTest { + @Test + public void newPayloadValidationExceptionWithNullDetailsHasNoDetails() { + ApplicationFailure applicationFailure = + PayloadValidationException.newPayloadValidationException(null); + + assertEquals(0, applicationFailure.getDetails().getSize()); + + DataConverter dataConverter = DefaultDataConverter.STANDARD_INSTANCE; + Failure encodedFailure = dataConverter.exceptionToFailure(applicationFailure); + assertEquals(0, encodedFailure.getApplicationFailureInfo().getDetails().getPayloadsCount()); + } + @Test public void newPayloadValidationExceptionReturnsNonRetryableApplicationFailureWithEncodedDetails() { From 12c21142e62b97e3efdbb9d5b58c1b8048bb3855 Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Mon, 24 Aug 2026 15:25:00 -0700 Subject: [PATCH 067/107] Add SDK Sentinel PR responder (#3034) --- .../workflows/sdk-sentinel-pr-responder.yml | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 .github/workflows/sdk-sentinel-pr-responder.yml diff --git a/.github/workflows/sdk-sentinel-pr-responder.yml b/.github/workflows/sdk-sentinel-pr-responder.yml new file mode 100644 index 0000000000..c3ef21a76e --- /dev/null +++ b/.github/workflows/sdk-sentinel-pr-responder.yml @@ -0,0 +1,57 @@ +name: SDK Sentinel PR responder relay + +on: + issue_comment: + types: [created] + +permissions: {} + +jobs: + relay: + if: >- + github.event.issue.pull_request && + github.event.comment.user.login != 'sdk-sentinel-bot' && + contains(github.event.comment.body, '@sdk-sentinel-bot') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Mint a Sentinel-only dispatch token from the Relay App + id: dispatch-token + uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2 + with: + app-id: ${{ vars.SDK_SENTINEL_RELAY_APP_ID }} + private-key: ${{ secrets.SDK_SENTINEL_RELAY_PRIVATE_KEY }} + owner: temporalio + repositories: sdk-sentinel + permission-actions: write + permission-metadata: read + - name: Verify the Sentinel Relay installation + env: + ACTUAL_APP_SLUG: ${{ steps.dispatch-token.outputs.app-slug }} + ACTUAL_INSTALLATION_ID: ${{ steps.dispatch-token.outputs.installation-id }} + EXPECTED_INSTALLATION_ID: ${{ vars.SDK_SENTINEL_RELAY_INSTALLATION_ID }} + run: | + test "$ACTUAL_APP_SLUG" = sdk-sentinel-relay + test "$ACTUAL_INSTALLATION_ID" = "$EXPECTED_INSTALLATION_ID" + - name: Dispatch the trusted central responder + env: + COMMENT_ID: ${{ github.event.comment.id }} + DISPATCH_TOKEN: ${{ steps.dispatch-token.outputs.token }} + PR_NUMBER: ${{ github.event.issue.number }} + TARGET_ID: java + run: | + payload="$( + jq -cn \ + --arg target "$TARGET_ID" \ + --arg pr_number "$PR_NUMBER" \ + --arg comment_id "$COMMENT_ID" \ + '{ref:"main",inputs:{target:$target,pr_number:$pr_number,comment_id:$comment_id}}' + )" + curl --fail --silent --show-error \ + --request POST \ + --header "Accept: application/vnd.github+json" \ + --header "Authorization: Bearer $DISPATCH_TOKEN" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --data "$payload" \ + https://api.github.com/repos/temporalio/sdk-sentinel/actions/workflows/sdk-pr-responder.yml/dispatches From 1b2ffb18bfa8a09d15ed63e3b0e9dfe50f9c5709 Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Tue, 25 Aug 2026 11:54:59 -0400 Subject: [PATCH 068/107] [SDK Sentinel] Allow activity retry delay test to complete (#3010) * Fix CI flake detected by SDK Sentinel (java) * Refine retry-delay test assertions SDK-Sentinel-Request: temporalio/sdk-java#3010/comment-5411842828 --------- Co-authored-by: sdk-sentinel-publisher[bot] <314421413+sdk-sentinel-publisher[bot]@users.noreply.github.com> --- .../activity/ActivityNextRetryDelayTest.java | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/activity/ActivityNextRetryDelayTest.java b/temporal-sdk/src/test/java/io/temporal/activity/ActivityNextRetryDelayTest.java index e04e33ccfc..60982869bf 100644 --- a/temporal-sdk/src/test/java/io/temporal/activity/ActivityNextRetryDelayTest.java +++ b/temporal-sdk/src/test/java/io/temporal/activity/ActivityNextRetryDelayTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.*; +import io.temporal.common.RetryOptions; import io.temporal.failure.ApplicationFailure; import io.temporal.testing.internal.SDKTestOptions; import io.temporal.testing.internal.SDKTestWorkflowRule; @@ -10,17 +11,21 @@ import io.temporal.workflow.WorkflowMethod; import io.temporal.workflow.shared.TestActivities; import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.Assert; import org.junit.Rule; import org.junit.Test; public class ActivityNextRetryDelayTest { + private final NextRetryDelayActivityImpl activity = new NextRetryDelayActivityImpl(); + @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() + .setTestTimeoutSeconds(30) .setWorkflowTypes(TestWorkflowImpl.class) - .setActivityImplementations(new NextRetryDelayActivityImpl()) + .setActivityImplementations(activity) .build(); @Test @@ -28,7 +33,8 @@ public void activityNextRetryDelay() { TestWorkflowReturnDuration workflow = testWorkflowRule.newWorkflowStub(TestWorkflowReturnDuration.class); Duration result = workflow.execute(false); - Assert.assertTrue(result.toMillis() > 5000 && result.toMillis() < 7000); + Assert.assertTrue(result.toMillis() > 5000); + Assert.assertEquals(4, activity.getAttemptCount()); } @Test @@ -36,7 +42,8 @@ public void localActivityNextRetryDelay() { TestWorkflowReturnDuration workflow = testWorkflowRule.newWorkflowStub(TestWorkflowReturnDuration.class); Duration result = workflow.execute(true); - Assert.assertTrue(result.toMillis() > 5000 && result.toMillis() < 7000); + Assert.assertTrue(result.toMillis() > 5000); + Assert.assertEquals(4, activity.getAttemptCount()); } @WorkflowInterface @@ -47,15 +54,22 @@ public interface TestWorkflowReturnDuration { public static class TestWorkflowImpl implements TestWorkflowReturnDuration { + private static final RetryOptions FALLBACK_RETRY_OPTIONS = + RetryOptions.newBuilder().setInitialInterval(Duration.ofMillis(100)).build(); + private final TestActivities.NoArgsActivity activities = Workflow.newActivityStub( TestActivities.NoArgsActivity.class, - SDKTestOptions.newActivityOptions20sScheduleToClose()); + SDKTestOptions.newActivityOptions20sScheduleToClose().toBuilder() + .setRetryOptions(FALLBACK_RETRY_OPTIONS) + .build()); private final TestActivities.NoArgsActivity localActivities = Workflow.newLocalActivityStub( TestActivities.NoArgsActivity.class, - SDKTestOptions.newLocalActivityOptions20sScheduleToClose()); + SDKTestOptions.newLocalActivityOptions20sScheduleToClose().toBuilder() + .setRetryOptions(FALLBACK_RETRY_OPTIONS) + .build()); @Override public Duration execute(boolean useLocalActivity) { @@ -71,8 +85,11 @@ public Duration execute(boolean useLocalActivity) { } public static class NextRetryDelayActivityImpl implements TestActivities.NoArgsActivity { + private final AtomicInteger attemptCount = new AtomicInteger(); + @Override public void execute() { + attemptCount.incrementAndGet(); int attempt = Activity.getExecutionContext().getInfo().getAttempt(); if (attempt < 4) { throw ApplicationFailure.newFailureWithCauseAndDelay( @@ -82,5 +99,9 @@ public void execute() { Duration.ofSeconds(attempt)); } } + + public int getAttemptCount() { + return attemptCount.get(); + } } } From 867ecf072956961aa1ecd6e09f099943d3d26843 Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Wed, 26 Aug 2026 14:19:06 -0700 Subject: [PATCH 069/107] Remove Experimental annotation for user metadata from a few APIs (#3038) --- .../java/io/temporal/workflow/MutableSideEffectOptions.java | 2 -- .../main/java/io/temporal/workflow/NexusOperationOptions.java | 2 -- .../src/main/java/io/temporal/workflow/SideEffectOptions.java | 2 -- 3 files changed, 6 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/MutableSideEffectOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/MutableSideEffectOptions.java index f475d9aa2c..132fa50110 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/MutableSideEffectOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/MutableSideEffectOptions.java @@ -1,6 +1,5 @@ package io.temporal.workflow; -import io.temporal.common.Experimental; import java.util.Objects; /** MutableSideEffectOptions is used to specify options for a side effect. */ @@ -42,7 +41,6 @@ private Builder(MutableSideEffectOptions options) { * *

Default is none/empty. */ - @Experimental public MutableSideEffectOptions.Builder setSummary(String summary) { this.summary = summary; return this; diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/NexusOperationOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/NexusOperationOptions.java index 0952f6853a..b4cddbf021 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/NexusOperationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/NexusOperationOptions.java @@ -106,7 +106,6 @@ public NexusOperationOptions.Builder setCancellationType( * *

Default is none/empty. */ - @Experimental public NexusOperationOptions.Builder setSummary(String summary) { this.summary = summary; return this; @@ -199,7 +198,6 @@ public NexusOperationCancellationType getCancellationType() { return cancellationType; } - @Experimental public String getSummary() { return summary; } diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/SideEffectOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/SideEffectOptions.java index 9a56d2ebb3..6b163b9925 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/SideEffectOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/SideEffectOptions.java @@ -1,6 +1,5 @@ package io.temporal.workflow; -import io.temporal.common.Experimental; import java.util.Objects; /** SideEffectOptions is used to specify options for a side effect. */ @@ -42,7 +41,6 @@ private Builder(SideEffectOptions options) { * *

Default is none/empty. */ - @Experimental public SideEffectOptions.Builder setSummary(String summary) { this.summary = summary; return this; From 37627549cb2c17cb06cf2dac4747cc14e814dd61 Mon Sep 17 00:00:00 2001 From: Frenchwood <46058503+JoshuaFrenchwood@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:49:59 -0500 Subject: [PATCH 070/107] Default Nexus client activity task queue to the worker task queue (#3039) * Default Nexus client activity task queue to the worker task queue * Removing taskQueue from StartActivityOptions in Nexus SAA tests --- .../temporal/client/StartActivityOptions.java | 16 ++++++---- .../client/RootActivityClientInvoker.java | 3 ++ .../nexus/NexusStartActivityHelper.java | 6 +++- .../temporal/nexus/TemporalNexusClient.java | 30 +++++++++---------- .../client/StartActivityOptionsTest.java | 14 ++++++++- .../client/nexus/NexusClientTest.java | 1 - .../client/RootActivityClientInvokerTest.java | 19 ++++++++++++ .../nexus/TemporalNexusClientImplTest.java | 22 ++++++++++---- .../ActivityHandleFailOnConflictTest.java | 1 - ...tivityHandleUseExistingOnConflictTest.java | 1 - .../nexus/AsyncActivityOperationTest.java | 1 - .../CancelActivityAsyncOperationTest.java | 2 -- 12 files changed, 81 insertions(+), 35 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java index 7eed754476..12e86b0522 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java @@ -31,7 +31,7 @@ public static Builder newBuilder(StartActivityOptions options) { public static final class Builder { private String id; - private String taskQueue; + private @Nullable String taskQueue; private @Nullable Duration scheduleToCloseTimeout; private @Nullable Duration scheduleToStartTimeout; private @Nullable Duration startToCloseTimeout; @@ -75,8 +75,12 @@ public Builder setId(String id) { return this; } - /** Required. The task queue that workers will poll for this activity. */ - public Builder setTaskQueue(String taskQueue) { + /** + * The task queue that workers will poll for this activity. Required when starting through an + * {@link ActivityClient}. When starting through a Temporal Nexus client, this defaults to the + * current worker's task queue. + */ + public Builder setTaskQueue(@Nullable String taskQueue) { this.taskQueue = taskQueue; return this; } @@ -178,7 +182,7 @@ public Builder setStartDelay(Duration startDelay) { public StartActivityOptions build() { Preconditions.checkArgument(!Strings.isNullOrEmpty(id), "id must not be null or empty"); Preconditions.checkArgument( - !Strings.isNullOrEmpty(taskQueue), "taskQueue must not be null or empty"); + taskQueue == null || !taskQueue.isEmpty(), "taskQueue must not be empty"); Preconditions.checkArgument( scheduleToCloseTimeout != null || startToCloseTimeout != null, "At least one of scheduleToCloseTimeout or startToCloseTimeout must be set"); @@ -187,7 +191,7 @@ public StartActivityOptions build() { } private final String id; - private final String taskQueue; + private final @Nullable String taskQueue; private final @Nullable Duration scheduleToCloseTimeout; private final @Nullable Duration scheduleToStartTimeout; private final @Nullable Duration startToCloseTimeout; @@ -226,7 +230,7 @@ public String getId() { return id; } - public String getTaskQueue() { + public @Nullable String getTaskQueue() { return taskQueue; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index 3d4b1155d3..225228e6a2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -55,6 +55,9 @@ public RootActivityClientInvoker( @Override public StartActivityOutput startActivity(StartActivityInput input) { StartActivityOptions options = input.getOptions(); + if (Strings.isNullOrEmpty(options.getTaskQueue())) { + throw new IllegalArgumentException("taskQueue must not be null or empty"); + } DataConverter dc = clientOptions.getDataConverter(); InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.isNexusContext() ? CurrentNexusOperationContext.get() : null; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java index 9cf346c8f7..5b9185b44d 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusStartActivityHelper.java @@ -23,7 +23,8 @@ public class NexusStartActivityHelper { * @param details the operation start details containing requestId, callback, links * @param activityType the activity type name * @param args the activity arguments - * @param options the activity scheduling options (must include task queue, ID) + * @param options the activity scheduling options; the task queue defaults to the current worker's + * task queue * @param header the propagated header * @param invoker function that starts the activity given a {@link NexusStartActivityRequest} * @return the {@link NexusStartActivityResponse} containing the activity ID and operation token @@ -37,6 +38,9 @@ public static NexusStartActivityResponse startActivityAndAttachLinks( Header header, Function invoker) { InternalNexusOperationContext nexusCtx = CurrentNexusOperationContext.get(); + if (options.getTaskQueue() == null) { + options = options.toBuilder().setTaskQueue(nexusCtx.getTaskQueue()).build(); + } NexusStartActivityRequest nexusRequest = new NexusStartActivityRequest( diff --git a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java index 0f5616985d..8b70f6f515 100644 --- a/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java +++ b/temporal-sdk/src/main/java/io/temporal/nexus/TemporalNexusClient.java @@ -1379,7 +1379,7 @@ TemporalOperationResult startWorkflowUpdate( * * @param activityInterface the activity interface class * @param activityMethod unbound method reference to the activity method - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the activity return type * @return an async {@link TemporalOperationResult} with the activity-execution operation token @@ -1395,7 +1395,7 @@ TemporalOperationResult startActivity( * @param activityInterface the activity interface class * @param activityMethod unbound method reference to the activity method * @param arg1 first activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the activity return type @@ -1414,7 +1414,7 @@ TemporalOperationResult startActivity( * @param activityMethod unbound method reference to the activity method * @param arg1 first activity argument * @param arg2 second activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1436,7 +1436,7 @@ TemporalOperationResult startActivity( * @param arg1 first activity argument * @param arg2 second activity argument * @param arg3 third activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1461,7 +1461,7 @@ TemporalOperationResult startActivity( * @param arg2 second activity argument * @param arg3 third activity argument * @param arg4 fourth activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1489,7 +1489,7 @@ TemporalOperationResult startActivity( * @param arg3 third activity argument * @param arg4 fourth activity argument * @param arg5 fifth activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1520,7 +1520,7 @@ TemporalOperationResult startActivity( * @param arg4 fourth activity argument * @param arg5 fifth activity argument * @param arg6 sixth activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1547,7 +1547,7 @@ TemporalOperationResult startActivity( * * @param activityInterface the activity interface class * @param activityMethod unbound method reference to the activity method - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @return an async {@link TemporalOperationResult} with the activity-execution operation token */ @@ -1560,7 +1560,7 @@ TemporalOperationResult startActivity( * @param activityInterface the activity interface class * @param activityMethod unbound method reference to the activity method * @param arg1 first activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @return an async {@link TemporalOperationResult} with the activity-execution operation token @@ -1578,7 +1578,7 @@ TemporalOperationResult startActivity( * @param activityMethod unbound method reference to the activity method * @param arg1 first activity argument * @param arg2 second activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1599,7 +1599,7 @@ TemporalOperationResult startActivity( * @param arg1 first activity argument * @param arg2 second activity argument * @param arg3 third activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1623,7 +1623,7 @@ TemporalOperationResult startActivity( * @param arg2 second activity argument * @param arg3 third activity argument * @param arg4 fourth activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1650,7 +1650,7 @@ TemporalOperationResult startActivity( * @param arg3 third activity argument * @param arg4 fourth activity argument * @param arg5 fifth activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1680,7 +1680,7 @@ TemporalOperationResult startActivity( * @param arg4 fourth activity argument * @param arg5 fifth activity argument * @param arg6 sixth activity argument - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param the activity interface type * @param the type of the first activity argument * @param the type of the second activity argument @@ -1712,7 +1712,7 @@ TemporalOperationResult startActivity( * * @param activityType the activity type name string * @param resultClass the expected result class - * @param options activity start options (must include taskQueue) + * @param options activity start options; taskQueue defaults to the current worker task queue * @param args activity arguments * @param the activity return type * @return an async {@link TemporalOperationResult} with the activity-execution operation token diff --git a/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java index 96d0482687..92ac00cea3 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java @@ -19,10 +19,22 @@ public void testMissingIdFails() { .build(); } + @Test + public void testMissingTaskQueueAllowed() { + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("id") + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + assertNull(options.getTaskQueue()); + } + @Test(expected = IllegalArgumentException.class) - public void testMissingTaskQueueFails() { + public void testEmptyTaskQueueFails() { StartActivityOptions.newBuilder() .setId("id") + .setTaskQueue("") .setStartToCloseTimeout(Duration.ofSeconds(10)) .build(); } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java index 0046a321d4..24a5132afd 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusClientTest.java @@ -391,7 +391,6 @@ public OperationHandler operation() { activityId, StartActivityOptions.newBuilder() .setId(activityId) - .setTaskQueue(testWorkflowRule.getTaskQueue()) .setScheduleToCloseTimeout(Duration.ofSeconds(30)) .build())); } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java index a80932dcc1..16ba8b7f36 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java @@ -148,6 +148,25 @@ public void nexusContextWithoutMetadataStartsOrdinaryActivity() { Assert.assertTrue(nexusContext.getResponseLinks().isEmpty()); } + @Test + public void missingTaskQueueFailsAtInvocation() { + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("activity-id") + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + IllegalArgumentException exception = + Assert.assertThrows( + IllegalArgumentException.class, + () -> + invoker.startActivity( + new StartActivityInput( + "TestActivity", Collections.emptyList(), options, Header.empty()))); + + Assert.assertEquals("taskQueue must not be null or empty", exception.getMessage()); + } + private static StartActivityInput newStartActivityInput() { StartActivityOptions options = StartActivityOptions.newBuilder() diff --git a/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java b/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java index 2433db132c..09a27d2a8d 100644 --- a/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java +++ b/temporal-sdk/src/test/java/io/temporal/nexus/TemporalNexusClientImplTest.java @@ -145,26 +145,39 @@ public void tearDown() { // ---------- Activity double-start ---------- @Test - public void startActivity_propagatesWorkflowClientContext() { + public void startActivity_defaultsTaskQueueAndPropagatesWorkflowClientContext() { StartActivityOptions options = StartActivityOptions.newBuilder() .setId("act-context") - .setTaskQueue(TASK_QUEUE) .setStartToCloseTimeout(Duration.ofSeconds(10)) .build(); client.startActivity(TestActivity.class, TestActivity::doSomething, options); Assert.assertNotNull(activityInput.get()); + Assert.assertEquals(TASK_QUEUE, activityInput.get().getOptions().getTaskQueue()); Assert.assertTrue(activityInput.get().getHeader().getValues().containsKey("propagated-key")); } + @Test + public void startActivity_preservesExplicitTaskQueue() { + StartActivityOptions options = + StartActivityOptions.newBuilder() + .setId("act-explicit-task-queue") + .setTaskQueue("explicit-task-queue") + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(); + + client.startActivity(TestActivity.class, TestActivity::doSomething, options); + + Assert.assertEquals("explicit-task-queue", activityInput.get().getOptions().getTaskQueue()); + } + @Test public void doubleStartActivity_secondCallThrowsBadRequest() { StartActivityOptions options = StartActivityOptions.newBuilder() .setId("act-1") - .setTaskQueue(TASK_QUEUE) .setStartToCloseTimeout(Duration.ofSeconds(10)) .build(); @@ -181,7 +194,6 @@ public void doubleStartActivity_secondCallThrowsBadRequest() { TestActivity::doSomething, StartActivityOptions.newBuilder() .setId("act-2") - .setTaskQueue(TASK_QUEUE) .setStartToCloseTimeout(Duration.ofSeconds(10)) .build())); @@ -244,7 +256,6 @@ public void startWorkflowThenActivity_activityThrowsBadRequest() { TestActivity::doSomething, StartActivityOptions.newBuilder() .setId("act-mixed-1") - .setTaskQueue(TASK_QUEUE) .setStartToCloseTimeout(Duration.ofSeconds(10)) .build())); @@ -258,7 +269,6 @@ public void startActivityThenWorkflow_workflowThrowsBadRequest() { StartActivityOptions actOptions = StartActivityOptions.newBuilder() .setId("act-mixed-2") - .setTaskQueue(TASK_QUEUE) .setStartToCloseTimeout(Duration.ofSeconds(10)) .build(); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java index bf6e23912d..a940b131cb 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleFailOnConflictTest.java @@ -133,7 +133,6 @@ public OperationHandler operation() { activityId, StartActivityOptions.newBuilder() .setId(activityId) - .setTaskQueue(testWorkflowRule.getTaskQueue()) .setScheduleToCloseTimeout(Duration.ofMinutes(1)) .setIdConflictPolicy(ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_FAIL) .build()); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java index c699d997ea..73b27eb992 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityHandleUseExistingOnConflictTest.java @@ -159,7 +159,6 @@ public OperationHandler operation() { activityId, StartActivityOptions.newBuilder() .setId(activityId) - .setTaskQueue(testWorkflowRule.getTaskQueue()) .setScheduleToCloseTimeout(Duration.ofMinutes(1)) .setIdConflictPolicy( ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_USE_EXISTING) diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java index 1a1e5bea49..908f5f618f 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/AsyncActivityOperationTest.java @@ -95,7 +95,6 @@ public OperationHandler operation() { input, StartActivityOptions.newBuilder() .setId("act-" + context.getRequestId()) - .setTaskQueue(testWorkflowRule.getTaskQueue()) .setStartToCloseTimeout(Duration.ofSeconds(10)) .build())); } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java index 45a2b78794..c0e3f303e2 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/CancelActivityAsyncOperationTest.java @@ -191,7 +191,6 @@ public OperationHandler operation() { input, StartActivityOptions.newBuilder() .setId("act-" + context.getRequestId()) - .setTaskQueue(input) .setStartToCloseTimeout(Duration.ofSeconds(60)) .setHeartbeatTimeout(Duration.ofSeconds(2)) .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) @@ -246,7 +245,6 @@ public OperationHandler operation() { input, StartActivityOptions.newBuilder() .setId("act-override-" + context.getRequestId()) - .setTaskQueue(input) .setStartToCloseTimeout(Duration.ofSeconds(5)) .setHeartbeatTimeout(Duration.ofSeconds(2)) .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) From 809e57cfdf747f8f42881b26d0eb89fae1a230b6 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:39:18 -0700 Subject: [PATCH 071/107] Add failure_reason tag to activity execution failed metrics (#3036) --- .../internal/activity/ActivityTaskHandlerImpl.java | 10 ++++++++-- .../internal/worker/ActivityFailedMetricsTests.java | 1 + .../test/java/io/temporal/workflow/MetricsTest.java | 1 + .../java/io/temporal/serviceclient/MetricsTag.java | 1 + 4 files changed, 11 insertions(+), 2 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java index 48e9dbfabf..5b2b2bf90a 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityTaskHandlerImpl.java @@ -195,11 +195,17 @@ static ActivityTaskHandler.Result mapToActivityFailure( metricsScope.tagged( ImmutableMap.of(MetricsTag.EXCEPTION, exception.getClass().getSimpleName())); if (!FailureUtils.isBenignApplicationFailure(exception)) { + // The deprecated counter keeps its original tags so that dashboards still reading it are + // not split into a new series. + Scope failureScope = + ms.tagged( + ImmutableMap.of( + MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_ACTIVITY_ERROR)); if (isLocalActivity) { - ms.counter(MetricsType.LOCAL_ACTIVITY_EXEC_FAILED_COUNTER).inc(1); + failureScope.counter(MetricsType.LOCAL_ACTIVITY_EXEC_FAILED_COUNTER).inc(1); ms.counter(MetricsType.LOCAL_ACTIVITY_FAILED_COUNTER).inc(1); } else { - ms.counter(MetricsType.ACTIVITY_EXEC_FAILED_COUNTER).inc(1); + failureScope.counter(MetricsType.ACTIVITY_EXEC_FAILED_COUNTER).inc(1); } } Failure failure = dataConverter.exceptionToFailure(exception); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityFailedMetricsTests.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityFailedMetricsTests.java index 1b0cf17d6b..86bd6c233a 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityFailedMetricsTests.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityFailedMetricsTests.java @@ -137,6 +137,7 @@ private Map getActivityTagsWithWorkerType( tags.put("namespace", "UnitTest"); tags.put("activity_type", "Execute"); tags.put("exception", "ApplicationFailure"); + tags.put("failure_reason", "ActivityError"); tags.put("worker_type", workerType); tags.put("workflow_type", workflowType); return tags; diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java index 17178b1996..5b4b9df97a 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java @@ -439,6 +439,7 @@ public void testTemporalActivityFailureMetric() throws InterruptedException { .putAll(TAGS_ACTIVITY_WORKER) .put(MetricsTag.ACTIVITY_TYPE, "ThrowIO") .put(MetricsTag.EXCEPTION, "IOException") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_ACTIVITY_ERROR) .put(MetricsTag.WORKFLOW_TYPE, "NoArgsWorkflow") .build(); diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java index e41533e612..d894709d74 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java @@ -27,6 +27,7 @@ public class MetricsTag { public static final String TASK_FAILURE_VALUE_NON_DETERMINISM_ERROR = "NonDeterminismError"; public static final String TASK_FAILURE_VALUE_GRPC_MESSAGE_TOO_LARGE = "GrpcMessageTooLarge"; public static final String TASK_FAILURE_VALUE_WORKFLOW_ERROR = "WorkflowError"; + public static final String TASK_FAILURE_VALUE_ACTIVITY_ERROR = "ActivityError"; public static final String TASK_FAILURE_VALUE_OPERATION_FAILED = "operation_failed"; public static final String TASK_FAILURE_VALUE_OPERATION_CANCELED = "operation_canceled"; public static final String TASK_FAILURE_VALUE_HANDLER_ERROR_BAD_REQUEST = From e10c8e62f644462cfac8833b5feea9ea2e6a354a Mon Sep 17 00:00:00 2001 From: Muhammad Rifqi Fatchurrahman Date: Thu, 27 Aug 2026 08:49:34 +0700 Subject: [PATCH 072/107] Fix NPE when a workflow implementation uses a composed @WorkflowImpl annotation (#3024) * Add failing test for composed annotated workflow auto-discovery NPE * Fix NPE by using AnnotationUtils for lookups * set longer startToCloseTimeout in test setup Co-authored-by: Maciej Dudkowski --------- Co-authored-by: Maciej Dudkowski --- .../template/WorkersTemplate.java | 4 +- .../AutoDiscoveryComposedAnnotationTest.java | 57 +++++++++++++++++++ .../ComposedActivityImpl.java | 12 ++++ .../ComposedAnnotatedActivity.java | 8 +++ .../ComposedAnnotatedActivityImpl.java | 10 ++++ .../ComposedAnnotatedWorkflow.java | 11 ++++ .../ComposedAnnotatedWorkflowImpl.java | 20 +++++++ .../ComposedWorkflowImpl.java | 12 ++++ .../src/test/resources/application.yml | 12 ++++ 9 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoDiscoveryComposedAnnotationTest.java create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedActivityImpl.java create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivity.java create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivityImpl.java create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflow.java create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflowImpl.java create mode 100644 temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedWorkflowImpl.java diff --git a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java index 6fb972cf0c..dfbc18d7f6 100644 --- a/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java +++ b/temporal-spring-boot-autoconfigure/src/main/java/io/temporal/spring/boot/autoconfigure/template/WorkersTemplate.java @@ -224,7 +224,7 @@ private void configureWorkflowImplementationsByTaskQueue( Workers workers, Collection> autoDiscoveredWorkflowImplementationClasses) { for (Class clazz : autoDiscoveredWorkflowImplementationClasses) { - WorkflowImpl annotation = clazz.getAnnotation(WorkflowImpl.class); + WorkflowImpl annotation = AnnotationUtils.findAnnotation(clazz, WorkflowImpl.class); for (String taskQueue : annotation.taskQueues()) { taskQueue = environment.resolvePlaceholders(taskQueue); Worker worker = workerFactory.tryGetWorker(taskQueue); @@ -303,7 +303,7 @@ private void configureNexusServiceBeansByTaskQueue( private void configureWorkflowImplementationsByWorkerName( Workers workers, Collection> autoDiscoveredWorkflowImplementationClasses) { for (Class clazz : autoDiscoveredWorkflowImplementationClasses) { - WorkflowImpl annotation = clazz.getAnnotation(WorkflowImpl.class); + WorkflowImpl annotation = AnnotationUtils.findAnnotation(clazz, WorkflowImpl.class); for (String workerName : annotation.workers()) { Worker worker = workers.getByName(workerName); diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoDiscoveryComposedAnnotationTest.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoDiscoveryComposedAnnotationTest.java new file mode 100644 index 0000000000..224ba77770 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/AutoDiscoveryComposedAnnotationTest.java @@ -0,0 +1,57 @@ +package io.temporal.spring.boot.autoconfigure; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.spring.boot.autoconfigure.composedannotation.ComposedAnnotatedActivityImpl; +import io.temporal.spring.boot.autoconfigure.composedannotation.ComposedAnnotatedWorkflow; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(classes = AutoDiscoveryComposedAnnotationTest.Configuration.class) +@ActiveProfiles(profiles = "auto-discovery-composed-annotation") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class AutoDiscoveryComposedAnnotationTest { + @Autowired ConfigurableApplicationContext applicationContext; + + @Autowired WorkflowClient workflowClient; + + @BeforeEach + void setUp() { + applicationContext.start(); + } + + @Test + @Timeout(value = 10) + public void testAutoDiscoveryViaComposedAnnotation() { + ComposedAnnotatedWorkflow workflow = + workflowClient.newWorkflowStub( + ComposedAnnotatedWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue("UnitTest").build()); + Assertions.assertEquals("composed:composed-activity:hi", workflow.execute("hi")); + } + + @ComponentScan( + excludeFilters = + @ComponentScan.Filter( + pattern = + "io\\.temporal\\.spring\\.boot\\.autoconfigure\\.(bytaskqueue|byworkername)\\..*", + type = FilterType.REGEX)) + public static class Configuration { + + // Not using @Component so that it stays scoped to this test + @Bean + public ComposedAnnotatedActivityImpl composedAnnotatedActivityImpl() { + return new ComposedAnnotatedActivityImpl(); + } + } +} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedActivityImpl.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedActivityImpl.java new file mode 100644 index 0000000000..0f8cc40008 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedActivityImpl.java @@ -0,0 +1,12 @@ +package io.temporal.spring.boot.autoconfigure.composedannotation; + +import io.temporal.spring.boot.ActivityImpl; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@ActivityImpl(taskQueues = "UnitTest") +public @interface ComposedActivityImpl {} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivity.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivity.java new file mode 100644 index 0000000000..0c0e88cdf3 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivity.java @@ -0,0 +1,8 @@ +package io.temporal.spring.boot.autoconfigure.composedannotation; + +import io.temporal.activity.ActivityInterface; + +@ActivityInterface +public interface ComposedAnnotatedActivity { + String execute(String input); +} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivityImpl.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivityImpl.java new file mode 100644 index 0000000000..790f8ce110 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedActivityImpl.java @@ -0,0 +1,10 @@ +package io.temporal.spring.boot.autoconfigure.composedannotation; + +@ComposedActivityImpl +public class ComposedAnnotatedActivityImpl implements ComposedAnnotatedActivity { + + @Override + public String execute(String input) { + return "composed-activity:" + input; + } +} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflow.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflow.java new file mode 100644 index 0000000000..aad215878e --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflow.java @@ -0,0 +1,11 @@ +package io.temporal.spring.boot.autoconfigure.composedannotation; + +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; + +@WorkflowInterface +public interface ComposedAnnotatedWorkflow { + + @WorkflowMethod + String execute(String input); +} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflowImpl.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflowImpl.java new file mode 100644 index 0000000000..61f4d656d6 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedAnnotatedWorkflowImpl.java @@ -0,0 +1,20 @@ +package io.temporal.spring.boot.autoconfigure.composedannotation; + +import io.temporal.activity.ActivityOptions; +import io.temporal.workflow.Workflow; +import java.time.Duration; + +@ComposedWorkflowImpl +public class ComposedAnnotatedWorkflowImpl implements ComposedAnnotatedWorkflow { + + @Override + public String execute(String input) { + ComposedAnnotatedActivity activity = + Workflow.newActivityStub( + ComposedAnnotatedActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .validateAndBuildWithDefaults()); + return "composed:" + activity.execute(input); + } +} diff --git a/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedWorkflowImpl.java b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedWorkflowImpl.java new file mode 100644 index 0000000000..77b97c4540 --- /dev/null +++ b/temporal-spring-boot-autoconfigure/src/test/java/io/temporal/spring/boot/autoconfigure/composedannotation/ComposedWorkflowImpl.java @@ -0,0 +1,12 @@ +package io.temporal.spring.boot.autoconfigure.composedannotation; + +import io.temporal.spring.boot.WorkflowImpl; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +@WorkflowImpl(taskQueues = "UnitTest") +public @interface ComposedWorkflowImpl {} diff --git a/temporal-spring-boot-autoconfigure/src/test/resources/application.yml b/temporal-spring-boot-autoconfigure/src/test/resources/application.yml index d33d50b46d..afc8654c92 100644 --- a/temporal-spring-boot-autoconfigure/src/test/resources/application.yml +++ b/temporal-spring-boot-autoconfigure/src/test/resources/application.yml @@ -109,6 +109,18 @@ spring: register-activity-beans: true register-nexus-service-beans: true +--- +spring: + config: + activate: + on-profile: auto-discovery-composed-annotation + temporal: + workers-auto-discovery: + enabled: true + workflow-packages: + - io.temporal.spring.boot.autoconfigure.composedannotation + register-activity-beans: true + --- spring: config: From 144ddea10f5942b55177487e2f6fbe3cc60528fd Mon Sep 17 00:00:00 2001 From: sdk-sentinel-bot Date: Wed, 26 Aug 2026 22:02:57 -0400 Subject: [PATCH 073/107] Stabilize sticky cache metrics test (#3037) --- .../io/temporal/workflow/MetricsTest.java | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java index 5b4b9df97a..2a02d05557 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/MetricsTest.java @@ -489,7 +489,7 @@ public void testStickyCacheSize() throws InterruptedException, ExecutionExceptio setUp(WorkerFactoryOptions.getDefaultInstance()); Worker worker = testEnvironment.newWorker(TASK_QUEUE); - worker.registerWorkflowImplementationTypes(TestWorkflowWithSleep.class); + worker.registerWorkflowImplementationTypes(TestWorkflowWithSignal.class); testEnvironment.start(); Thread.sleep(REPORTING_FLUSH_TIME); @@ -501,7 +501,8 @@ public void testStickyCacheSize() throws InterruptedException, ExecutionExceptio .setWorkflowRunTimeout(Duration.ofSeconds(7000)) .setTaskQueue(TASK_QUEUE) .build(); - NoArgsWorkflow workflow = workflowClient.newWorkflowStub(NoArgsWorkflow.class, options); + CacheMetricsWorkflow workflow = + workflowClient.newWorkflowStub(CacheMetricsWorkflow.class, options); CompletableFuture wfFuture = WorkflowClient.execute(workflow::execute); SDKTestWorkflowRule.waitForOKQuery(WorkflowStub.fromTyped(workflow)); @@ -509,6 +510,7 @@ public void testStickyCacheSize() throws InterruptedException, ExecutionExceptio reporter.assertGauge(STICKY_CACHE_SIZE, TAGS_NAMESPACE, 1); reporter.assertGauge(WORKFLOW_ACTIVE_THREAD_COUNT, TAGS_NAMESPACE, val -> val == 1 || val == 2); + workflow.complete(); wfFuture.get(); Thread.sleep(REPORTING_FLUSH_TIME); @@ -646,11 +648,26 @@ public String execute() { } } - public static class TestWorkflowWithSleep implements NoArgsWorkflow { + @WorkflowInterface + public interface CacheMetricsWorkflow { + @WorkflowMethod + void execute(); + + @SignalMethod + void complete(); + } + + public static class TestWorkflowWithSignal implements CacheMetricsWorkflow { + private boolean complete; @Override public void execute() { - Workflow.sleep(5000); + Workflow.await(() -> complete); + } + + @Override + public void complete() { + complete = true; } } From b7dd8c8c64d6c4498ce16bcb0f4bf38b191b0d22 Mon Sep 17 00:00:00 2001 From: Daniel Bak Date: Wed, 26 Aug 2026 22:03:46 -0400 Subject: [PATCH 074/107] Include WorkflowType in wrapFailure error message (#2960) The "Failure processing workflow task" RuntimeException only included WorkflowId, RunId, and Attempt, forcing anyone debugging a failure to look up the workflow type separately. PollWorkflowTaskQueueResponse already carries the workflow type, so include it directly. --- .../main/java/io/temporal/internal/worker/WorkflowWorker.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index 3eed1099d3..98660034d4 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -611,6 +611,8 @@ public Throwable wrapFailure(WorkflowTask task, Throwable failure) { + execution.getWorkflowId() + ", RunId=" + execution.getRunId() + + ", WorkflowType=" + + task.getResponse().getWorkflowType().getName() + ", Attempt=" + task.getResponse().getAttempt(), failure); From 394d8eec66347fd38f7d48efbc832bdfcb379347 Mon Sep 17 00:00:00 2001 From: Sam Agarwal <44370096+samarth70@users.noreply.github.com> Date: Wed, 26 Aug 2026 23:03:19 -0400 Subject: [PATCH 075/107] Fix setRetryOptions merging initialInterval into congestionInitialInterval (#2956) RpcRetryOptions.Builder.setRetryOptions merged every field from its own counterpart except congestionInitialInterval, which read o.getInitialInterval(). The source's congestionInitialInterval was therefore never read, and the value was overwritten by the regular initial interval. congestionInitialInterval is the backoff applied to RESOURCE_EXHAUSTED (used by GrpcSyncRetryer/GrpcAsyncRetryer via BackoffThrottler) and defaults to 10x the regular interval (1000ms vs 100ms). Merging collapsed that margin, so a client would retry an already-congested server far faster than intended - the opposite of what the setting exists for. Read o.getCongestionInitialInterval() and add a regression test asserting a user-set congestion interval survives setRetryOptions. --- .../serviceclient/RpcRetryOptions.java | 3 +- .../serviceclient/RpcRetryOptionsTest.java | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 temporal-serviceclient/src/test/java/io/temporal/serviceclient/RpcRetryOptionsTest.java diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/RpcRetryOptions.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/RpcRetryOptions.java index a105098f43..bddcca5a14 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/RpcRetryOptions.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/RpcRetryOptions.java @@ -272,7 +272,8 @@ public Builder setRetryOptions(RpcRetryOptions o) { setInitialInterval( OptionsUtils.merge(initialInterval, o.getInitialInterval(), Duration.class)); setCongestionInitialInterval( - OptionsUtils.merge(congestionInitialInterval, o.getInitialInterval(), Duration.class)); + OptionsUtils.merge( + congestionInitialInterval, o.getCongestionInitialInterval(), Duration.class)); setExpiration(OptionsUtils.merge(expiration, o.getExpiration(), Duration.class)); setMaximumInterval( OptionsUtils.merge(maximumInterval, o.getMaximumInterval(), Duration.class)); diff --git a/temporal-serviceclient/src/test/java/io/temporal/serviceclient/RpcRetryOptionsTest.java b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/RpcRetryOptionsTest.java new file mode 100644 index 0000000000..8b66a2ded3 --- /dev/null +++ b/temporal-serviceclient/src/test/java/io/temporal/serviceclient/RpcRetryOptionsTest.java @@ -0,0 +1,30 @@ +package io.temporal.serviceclient; + +import static org.junit.Assert.assertEquals; + +import java.time.Duration; +import org.junit.Test; + +public class RpcRetryOptionsTest { + + /** + * congestionInitialInterval is the backoff used for RESOURCE_EXHAUSTED and is deliberately set + * much higher than initialInterval. Builder.setRetryOptions must merge it from the source's + * congestionInitialInterval, not from its initialInterval, otherwise that margin is silently + * collapsed. + */ + @Test + public void setRetryOptionsMergesCongestionInitialInterval() { + RpcRetryOptions source = + RpcRetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(200)) + .setCongestionInitialInterval(Duration.ofSeconds(7)) + .validateBuildWithDefaults(); + + RpcRetryOptions merged = + RpcRetryOptions.newBuilder().setRetryOptions(source).validateBuildWithDefaults(); + + assertEquals(Duration.ofMillis(200), merged.getInitialInterval()); + assertEquals(Duration.ofSeconds(7), merged.getCongestionInitialInterval()); + } +} From c5f93b78a8e124c8bbcd45c589bb0f179c0c5228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Makl=C3=A9r?= Date: Thu, 27 Aug 2026 05:21:28 +0200 Subject: [PATCH 076/107] Fix inconsistency in javadoc for WorkflowInterface (#2232) (#2233) --- .../main/java/io/temporal/workflow/WorkflowInterface.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowInterface.java b/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowInterface.java index c70b460099..01f7f137b5 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowInterface.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowInterface.java @@ -64,7 +64,7 @@ * String d(); * } * - * public class CImpl implements C { + * public class DImpl implements D { * public void a() {} * public void aa() {} * public void b() {} @@ -73,7 +73,7 @@ * } * * - * When CImpl instance is registered with the {@link io.temporal.worker.Worker} the + * When DImpl instance is registered with the {@link io.temporal.worker.Worker} the * following is registered: * *

    @@ -85,7 +85,7 @@ *
* * The client code can call signals through stubs to B, C and D - * interfaces. A call to crate a stub to A interface will fail as A + * interfaces. A call to create a stub to A interface will fail as A * is not annotated with the WorkflowInterface. */ @Retention(RetentionPolicy.RUNTIME) From 6d5ba591bb405da453870c85767599777ade0241 Mon Sep 17 00:00:00 2001 From: Oleksandr Porunov Date: Thu, 27 Aug 2026 04:40:03 +0100 Subject: [PATCH 077/107] feat(#2790): Add ChildWorkflowOptions support to WorkflowImplementationOptions (#2887) Fixes #2790 Allow predefining ChildWorkflowOptions on WorkflowImplementationOptions, mirroring the existing ActivityOptions, LocalActivityOptions and NexusServiceOptions support: - Add setChildWorkflowOptions(Map) and setDefaultChildWorkflowOptions() builder methods, matching getters, and equals/hashCode/toString/ toBuilder support on WorkflowImplementationOptions - Expose the options through SyncWorkflowContext, served directly from the immutable WorkflowImplementationOptions - Add ChildWorkflowOptions.Builder#mergeChildWorkflowOptions(): non-null override fields win, except contextPropagators lists are concatenated (matching ActivityOptions.Builder#mergeActivityOptions), the mutually exclusive searchAttributes/typedSearchAttributes are merged as one logical field so the merged options never carry both flavors, and VERSIONING_INTENT_UNSPECIFIED is treated as unset - Resolve the predefined options for both typed and untyped child stubs (WorkflowInternal.newChildWorkflowStub and newUntypedChildWorkflowStub, so DynamicWorkflow parents are covered too) with the following precedence, highest to lowest, merged field by field: options passed to the stub creation method > per-type options (setChildWorkflowOptions) > default options (setDefaultChildWorkflowOptions) - Keep child stub creation cost unchanged: the interface metadata is computed once and passed into ChildWorkflowInvocationHandler instead of being reflectively re-derived - Keep the previous public WorkflowImplementationOptions constructor as a backward-compatible delegating overload - Document the new behavior on the Workflow child stub factory methods and warn against predefining workflowId, which would be applied to every child of the matching scope Fix child workflow options precedence: the predefined options were merged in the wrong order, so when no options were passed to newChildWorkflowStub the default options overrode the per-type options. The default options have the lowest precedence and must never override per-type options. The merge order is now correct, and the javadocs on setChildWorkflowOptions and setDefaultChildWorkflowOptions describe the actual precedence. Tests: - Verify the applied options through the child's memo in DefaultChildWorkflowOptionsSetOnWorkflowTest (covering default, per-type, explicit and field-level merge precedence for both typed and untyped stubs), so a test only passes if the expected options actually took effect - Add an exhaustive unit test for ChildWorkflowOptions#mergeChildWorkflowOptions that exercises every field, plus dedicated tests for the search attribute flavors, context propagator concatenation and unspecified versioning intent Signed-off-by: Oleksandr Porunov --- .../sync/ChildWorkflowInvocationHandler.java | 7 +- .../internal/sync/SyncWorkflowContext.java | 12 + .../internal/sync/WorkflowInternal.java | 39 +- .../worker/WorkflowImplementationOptions.java | 100 ++++++ .../workflow/ChildWorkflowOptions.java | 84 +++++ .../java/io/temporal/workflow/Workflow.java | 26 +- ...nsInWorkflowImplementationOptionsTest.java | 339 ++++++++++++++++++ ...ChildWorkflowOptionsSetOnWorkflowTest.java | 199 ++++++++++ 8 files changed, 795 insertions(+), 11 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java index 365ff4b87d..7268859259 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/ChildWorkflowInvocationHandler.java @@ -22,16 +22,17 @@ class ChildWorkflowInvocationHandler implements InvocationHandler { private final POJOWorkflowInterfaceMetadata workflowMetadata; ChildWorkflowInvocationHandler( - Class workflowInterface, + POJOWorkflowInterfaceMetadata workflowMetadata, ChildWorkflowOptions options, WorkflowOutboundCallsInterceptor outboundCallsInterceptor, Functions.Proc1 assertReadOnly) { - workflowMetadata = POJOWorkflowInterfaceMetadata.newInstance(workflowInterface); + this.workflowMetadata = workflowMetadata; Optional workflowMethodMetadata = workflowMetadata.getWorkflowMethod(); if (!workflowMethodMetadata.isPresent()) { throw new IllegalArgumentException( - "Missing method annotated with @WorkflowMethod: " + workflowInterface.getName()); + "Missing method annotated with @WorkflowMethod: " + + workflowMetadata.getInterfaceClass().getName()); } Method workflowMethod = workflowMethodMetadata.get().getWorkflowMethod(); MethodRetry retryAnnotation = workflowMethod.getAnnotation(MethodRetry.class); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index cdf4818bb9..065ce71428 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -215,6 +215,18 @@ public NexusServiceOptions getDefaultNexusServiceOptions() { : Collections.emptyMap(); } + /** + * Unlike the activity options above, the child workflow options have no runtime mutators, so they + * are served directly from the immutable {@link WorkflowImplementationOptions}. + */ + public ChildWorkflowOptions getDefaultChildWorkflowOptions() { + return workflowImplementationOptions.getDefaultChildWorkflowOptions(); + } + + public @Nonnull Map getChildWorkflowOptions() { + return workflowImplementationOptions.getChildWorkflowOptions(); + } + public void setDefaultActivityOptions(ActivityOptions defaultActivityOptions) { this.defaultActivityOptions = (this.defaultActivityOptions == null) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java index 3ea979e8db..84b1e91fd3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java @@ -391,17 +391,48 @@ public static ActivityStub newUntypedLocalActivityStub(LocalActivityOptions opti @SuppressWarnings("unchecked") public static T newChildWorkflowStub( Class workflowInterface, ChildWorkflowOptions options) { + SyncWorkflowContext context = getRootWorkflowContext(); + POJOWorkflowInterfaceMetadata workflowMetadata = + POJOWorkflowInterfaceMetadata.newInstance(workflowInterface); + // The workflow type is absent for interfaces that contain only signal and query methods; + // ChildWorkflowInvocationHandler rejects such interfaces. + String workflowType = workflowMetadata.getWorkflowType().orElse(null); + options = mergePredefinedChildWorkflowOptions(context, workflowType, options); return (T) Proxy.newProxyInstance( workflowInterface.getClassLoader(), new Class[] {workflowInterface, StubMarker.class, AsyncMarker.class}, new ChildWorkflowInvocationHandler( - workflowInterface, + workflowMetadata, options, - getWorkflowOutboundInterceptor(), + context.getWorkflowOutboundInterceptor(), WorkflowInternal::assertNotReadOnly)); } + /** + * Merges the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)} into the options passed to the stub creation method. Precedence from + * lowest to highest: default options < per-type options < the passed options. Each layer + * overrides only the non-null fields of the layers below it. + */ + private static ChildWorkflowOptions mergePredefinedChildWorkflowOptions( + SyncWorkflowContext context, + @Nullable String workflowType, + @Nullable ChildWorkflowOptions options) { + ChildWorkflowOptions defaultOptions = context.getDefaultChildWorkflowOptions(); + ChildWorkflowOptions perTypeOptions = + workflowType != null ? context.getChildWorkflowOptions().get(workflowType) : null; + if (defaultOptions == null && perTypeOptions == null) { + return options; + } + return ChildWorkflowOptions.newBuilder(defaultOptions) + .mergeChildWorkflowOptions(perTypeOptions) + .mergeChildWorkflowOptions(options) + .build(); + } + @SuppressWarnings("unchecked") public static T newExternalWorkflowStub( Class workflowInterface, WorkflowExecution execution) { @@ -434,10 +465,12 @@ public static Promise getWorkflowExecution(Object workflowStu public static ChildWorkflowStub newUntypedChildWorkflowStub( String workflowType, ChildWorkflowOptions options) { + SyncWorkflowContext context = getRootWorkflowContext(); + options = mergePredefinedChildWorkflowOptions(context, workflowType, options); return new ChildWorkflowStubImpl( workflowType, options, - getWorkflowOutboundInterceptor(), + context.getWorkflowOutboundInterceptor(), WorkflowInternal::assertNotReadOnly); } diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java index c2d0aa033c..f0c39f8572 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkflowImplementationOptions.java @@ -3,6 +3,7 @@ import io.temporal.activity.ActivityOptions; import io.temporal.activity.LocalActivityOptions; import io.temporal.common.Experimental; +import io.temporal.workflow.ChildWorkflowOptions; import io.temporal.workflow.NexusServiceOptions; import io.temporal.workflow.Workflow; import java.util.*; @@ -42,6 +43,8 @@ public static final class Builder { private LocalActivityOptions defaultLocalActivityOptions; private Map nexusServiceOptions; private NexusServiceOptions defaultNexusServiceOptions; + private Map childWorkflowOptions; + private ChildWorkflowOptions defaultChildWorkflowOptions; private boolean enableUpsertVersionSearchAttributes; private Builder() {} @@ -57,6 +60,8 @@ private Builder(WorkflowImplementationOptions options) { this.defaultLocalActivityOptions = options.getDefaultLocalActivityOptions(); this.nexusServiceOptions = options.getNexusServiceOptions(); this.defaultNexusServiceOptions = options.getDefaultNexusServiceOptions(); + this.childWorkflowOptions = options.getChildWorkflowOptions(); + this.defaultChildWorkflowOptions = options.getDefaultChildWorkflowOptions(); this.enableUpsertVersionSearchAttributes = options.isEnableUpsertVersionSearchAttributes(); } @@ -158,6 +163,49 @@ public Builder setDefaultNexusServiceOptions(NexusServiceOptions defaultNexusSer return this; } + /** + * Set individual child workflow options per workflow type. They apply to child workflow stubs + * created through both {@link io.temporal.workflow.Workflow#newChildWorkflowStub(Class, + * ChildWorkflowOptions)} and {@link + * io.temporal.workflow.Workflow#newUntypedChildWorkflowStub(String, ChildWorkflowOptions)}. + * These options take precedence over the default options set through {@link + * #setDefaultChildWorkflowOptions(ChildWorkflowOptions)}, but each field is still overridden by + * the corresponding non-null field of the options passed to the stub creation method, which + * have the highest precedence. + * + *

Avoid setting {@link ChildWorkflowOptions.Builder#setWorkflowId(String)} here: the id + * would be applied to every child workflow of the type, so starting more than one such child + * fails with a duplicate workflow id error. + * + * @param childWorkflowOptions map from workflow type to ChildWorkflowOptions + */ + public Builder setChildWorkflowOptions(Map childWorkflowOptions) { + this.childWorkflowOptions = new HashMap<>(Objects.requireNonNull(childWorkflowOptions)); + return this; + } + + /** + * These child workflow options have the lowest precedence across all child workflow options. + * They apply to child workflow stubs created through both {@link + * io.temporal.workflow.Workflow#newChildWorkflowStub(Class, ChildWorkflowOptions)} and {@link + * io.temporal.workflow.Workflow#newUntypedChildWorkflowStub(String, ChildWorkflowOptions)}. + * Each field is overridden by the corresponding non-null field of the per-type options set + * through {@link #setChildWorkflowOptions(Map)}, and then by the options passed to the stub + * creation method, which have the highest precedence. + * + *

Avoid setting {@link ChildWorkflowOptions.Builder#setWorkflowId(String)} here: the id + * would be applied to every child workflow started with these options, so starting more than + * one such child fails with a duplicate workflow id error. + * + * @param defaultChildWorkflowOptions ChildWorkflowOptions for all child workflows in the + * workflow. + */ + public Builder setDefaultChildWorkflowOptions( + ChildWorkflowOptions defaultChildWorkflowOptions) { + this.defaultChildWorkflowOptions = Objects.requireNonNull(defaultChildWorkflowOptions); + return this; + } + /** * Enable upserting version search attributes on {@link Workflow#getVersion}. This will cause * the SDK to automatically add the TemporalChangeVersion search attributes to the @@ -187,6 +235,8 @@ public WorkflowImplementationOptions build() { defaultLocalActivityOptions, nexusServiceOptions == null ? null : nexusServiceOptions, defaultNexusServiceOptions, + childWorkflowOptions, + defaultChildWorkflowOptions, enableUpsertVersionSearchAttributes); } } @@ -198,8 +248,14 @@ public WorkflowImplementationOptions build() { private final LocalActivityOptions defaultLocalActivityOptions; private final @Nullable Map nexusServiceOptions; private final NexusServiceOptions defaultNexusServiceOptions; + private final @Nullable Map childWorkflowOptions; + private final ChildWorkflowOptions defaultChildWorkflowOptions; private final boolean enableUpsertVersionSearchAttributes; + /** + * Retained for backward compatibility with code compiled against SDK versions without child + * workflow options support. Prefer {@link #newBuilder()}. + */ public WorkflowImplementationOptions( Class[] failWorkflowExceptionTypes, @Nullable Map activityOptions, @@ -209,6 +265,30 @@ public WorkflowImplementationOptions( @Nullable Map nexusServiceOptions, NexusServiceOptions defaultNexusServiceOptions, boolean enableUpsertVersionSearchAttributes) { + this( + failWorkflowExceptionTypes, + activityOptions, + defaultActivityOptions, + localActivityOptions, + defaultLocalActivityOptions, + nexusServiceOptions, + defaultNexusServiceOptions, + null, + null, + enableUpsertVersionSearchAttributes); + } + + public WorkflowImplementationOptions( + Class[] failWorkflowExceptionTypes, + @Nullable Map activityOptions, + ActivityOptions defaultActivityOptions, + @Nullable Map localActivityOptions, + LocalActivityOptions defaultLocalActivityOptions, + @Nullable Map nexusServiceOptions, + NexusServiceOptions defaultNexusServiceOptions, + @Nullable Map childWorkflowOptions, + ChildWorkflowOptions defaultChildWorkflowOptions, + boolean enableUpsertVersionSearchAttributes) { this.failWorkflowExceptionTypes = failWorkflowExceptionTypes; this.activityOptions = activityOptions; this.defaultActivityOptions = defaultActivityOptions; @@ -216,6 +296,8 @@ public WorkflowImplementationOptions( this.defaultLocalActivityOptions = defaultLocalActivityOptions; this.nexusServiceOptions = nexusServiceOptions; this.defaultNexusServiceOptions = defaultNexusServiceOptions; + this.childWorkflowOptions = childWorkflowOptions; + this.defaultChildWorkflowOptions = defaultChildWorkflowOptions; this.enableUpsertVersionSearchAttributes = enableUpsertVersionSearchAttributes; } @@ -253,6 +335,16 @@ public NexusServiceOptions getDefaultNexusServiceOptions() { return defaultNexusServiceOptions; } + public @Nonnull Map getChildWorkflowOptions() { + return childWorkflowOptions != null + ? Collections.unmodifiableMap(childWorkflowOptions) + : Collections.emptyMap(); + } + + public ChildWorkflowOptions getDefaultChildWorkflowOptions() { + return defaultChildWorkflowOptions; + } + @Experimental public boolean isEnableUpsertVersionSearchAttributes() { return enableUpsertVersionSearchAttributes; @@ -275,6 +367,10 @@ public String toString() { + nexusServiceOptions + ", defaultNexusServiceOptions=" + defaultNexusServiceOptions + + ", childWorkflowOptions=" + + childWorkflowOptions + + ", defaultChildWorkflowOptions=" + + defaultChildWorkflowOptions + ", enableUpsertVersionSearchAttributes=" + enableUpsertVersionSearchAttributes + '}'; @@ -292,6 +388,8 @@ public boolean equals(Object o) { && Objects.equals(defaultLocalActivityOptions, that.defaultLocalActivityOptions) && Objects.equals(nexusServiceOptions, that.nexusServiceOptions) && Objects.equals(defaultNexusServiceOptions, that.defaultNexusServiceOptions) + && Objects.equals(childWorkflowOptions, that.childWorkflowOptions) + && Objects.equals(defaultChildWorkflowOptions, that.defaultChildWorkflowOptions) && Objects.equals( enableUpsertVersionSearchAttributes, that.enableUpsertVersionSearchAttributes); } @@ -306,6 +404,8 @@ public int hashCode() { defaultLocalActivityOptions, nexusServiceOptions, defaultNexusServiceOptions, + childWorkflowOptions, + defaultChildWorkflowOptions, enableUpsertVersionSearchAttributes); result = 31 * result + Arrays.hashCode(failWorkflowExceptionTypes); return result; diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java index 543c76b57e..fdcb14a64b 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/ChildWorkflowOptions.java @@ -12,6 +12,7 @@ import io.temporal.failure.TimeoutFailure; import io.temporal.internal.common.OptionsUtils; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -336,6 +337,89 @@ public Builder setPriority(Priority priority) { return this; } + /** + * Merges the provided override options into this builder. Any non-null fields in the override + * will take precedence over the fields in this builder, with the following exceptions: + * + *

    + *
  • {@code contextPropagators} lists are concatenated instead of replaced, matching {@link + * io.temporal.activity.ActivityOptions.Builder#mergeActivityOptions( + * io.temporal.activity.ActivityOptions)}. + *
  • The mutually exclusive {@code searchAttributes} and {@code typedSearchAttributes} are + * merged as a single logical field: an override that specifies either flavor replaces + * both, so the merged options never carry both flavors at once. + *
  • A {@code versioningIntent} of {@link VersioningIntent#VERSIONING_INTENT_UNSPECIFIED} is + * treated as unset. + *
+ * + * @param override ChildWorkflowOptions that overrides the current builder values. + * @return this builder. + */ + @SuppressWarnings("deprecation") + public Builder mergeChildWorkflowOptions(ChildWorkflowOptions override) { + if (override == null) { + return this; + } + this.namespace = (override.getNamespace() == null) ? this.namespace : override.getNamespace(); + this.workflowId = + (override.getWorkflowId() == null) ? this.workflowId : override.getWorkflowId(); + this.workflowIdReusePolicy = + (override.getWorkflowIdReusePolicy() == null) + ? this.workflowIdReusePolicy + : override.getWorkflowIdReusePolicy(); + this.workflowRunTimeout = + (override.getWorkflowRunTimeout() == null) + ? this.workflowRunTimeout + : override.getWorkflowRunTimeout(); + this.workflowExecutionTimeout = + (override.getWorkflowExecutionTimeout() == null) + ? this.workflowExecutionTimeout + : override.getWorkflowExecutionTimeout(); + this.workflowTaskTimeout = + (override.getWorkflowTaskTimeout() == null) + ? this.workflowTaskTimeout + : override.getWorkflowTaskTimeout(); + this.taskQueue = (override.getTaskQueue() == null) ? this.taskQueue : override.getTaskQueue(); + this.retryOptions = + (override.getRetryOptions() == null) ? this.retryOptions : override.getRetryOptions(); + this.cronSchedule = + (override.getCronSchedule() == null) ? this.cronSchedule : override.getCronSchedule(); + this.parentClosePolicy = + (override.getParentClosePolicy() == null) + ? this.parentClosePolicy + : override.getParentClosePolicy(); + this.memo = (override.getMemo() == null) ? this.memo : override.getMemo(); + // searchAttributes and typedSearchAttributes are mutually exclusive (the setters reject + // mixing them), so they are merged as one logical field: an override specifying either + // flavor replaces both, otherwise the merged options could carry both flavors and fail at + // child-scheduling time. + if (override.getSearchAttributes() != null || override.getTypedSearchAttributes() != null) { + this.searchAttributes = override.getSearchAttributes(); + this.typedSearchAttributes = override.getTypedSearchAttributes(); + } + if (this.contextPropagators == null) { + this.contextPropagators = override.getContextPropagators(); + } else if (override.getContextPropagators() != null) { + List mergedPropagators = new ArrayList<>(this.contextPropagators); + mergedPropagators.addAll(override.getContextPropagators()); + this.contextPropagators = mergedPropagators; + } + this.cancellationType = + (override.getCancellationType() == null) + ? this.cancellationType + : override.getCancellationType(); + if (override.getVersioningIntent() != null + && override.getVersioningIntent() != VersioningIntent.VERSIONING_INTENT_UNSPECIFIED) { + this.versioningIntent = override.getVersioningIntent(); + } + this.staticSummary = + (override.getStaticSummary() == null) ? this.staticSummary : override.getStaticSummary(); + this.staticDetails = + (override.getStaticDetails() == null) ? this.staticDetails : override.getStaticDetails(); + this.priority = (override.getPriority() == null) ? this.priority : override.getPriority(); + return this; + } + public ChildWorkflowOptions build() { return new ChildWorkflowOptions( namespace, diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java index 2cea96c08a..d04a617d5d 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java @@ -152,8 +152,11 @@ public static ActivityStub newUntypedLocalActivityStub(LocalActivityOptions opti /** * Creates client stub that can be used to start a child workflow that implements the given - * interface using parent options. Use {@link #newExternalWorkflowStub(Class, String)} to get a - * stub to signal a workflow without starting it. + * interface using parent options. Child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)} are applied. Use {@link #newExternalWorkflowStub(Class, String)} to get + * a stub to signal a workflow without starting it. * * @param workflowInterface interface type implemented by activities */ @@ -167,7 +170,12 @@ public static T newChildWorkflowStub(Class workflowInterface) { * starting it. * * @param workflowInterface interface type implemented by activities - * @param options options passed to the child workflow. + * @param options options passed to the child workflow. Each non-null field overrides the + * corresponding field of the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)}. */ public static T newChildWorkflowStub( Class workflowInterface, ChildWorkflowOptions options) { @@ -211,7 +219,12 @@ public static Promise getWorkflowExecution(Object workflowStu * Creates untyped client stub that can be used to start and signal a child workflow. * * @param workflowType name of the workflow type to start. - * @param options options passed to the child workflow. + * @param options options passed to the child workflow. Each non-null field overrides the + * corresponding field of the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)}. */ public static ChildWorkflowStub newUntypedChildWorkflowStub( String workflowType, ChildWorkflowOptions options) { @@ -220,7 +233,10 @@ public static ChildWorkflowStub newUntypedChildWorkflowStub( /** * Creates untyped client stub that can be used to start and signal a child workflow. All options - * are inherited from the parent. + * are inherited from the parent, except for the child workflow options predefined through {@link + * io.temporal.worker.WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)} and + * {@link io.temporal.worker.WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions( + * ChildWorkflowOptions)}, which are applied. * * @param workflowType name of the workflow type to start. */ diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java new file mode 100644 index 0000000000..09728b87f9 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/ChildWorkflowOptionsInWorkflowImplementationOptionsTest.java @@ -0,0 +1,339 @@ +package io.temporal.workflow; + +import static org.junit.Assert.*; + +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.ParentClosePolicy; +import io.temporal.api.enums.v1.WorkflowIdReusePolicy; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import io.temporal.common.SearchAttributeKey; +import io.temporal.common.SearchAttributes; +import io.temporal.common.context.ContextPropagator; +import io.temporal.worker.WorkflowImplementationOptions; +import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import org.junit.Test; + +public class ChildWorkflowOptionsInWorkflowImplementationOptionsTest { + + @Test + public void testBuilderSetAndGet() { + ChildWorkflowOptions defaultOpts = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .setTaskQueue("default-queue") + .build(); + + ChildWorkflowOptions perTypeOpts = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(200)) + .setTaskQueue("per-type-queue") + .build(); + + Map optionsMap = + Collections.singletonMap("MyWorkflow", perTypeOpts); + + WorkflowImplementationOptions options = + WorkflowImplementationOptions.newBuilder() + .setDefaultChildWorkflowOptions(defaultOpts) + .setChildWorkflowOptions(optionsMap) + .build(); + + assertEquals(defaultOpts, options.getDefaultChildWorkflowOptions()); + assertEquals(1, options.getChildWorkflowOptions().size()); + assertEquals(perTypeOpts, options.getChildWorkflowOptions().get("MyWorkflow")); + } + + @Test + public void testDefaultInstanceHasEmptyChildWorkflowOptions() { + WorkflowImplementationOptions options = WorkflowImplementationOptions.getDefaultInstance(); + assertNull(options.getDefaultChildWorkflowOptions()); + assertNotNull(options.getChildWorkflowOptions()); + assertTrue(options.getChildWorkflowOptions().isEmpty()); + } + + @Test + public void testToBuilder() { + ChildWorkflowOptions defaultOpts = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .build(); + + Map optionsMap = + Collections.singletonMap("MyWorkflow", defaultOpts); + + WorkflowImplementationOptions original = + WorkflowImplementationOptions.newBuilder() + .setDefaultChildWorkflowOptions(defaultOpts) + .setChildWorkflowOptions(optionsMap) + .build(); + + WorkflowImplementationOptions copy = original.toBuilder().build(); + assertEquals(original.getDefaultChildWorkflowOptions(), copy.getDefaultChildWorkflowOptions()); + assertEquals(original.getChildWorkflowOptions(), copy.getChildWorkflowOptions()); + } + + @Test + public void testMergeChildWorkflowOptionsOverridesNonNull() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .setTaskQueue("base-queue") + .setWorkflowRunTimeout(Duration.ofSeconds(50)) + .build(); + + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(200)) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + + // The override takes precedence for workflowExecutionTimeout. + assertEquals(Duration.ofSeconds(200), merged.getWorkflowExecutionTimeout()); + // Base values are preserved for fields not set in the override. + assertEquals("base-queue", merged.getTaskQueue()); + assertEquals(Duration.ofSeconds(50), merged.getWorkflowRunTimeout()); + } + + @Test + public void testMergeChildWorkflowOptionsWithNull() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setWorkflowExecutionTimeout(Duration.ofSeconds(100)) + .setTaskQueue("base-queue") + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(null).build(); + + assertEquals(Duration.ofSeconds(100), merged.getWorkflowExecutionTimeout()); + assertEquals("base-queue", merged.getTaskQueue()); + } + + /** + * Exhaustively verifies the merge for every field. Both options have every field set to distinct + * values, so the merge result can only match the expectation if each field is merged from the + * correct getter, and {@code merge(base, empty)} can only equal {@code base} if no field is + * dropped. The only field the override does not replace is {@code contextPropagators}, which is + * concatenated. + */ + @Test + public void testMergeChildWorkflowOptionsOverridesEveryField() { + ContextPropagator propagatorA = new TestContextPropagator("A"); + ContextPropagator propagatorB = new TestContextPropagator("B"); + ChildWorkflowOptions optionsA = allFieldsSet(1, propagatorA); + ChildWorkflowOptions optionsB = allFieldsSet(2, propagatorB); + + // A fully populated override replaces every field of the base, except the context propagator + // lists, which are concatenated. + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(optionsA).mergeChildWorkflowOptions(optionsB).build(); + ChildWorkflowOptions expected = + optionsB.toBuilder().setContextPropagators(Arrays.asList(propagatorA, propagatorB)).build(); + assertEquals(expected, merged); + + // An override that sets no fields leaves every field of the base untouched. + ChildWorkflowOptions mergedWithEmpty = + ChildWorkflowOptions.newBuilder(optionsA) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertEquals(optionsA, mergedWithEmpty); + } + + /** + * The two search attribute flavors are mutually exclusive, so an override that specifies either + * flavor must replace both fields; otherwise the merge could produce options carrying both + * flavors, which fail at child-scheduling time. + */ + @Test + @SuppressWarnings("deprecation") + public void testMergeChildWorkflowOptionsReplacesSearchAttributeFlavorsAsOneField() { + Map legacyAttributes = Collections.singletonMap("Field", "legacy"); + ChildWorkflowOptions deprecatedFlavor = + ChildWorkflowOptions.newBuilder().setSearchAttributes(legacyAttributes).build(); + SearchAttributes typedAttributes = + SearchAttributes.newBuilder() + .set(SearchAttributeKey.forText("CustomTextField"), "typed") + .build(); + ChildWorkflowOptions typedFlavor = + ChildWorkflowOptions.newBuilder().setTypedSearchAttributes(typedAttributes).build(); + + // A typed override replaces a deprecated base. + ChildWorkflowOptions typedWins = + ChildWorkflowOptions.newBuilder(deprecatedFlavor) + .mergeChildWorkflowOptions(typedFlavor) + .build(); + assertNull(typedWins.getSearchAttributes()); + assertEquals(typedAttributes, typedWins.getTypedSearchAttributes()); + + // A deprecated override replaces a typed base. + ChildWorkflowOptions deprecatedWins = + ChildWorkflowOptions.newBuilder(typedFlavor) + .mergeChildWorkflowOptions(deprecatedFlavor) + .build(); + assertEquals(legacyAttributes, deprecatedWins.getSearchAttributes()); + assertNull(deprecatedWins.getTypedSearchAttributes()); + + // An override that specifies neither flavor keeps the base flavor. + ChildWorkflowOptions baseKept = + ChildWorkflowOptions.newBuilder(typedFlavor) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertNull(baseKept.getSearchAttributes()); + assertEquals(typedAttributes, baseKept.getTypedSearchAttributes()); + } + + /** + * {@code VERSIONING_INTENT_UNSPECIFIED} means "not set" and must not override a specific + * versioning intent, matching {@code ActivityOptions.Builder#mergeActivityOptions}. + */ + @Test + @SuppressWarnings("deprecation") + public void testMergeChildWorkflowOptionsIgnoresUnspecifiedVersioningIntent() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setVersioningIntent(io.temporal.common.VersioningIntent.VERSIONING_INTENT_COMPATIBLE) + .build(); + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setVersioningIntent(io.temporal.common.VersioningIntent.VERSIONING_INTENT_UNSPECIFIED) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + assertEquals( + io.temporal.common.VersioningIntent.VERSIONING_INTENT_COMPATIBLE, + merged.getVersioningIntent()); + } + + /** + * Context propagator lists are concatenated instead of replaced, matching {@code + * ActivityOptions.Builder#mergeActivityOptions}. + */ + @Test + public void testMergeChildWorkflowOptionsConcatenatesContextPropagators() { + ContextPropagator propagatorA = new TestContextPropagator("A"); + ContextPropagator propagatorB = new TestContextPropagator("B"); + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setContextPropagators(Collections.singletonList(propagatorA)) + .build(); + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setContextPropagators(Collections.singletonList(propagatorB)) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + assertEquals(Arrays.asList(propagatorA, propagatorB), merged.getContextPropagators()); + + ChildWorkflowOptions mergedWithEmptyOverride = + ChildWorkflowOptions.newBuilder(base) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertEquals( + Collections.singletonList(propagatorA), mergedWithEmptyOverride.getContextPropagators()); + } + + /** The deprecated {@code searchAttributes} field is mutually exclusive with the typed variant. */ + @Test + @SuppressWarnings("deprecation") + public void testMergeChildWorkflowOptionsMergesDeprecatedSearchAttributes() { + ChildWorkflowOptions base = + ChildWorkflowOptions.newBuilder() + .setSearchAttributes(Collections.singletonMap("Field", "base")) + .build(); + ChildWorkflowOptions override = + ChildWorkflowOptions.newBuilder() + .setSearchAttributes(Collections.singletonMap("Field", "override")) + .build(); + + ChildWorkflowOptions merged = + ChildWorkflowOptions.newBuilder(base).mergeChildWorkflowOptions(override).build(); + assertEquals(Collections.singletonMap("Field", "override"), merged.getSearchAttributes()); + + ChildWorkflowOptions mergedKeepsBase = + ChildWorkflowOptions.newBuilder(base) + .mergeChildWorkflowOptions(ChildWorkflowOptions.newBuilder().build()) + .build(); + assertEquals(Collections.singletonMap("Field", "base"), mergedKeepsBase.getSearchAttributes()); + } + + /** + * Builds a {@link ChildWorkflowOptions} with every field set to a value derived from {@code v}. + */ + @SuppressWarnings("deprecation") + private static ChildWorkflowOptions allFieldsSet(int v, ContextPropagator propagator) { + return ChildWorkflowOptions.newBuilder() + .setNamespace("namespace-" + v) + .setWorkflowId("workflow-id-" + v) + .setWorkflowIdReusePolicy( + v == 1 + ? WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_ALLOW_DUPLICATE + : WorkflowIdReusePolicy.WORKFLOW_ID_REUSE_POLICY_REJECT_DUPLICATE) + .setWorkflowRunTimeout(Duration.ofSeconds(10 + v)) + .setWorkflowExecutionTimeout(Duration.ofSeconds(20 + v)) + .setWorkflowTaskTimeout(Duration.ofSeconds(30 + v)) + .setTaskQueue("task-queue-" + v) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(v).build()) + .setCronSchedule(v + " 0 * * *") + .setParentClosePolicy( + v == 1 + ? ParentClosePolicy.PARENT_CLOSE_POLICY_ABANDON + : ParentClosePolicy.PARENT_CLOSE_POLICY_TERMINATE) + .setMemo(Collections.singletonMap("memoKey", "memo-" + v)) + .setTypedSearchAttributes( + SearchAttributes.newBuilder() + .set(SearchAttributeKey.forText("CustomTextField"), "search-attribute-" + v) + .build()) + .setContextPropagators(Collections.singletonList(propagator)) + .setCancellationType( + v == 1 + ? ChildWorkflowCancellationType.TRY_CANCEL + : ChildWorkflowCancellationType.WAIT_CANCELLATION_COMPLETED) + .setVersioningIntent( + v == 1 + ? io.temporal.common.VersioningIntent.VERSIONING_INTENT_COMPATIBLE + : io.temporal.common.VersioningIntent.VERSIONING_INTENT_DEFAULT) + .setStaticSummary("summary-" + v) + .setStaticDetails("details-" + v) + .setPriority(Priority.newBuilder().setPriorityKey(v).build()) + .build(); + } + + private static class TestContextPropagator implements ContextPropagator { + private final String name; + + TestContextPropagator(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public Map serializeContext(Object context) { + return Collections.emptyMap(); + } + + @Override + public Object deserializeContext(Map context) { + return null; + } + + @Override + public Object getCurrentContext() { + return null; + } + + @Override + public void setCurrentContext(Object context) {} + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java new file mode 100644 index 0000000000..a1a66a5cec --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/DefaultChildWorkflowOptionsSetOnWorkflowTest.java @@ -0,0 +1,199 @@ +package io.temporal.workflow; + +import static org.junit.Assert.assertEquals; + +import io.temporal.client.WorkflowOptions; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkflowImplementationOptions; +import java.time.Duration; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies that the {@link ChildWorkflowOptions} configured on {@link + * WorkflowImplementationOptions} are actually applied to child workflows created through both typed + * and untyped stubs, and that the precedence between the per-type options ({@link + * WorkflowImplementationOptions.Builder#setChildWorkflowOptions(Map)}), the default options ({@link + * WorkflowImplementationOptions.Builder#setDefaultChildWorkflowOptions}) and the options passed to + * the stub creation method is correct. + * + *

Each scenario is verified by reading the child's memo from inside the child workflow, so a + * test only passes if the expected options object was the one that actually took effect. + */ +public class DefaultChildWorkflowOptionsSetOnWorkflowTest { + + private static final String MEMO_KEY = "optionsSource"; + private static final Duration DEFAULT_RUN_TIMEOUT = Duration.ofSeconds(30); + + /** Lowest precedence options applied to every child workflow. */ + private static final ChildWorkflowOptions defaultChildWorkflowOptions = + ChildWorkflowOptions.newBuilder() + .setWorkflowRunTimeout(DEFAULT_RUN_TIMEOUT) + .setMemo(Collections.singletonMap(MEMO_KEY, "default")) + .build(); + + /** Per-type options applied only to the {@link PerTypeChild} workflow type. */ + private static final ChildWorkflowOptions perTypeChildWorkflowOptions = + ChildWorkflowOptions.newBuilder() + .setMemo(Collections.singletonMap(MEMO_KEY, "perType")) + .build(); + + /** Highest precedence options, passed explicitly to the stub creation methods. */ + private static final ChildWorkflowOptions explicitChildWorkflowOptions = + ChildWorkflowOptions.newBuilder() + .setMemo(Collections.singletonMap(MEMO_KEY, "explicit")) + .build(); + + private static final Map childWorkflowOptionsMap = + Collections.singletonMap("PerTypeChild", perTypeChildWorkflowOptions); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + WorkflowImplementationOptions.newBuilder() + .setDefaultChildWorkflowOptions(defaultChildWorkflowOptions) + .setChildWorkflowOptions(childWorkflowOptionsMap) + .build(), + ParentWorkflowImpl.class, + PerTypeChildImpl.class, + DefaultChildImpl.class) + .build(); + + /** + * Verifies the predefined options applied when no explicit options are passed to the stub + * creation method: the per-type options must win over the default options, the default options + * must be applied to types without a per-type entry, and fields the per-type options do not set + * must fall back to the default options. Untyped stubs must behave the same as typed stubs. + */ + @Test + public void testPredefinedOptionsApplied() { + Map report = runParent(); + + // Typed stubs. + assertEquals("perType", report.get("perType.memo")); + assertEquals("default", report.get("default.memo")); + // The per-type options only set the memo; other fields fall back to the default options. + assertEquals(DEFAULT_RUN_TIMEOUT.toString(), report.get("perType.runTimeout")); + + // Untyped stubs. + assertEquals("perType", report.get("untypedPerType.memo")); + assertEquals("default", report.get("untypedDefault.memo")); + } + + /** + * Verifies that the options passed to the stub creation method have the highest precedence over + * both the per-type options and the default options, while fields they do not set still fall back + * through the per-type options to the default options. + */ + @Test + public void testExplicitOptionsTakePrecedence() { + Map report = runParent(); + + assertEquals("explicit", report.get("explicitOverDefault.memo")); + assertEquals("explicit", report.get("explicitOverPerType.memo")); + assertEquals("explicit", report.get("untypedExplicit.memo")); + // The explicit options only set the memo; other fields fall back to the default options. + assertEquals(DEFAULT_RUN_TIMEOUT.toString(), report.get("explicitOverPerType.runTimeout")); + } + + private Map runParent() { + ParentWorkflow parent = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + ParentWorkflow.class, + WorkflowOptions.newBuilder().setTaskQueue(testWorkflowRule.getTaskQueue()).build()); + return parent.execute(); + } + + @WorkflowInterface + public interface ParentWorkflow { + @WorkflowMethod + Map execute(); + } + + @WorkflowInterface + public interface PerTypeChild { + @WorkflowMethod + Map execute(); + } + + @WorkflowInterface + public interface DefaultChild { + @WorkflowMethod + Map execute(); + } + + public static class ParentWorkflowImpl implements ParentWorkflow { + @Override + @SuppressWarnings("unchecked") + public Map execute() { + Map report = new HashMap<>(); + + // No explicit options: PerTypeChild has per-type options, which must win over the default. + PerTypeChild perTypeChild = Workflow.newChildWorkflowStub(PerTypeChild.class); + prefix(report, "perType", perTypeChild.execute()); + + // No explicit options and no per-type entry: the default options must be applied. + DefaultChild defaultChild = Workflow.newChildWorkflowStub(DefaultChild.class); + prefix(report, "default", defaultChild.execute()); + + // Explicit options on a type without per-type options: explicit must win over the default. + DefaultChild explicitOverDefault = + Workflow.newChildWorkflowStub(DefaultChild.class, explicitChildWorkflowOptions); + prefix(report, "explicitOverDefault", explicitOverDefault.execute()); + + // Explicit options on a type that also has per-type options: explicit must win over both. + PerTypeChild explicitOverPerType = + Workflow.newChildWorkflowStub(PerTypeChild.class, explicitChildWorkflowOptions); + prefix(report, "explicitOverPerType", explicitOverPerType.execute()); + + // Untyped stubs must apply the predefined options the same way as typed stubs. + ChildWorkflowStub untypedPerType = Workflow.newUntypedChildWorkflowStub("PerTypeChild"); + prefix(report, "untypedPerType", untypedPerType.execute(Map.class)); + + ChildWorkflowStub untypedDefault = Workflow.newUntypedChildWorkflowStub("DefaultChild"); + prefix(report, "untypedDefault", untypedDefault.execute(Map.class)); + + ChildWorkflowStub untypedExplicit = + Workflow.newUntypedChildWorkflowStub("PerTypeChild", explicitChildWorkflowOptions); + prefix(report, "untypedExplicit", untypedExplicit.execute(Map.class)); + + return report; + } + + private static void prefix( + Map target, String prefix, Map childReport) { + for (Map.Entry entry : childReport.entrySet()) { + target.put(prefix + "." + entry.getKey(), entry.getValue()); + } + } + } + + public static class PerTypeChildImpl implements PerTypeChild { + @Override + public Map execute() { + return reportAppliedOptions(); + } + } + + public static class DefaultChildImpl implements DefaultChild { + @Override + public Map execute() { + return reportAppliedOptions(); + } + } + + /** Reports the options that were actually applied to the running (child) workflow. */ + private static Map reportAppliedOptions() { + Map report = new HashMap<>(); + Object memo = Workflow.getMemo(MEMO_KEY, String.class); + report.put("memo", memo == null ? "none" : memo.toString()); + report.put("runTimeout", String.valueOf(Workflow.getInfo().getWorkflowRunTimeout())); + return report; + } +} From d730cff553b264f104eaa1f40c0ecd8ce2df775d Mon Sep 17 00:00:00 2001 From: Sean Bollin Date: Thu, 27 Aug 2026 14:41:36 -0700 Subject: [PATCH 078/107] Add GCP Cloud Run OpenTelemetry support (#3022) Adds a Cloud Run serverless-worker OpenTelemetry integration, mirroring the approved .NET SDK common-core refactor (temporalio/sdk-dotnet#844) and building on #2955. - New module temporal-gcp-cloud-run (io.temporal.gcp.cloudrun): a CloudRunOpenTelemetryPlugin that exports Core metrics and tracing spans over OTLP/gRPC to a local OpenTelemetry Collector sidecar. Cloud Run specifics only (service name from CLOUD_RUN_WORKER_POOL/K_SERVICE, 60s report interval, deferred shutdown flush); depends only on temporal-opentelemetry, no Google client libraries. - Extract a shared OpenTelemetryWorker.resolveServiceName(env, default, fallbackEnvVars...) into the provider-neutral core and add OpenTelemetryPlugin.Builder.getMetricsReportInterval() (both additive). - Refactor the AWS Lambda OtelLambdaWorkerConfigurationHelper onto the shared resolver with no public API or behavior change (existing tests unmodified). Co-authored-by: Edward Amsden Co-authored-by: Claude Opus 4.8 --- .../OtelLambdaWorkerConfigurationHelper.java | 16 +- contrib/temporal-gcp-cloud-run/README.md | 99 +++++++++ contrib/temporal-gcp-cloud-run/build.gradle | 17 ++ .../cloudrun/CloudRunOpenTelemetryPlugin.java | 196 ++++++++++++++++++ .../CloudRunOpenTelemetryPluginTest.java | 123 +++++++++++ .../opentelemetry/OpenTelemetryPlugin.java | 4 + .../opentelemetry/OpenTelemetryWorker.java | 31 ++- settings.gradle | 2 + temporal-bom/build.gradle | 1 + 9 files changed, 474 insertions(+), 15 deletions(-) create mode 100644 contrib/temporal-gcp-cloud-run/README.md create mode 100644 contrib/temporal-gcp-cloud-run/build.gradle create mode 100644 contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java create mode 100644 contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java diff --git a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java index 235352b7ac..d087b1db49 100644 --- a/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java +++ b/contrib/temporal-aws-lambda/src/main/java/io/temporal/aws/lambda/OtelLambdaWorkerConfigurationHelper.java @@ -125,12 +125,8 @@ public static void configureFlushHook( } static String resolveServiceName(Map env) { - String serviceName = nonEmptyEnv(env, OTEL_SERVICE_NAME); - if (serviceName != null) { - return serviceName; - } - serviceName = nonEmptyEnv(env, AWS_LAMBDA_FUNCTION_NAME); - return serviceName == null ? DEFAULT_SERVICE_NAME : serviceName; + return OpenTelemetryWorker.resolveServiceName( + env, DEFAULT_SERVICE_NAME, AWS_LAMBDA_FUNCTION_NAME); } public static final class Builder { @@ -258,14 +254,6 @@ OpenTelemetry create( IdGenerator idGenerator); } - private static String nonEmptyEnv(Map env, String name) { - if (env == null) { - return null; - } - String value = env.get(name); - return value == null || value.trim().isEmpty() ? null : value; - } - private static void appendServiceStubsPlugin( WorkflowServiceStubsOptions.Builder options, WorkflowServiceStubsPlugin plugin) { WorkflowServiceStubsPlugin[] existing = options.build().getPlugins(); diff --git a/contrib/temporal-gcp-cloud-run/README.md b/contrib/temporal-gcp-cloud-run/README.md new file mode 100644 index 0000000000..01a6c15184 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run/README.md @@ -0,0 +1,99 @@ +# Temporal Google Cloud Run module + +This module provides an OpenTelemetry plugin with defaults for Temporal Java SDK workers running on Google Cloud Run. Cloud Run worker pools are the recommended deployment because Temporal workers are continuous, pull-based background workloads. + +> **Collector required by default:** The plugin exports metrics and traces to an OTLP collector at `http://localhost:4317`. It does not export directly to Google Cloud. Deploy the Google-Built OpenTelemetry Collector as a sidecar, configure another collector endpoint, or provide an application-owned `OpenTelemetry` instance. Without a collector at the configured endpoint, telemetry is not delivered to Google Cloud. + +This integration is for container-based Cloud Run workloads. It does not implement a Cloud Run functions invocation lifecycle. + +A Cloud Run service can also host a Temporal worker, but it must use instance-based billing so CPU is available outside request handling, keep at least one instance active through minimum instances or manual scaling, and run an ingress container that listens on `PORT`. These are deployment requirements; the plugin cannot configure them from inside the worker process. + +## Usage + +Add `temporal-gcp-cloud-run` next to your Temporal SDK dependency, then install the plugin on service stubs options before creating clients and workers: + +```java +CloudRunOpenTelemetryPlugin plugin = CloudRunOpenTelemetryPlugin.newBuilder().build(); + +WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setPlugins(plugin) + .build()); +WorkflowClient client = WorkflowClient.newInstance(service); +WorkerFactory factory = WorkerFactory.newInstance(client); +``` + +The plugin configures the SDK metrics scope, tracing interceptors, and OTLP metric and trace exporters through `temporal-opentelemetry`. Do not install both `CloudRunOpenTelemetryPlugin` and `OpenTelemetryPlugin` on the same service stubs. + +## Shutdown lifecycle + +`WorkerFactory.shutdown()` initiates asynchronous shutdown. The Cloud Run plugin therefore does not flush from the worker-factory shutdown callback by default: doing so could miss telemetry emitted while activities and workflows finish. Wait for the factory to terminate, then flush with the time remaining before Cloud Run sends `SIGKILL`. For example, the following JVM shutdown hook reserves six seconds for graceful shutdown, one second for forced shutdown, and two seconds for telemetry flushing within Cloud Run's ten-second termination window: + +```java +Runtime.getRuntime() + .addShutdownHook( + new Thread( + () -> { + factory.shutdown(); + factory.awaitTermination(6, TimeUnit.SECONDS); + if (!factory.isTerminated()) { + factory.shutdownNow(); + factory.awaitTermination(1, TimeUnit.SECONDS); + } + plugin.newFlushHook().run(Duration.ofSeconds(2)); + })); +``` + +Applications with an existing lifecycle manager should perform the same sequence there instead of registering another JVM hook. `Builder.setFlushOnWorkerFactoryShutdown(true)` restores the underlying plugin's immediate, best-effort flush, but it should only be used when no work can emit telemetry after the shutdown request. + +The OTLP endpoint is resolved in this order: + +1. `Builder.setEndpoint(...)`. +2. `OTEL_EXPORTER_OTLP_ENDPOINT`. +3. `http://localhost:4317`. + +When the plugin creates the OpenTelemetry SDK, metrics are reported and exported every sixty seconds by default. This matches the coordinated GCP plugin default across Temporal SDKs and exceeds Google Cloud's five-second minimum export interval. If you use `Builder.setMetricsReportInterval(...)`, keep the interval above that minimum. The collector also needs the unbatched metrics pipeline described below to make a forced shutdown flush safe regardless of its timing relative to the last periodic export. + +With an application-owned `OpenTelemetry` instance, the setting only controls how often the Temporal metrics scope reports into that instance. Configure the instance's metric reader to export at an interval above the Google Cloud minimum as well. + +The OpenTelemetry service name is resolved in this order: + +1. `Builder.setServiceName(...)`. +2. `OTEL_SERVICE_NAME`. +3. `CLOUD_RUN_WORKER_POOL` for a Cloud Run worker pool. +4. `K_SERVICE` for a Cloud Run service. +5. `temporal-worker`. + +The collector should use its GCP resource detector to add the Google Cloud attributes it recognizes. Do not rely on the detector to infer Cloud Run worker-pool-specific location or revision attributes; configure those explicitly with a collector resource processor if they are required. This module does not call the Google Cloud metadata server and adds no Google Cloud client libraries or exporters to the worker process. + +## Collector sidecar + +Google publishes the Google-Built OpenTelemetry Collector as a container image. Configure it as a second Cloud Run container, listen for OTLP gRPC on `localhost:4317`, and use its GCP exporters for metrics and traces. For the image, recommended collector configuration, IAM roles, health check, and Secret Manager mount, see [Deploy Google-Built OpenTelemetry Collector on Cloud Run](https://cloud.google.com/stackdriver/docs/instrumentation/opentelemetry-collector-cloud-run). That guide demonstrates a Cloud Run service; adapt its collector container and configuration when deploying a worker pool. + +Do not put a batch processor in the `googlemanagedprometheus` metrics pipeline. A periodic cumulative metric export followed closely by a forced shutdown flush can otherwise put two points for the same time series in one request, which Managed Service for Prometheus rejects. Pass metrics through the memory limiter, GCP resource detection, and any collision transforms directly to `googlemanagedprometheus`. Keep a dedicated five-second batch processor on the traces pipeline: + +```yaml +processors: + batch/traces: + send_batch_max_size: 200 + send_batch_size: 200 + timeout: 5s + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [memory_limiter, resourcedetection, transform/collision] + exporters: [googlemanagedprometheus] + traces: + receivers: [otlp] + processors: [memory_limiter, resourcedetection, transform/set_project_id, batch/traces] + exporters: [otlp] +``` + +Cloud Run worker pools support sidecar containers over localhost and are intended for continuous background work. The deployment should start the collector before the Temporal worker and use the collector health extension as its startup probe. + +To use an external collector instead, set `OTEL_EXPORTER_OTLP_ENDPOINT` or call `Builder.setEndpoint(...)`. + +To use an application-owned provider, call `Builder.setOpenTelemetry(...)`. In that path, no exporters are created; the plugin installs the Temporal metrics scope, tracing interceptors, and shutdown flush hook around the supplied provider. diff --git a/contrib/temporal-gcp-cloud-run/build.gradle b/contrib/temporal-gcp-cloud-run/build.gradle new file mode 100644 index 0000000000..35f2e781d4 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run/build.gradle @@ -0,0 +1,17 @@ +description = '''Temporal Java SDK Google Cloud Run Support Module''' + +dependencies { + // This module shouldn't carry temporal-sdk with it, especially for situations when users may + // be using a shaded artifact. + compileOnly project(':temporal-serviceclient') + compileOnly project(':temporal-sdk') + compileOnly "javax.annotation:javax.annotation-api:$annotationApiVersion" + + api project(':temporal-opentelemetry') + + testImplementation project(':temporal-sdk') + testImplementation project(':temporal-serviceclient') + testImplementation "junit:junit:${junitVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java b/contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java new file mode 100644 index 0000000000..a985e66326 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java @@ -0,0 +1,196 @@ +package io.temporal.gcp.cloudrun; + +import io.opentelemetry.api.OpenTelemetry; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.Experimental; +import io.temporal.common.SimplePlugin; +import io.temporal.opentelemetry.OpenTelemetryPlugin; +import io.temporal.opentelemetry.OpenTelemetryWorker; +import io.temporal.opentelemetry.TimedShutdownHook; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactory; +import io.temporal.worker.WorkerFactoryOptions; +import java.time.Duration; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import javax.annotation.Nonnull; + +/** + * OpenTelemetry plugin with defaults for Temporal workers running on Google Cloud Run, primarily in + * worker pools. + */ +@Experimental +public final class CloudRunOpenTelemetryPlugin extends SimplePlugin { + public static final String NAME = OpenTelemetryPlugin.NAME; + public static final String OTEL_EXPORTER_OTLP_ENDPOINT = + OpenTelemetryWorker.OTEL_EXPORTER_OTLP_ENDPOINT; + public static final String OTEL_SERVICE_NAME = OpenTelemetryWorker.OTEL_SERVICE_NAME; + public static final String CLOUD_RUN_WORKER_POOL = "CLOUD_RUN_WORKER_POOL"; + public static final String K_SERVICE = "K_SERVICE"; + public static final String DEFAULT_OTLP_ENDPOINT = OpenTelemetryWorker.DEFAULT_OTLP_ENDPOINT; + public static final String DEFAULT_SERVICE_NAME = OpenTelemetryWorker.DEFAULT_SERVICE_NAME; + public static final Duration DEFAULT_METRICS_REPORT_INTERVAL = Duration.ofSeconds(60); + + private final OpenTelemetryPlugin delegate; + + private CloudRunOpenTelemetryPlugin(Builder builder) { + super(NAME); + this.delegate = builder.buildDelegate(); + } + + public static Builder newBuilder() { + return new Builder(System.getenv()); + } + + public static Builder newBuilder(@Nonnull Map env) { + return new Builder(env); + } + + public String getEndpoint() { + return delegate.getEndpoint(); + } + + public String getServiceName() { + return delegate.getServiceName(); + } + + public OpenTelemetry getOpenTelemetry() { + return delegate.getOpenTelemetry(); + } + + /** + * Creates a flush hook that reports buffered Temporal metrics before force-flushing OpenTelemetry + * providers. + */ + public TimedShutdownHook newFlushHook() { + return delegate.newFlushHook(); + } + + @Override + public void configureServiceStubs(@Nonnull WorkflowServiceStubsOptions.Builder builder) { + delegate.configureServiceStubs(builder); + } + + @Override + public void configureWorkflowClient(@Nonnull WorkflowClientOptions.Builder builder) { + delegate.configureWorkflowClient(builder); + } + + @Override + public void configureWorkerFactory(@Nonnull WorkerFactoryOptions.Builder builder) { + delegate.configureWorkerFactory(builder); + } + + @Override + public void shutdownWorkerFactory( + @Nonnull WorkerFactory factory, @Nonnull Consumer next) { + delegate.shutdownWorkerFactory(factory, next); + } + + /** Builder for {@link CloudRunOpenTelemetryPlugin}. */ + public static final class Builder { + private final Map env; + private final OpenTelemetryPlugin.Builder delegate; + private String serviceName; + + private Builder(Map env) { + this.env = Objects.requireNonNull(env, "env"); + this.delegate = + OpenTelemetryPlugin.newBuilder(env) + .setMetricsReportInterval(DEFAULT_METRICS_REPORT_INTERVAL) + .setFlushOnWorkerFactoryShutdown(false); + } + + /** + * Uses an application-owned OpenTelemetry instance instead of creating an SDK and exporters. + */ + public Builder setOpenTelemetry(@Nonnull OpenTelemetry openTelemetry) { + delegate.setOpenTelemetry(openTelemetry); + return this; + } + + /** Sets the OTLP metric and trace exporter endpoint used by the default SDK setup. */ + public Builder setEndpoint(@Nonnull String endpoint) { + delegate.setEndpoint(endpoint); + return this; + } + + /** Sets the service name used by the default SDK resource and Temporal metrics reporter. */ + public Builder setServiceName(@Nonnull String serviceName) { + this.serviceName = Objects.requireNonNull(serviceName, "serviceName"); + return this; + } + + /** + * Sets the interval used by the Temporal metrics scope and, when the plugin creates the + * OpenTelemetry instance, its periodic metric reader. + * + *

An application-owned OpenTelemetry instance must configure its metric reader separately. + */ + public Builder setMetricsReportInterval(@Nonnull Duration metricsReportInterval) { + delegate.setMetricsReportInterval(metricsReportInterval); + return this; + } + + /** Sets how long the OpenTelemetry flush hook waits for provider flushing. */ + public Builder setFlushTimeout(@Nonnull Duration flushTimeout) { + delegate.setFlushTimeout(flushTimeout); + return this; + } + + /** Overrides the OpenTelemetry provider flush hook. */ + public Builder setFlushHook(@Nonnull Runnable flushHook) { + delegate.setFlushHook(flushHook); + return this; + } + + /** + * Controls whether the plugin flushes immediately after {@link WorkerFactory#shutdown()} or + * {@link WorkerFactory#shutdownNow()} initiates shutdown. + * + *

This is disabled by default because worker-factory shutdown is asynchronous. Cloud Run + * applications should wait for worker termination and then run {@link + * CloudRunOpenTelemetryPlugin#newFlushHook()} with the remaining shutdown time. + */ + public Builder setFlushOnWorkerFactoryShutdown(boolean flushOnWorkerFactoryShutdown) { + delegate.setFlushOnWorkerFactoryShutdown(flushOnWorkerFactoryShutdown); + return this; + } + + public String getEndpoint() { + return delegate.getEndpoint(); + } + + public String getServiceName() { + return serviceName == null ? resolveServiceName(env) : serviceName; + } + + public Duration getMetricsReportInterval() { + return delegate.getMetricsReportInterval(); + } + + public OpenTelemetry createOpenTelemetry() { + applyServiceNameDefault(); + return delegate.createOpenTelemetry(); + } + + public CloudRunOpenTelemetryPlugin build() { + return new CloudRunOpenTelemetryPlugin(this); + } + + private OpenTelemetryPlugin buildDelegate() { + applyServiceNameDefault(); + return delegate.build(); + } + + private void applyServiceNameDefault() { + delegate.setServiceName(getServiceName()); + } + } + + static String resolveServiceName(Map env) { + return OpenTelemetryWorker.resolveServiceName( + env, DEFAULT_SERVICE_NAME, CLOUD_RUN_WORKER_POOL, K_SERVICE); + } +} diff --git a/contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java b/contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java new file mode 100644 index 0000000000..3be4529168 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java @@ -0,0 +1,123 @@ +package io.temporal.gcp.cloudrun; + +import static org.junit.Assert.*; + +import io.opentelemetry.api.OpenTelemetry; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.opentelemetry.OpenTelemetryPlugin; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.WorkerFactoryOptions; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class CloudRunOpenTelemetryPluginTest { + @Test + public void defaultsToLocalCollectorAndGenericServiceName() { + CloudRunOpenTelemetryPlugin.Builder builder = + CloudRunOpenTelemetryPlugin.newBuilder(new HashMap<>()); + + assertEquals("http://localhost:4317", builder.getEndpoint()); + assertEquals("temporal-worker", builder.getServiceName()); + assertEquals(Duration.ofSeconds(60), builder.getMetricsReportInterval()); + } + + @Test + public void resolvesCloudRunServiceNameWithExpectedPrecedence() { + Map env = new HashMap<>(); + env.put(CloudRunOpenTelemetryPlugin.K_SERVICE, "cloud-run-service"); + assertEquals("cloud-run-service", CloudRunOpenTelemetryPlugin.newBuilder(env).getServiceName()); + + env.put(CloudRunOpenTelemetryPlugin.CLOUD_RUN_WORKER_POOL, "worker-pool"); + assertEquals("worker-pool", CloudRunOpenTelemetryPlugin.newBuilder(env).getServiceName()); + + env.put(CloudRunOpenTelemetryPlugin.OTEL_SERVICE_NAME, "otel-service"); + assertEquals("otel-service", CloudRunOpenTelemetryPlugin.newBuilder(env).getServiceName()); + + assertEquals( + "builder-service", + CloudRunOpenTelemetryPlugin.newBuilder(env) + .setServiceName("builder-service") + .getServiceName()); + } + + @Test + public void ignoresEmptyEnvironmentValues() { + Map env = new HashMap<>(); + env.put(CloudRunOpenTelemetryPlugin.OTEL_SERVICE_NAME, " "); + env.put(CloudRunOpenTelemetryPlugin.CLOUD_RUN_WORKER_POOL, ""); + env.put(CloudRunOpenTelemetryPlugin.K_SERVICE, "cloud-run-service"); + + assertEquals("cloud-run-service", CloudRunOpenTelemetryPlugin.newBuilder(env).getServiceName()); + } + + @Test + public void buildAppliesResolvedEndpointAndServiceName() { + Map env = new HashMap<>(); + env.put(CloudRunOpenTelemetryPlugin.OTEL_EXPORTER_OTLP_ENDPOINT, "http://collector:4317"); + env.put(CloudRunOpenTelemetryPlugin.CLOUD_RUN_WORKER_POOL, "worker-pool"); + + CloudRunOpenTelemetryPlugin plugin = + CloudRunOpenTelemetryPlugin.newBuilder(env).setOpenTelemetry(OpenTelemetry.noop()).build(); + + assertEquals("http://collector:4317", plugin.getEndpoint()); + assertEquals("worker-pool", plugin.getServiceName()); + } + + @Test + public void installsMetricsScopeAndTracingInterceptors() { + CloudRunOpenTelemetryPlugin plugin = + CloudRunOpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setOpenTelemetry(OpenTelemetry.noop()) + .build(); + WorkflowServiceStubsOptions.Builder serviceOptions = WorkflowServiceStubsOptions.newBuilder(); + WorkflowClientOptions.Builder clientOptions = WorkflowClientOptions.newBuilder(); + WorkerFactoryOptions.Builder factoryOptions = WorkerFactoryOptions.newBuilder(); + + plugin.configureServiceStubs(serviceOptions); + plugin.configureWorkflowClient(clientOptions); + plugin.configureWorkerFactory(factoryOptions); + + assertEquals(OpenTelemetryPlugin.NAME, plugin.getName()); + assertNotNull(serviceOptions.build().getMetricsScope()); + assertEquals(1, clientOptions.build().getInterceptors().length); + assertEquals(1, factoryOptions.build().getWorkerInterceptors().length); + } + + @Test + public void workerFactoryShutdownDefersFlushByDefault() { + AtomicInteger flushes = new AtomicInteger(); + AtomicInteger shutdowns = new AtomicInteger(); + CloudRunOpenTelemetryPlugin plugin = + CloudRunOpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setOpenTelemetry(OpenTelemetry.noop()) + .setFlushHook(flushes::incrementAndGet) + .build(); + + plugin.shutdownWorkerFactory(null, factory -> shutdowns.incrementAndGet()); + + assertEquals(1, shutdowns.get()); + assertEquals(0, flushes.get()); + + plugin.newFlushHook().run(); + + assertEquals(1, flushes.get()); + } + + @Test + public void workerFactoryShutdownFlushCanBeEnabled() { + AtomicInteger flushes = new AtomicInteger(); + CloudRunOpenTelemetryPlugin plugin = + CloudRunOpenTelemetryPlugin.newBuilder(new HashMap<>()) + .setOpenTelemetry(OpenTelemetry.noop()) + .setFlushHook(flushes::incrementAndGet) + .setFlushOnWorkerFactoryShutdown(true) + .build(); + + plugin.shutdownWorkerFactory(null, factory -> {}); + + assertEquals(1, flushes.get()); + } +} diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java index 2604e205f1..3b1e2f1604 100644 --- a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryPlugin.java @@ -235,6 +235,10 @@ public String getServiceName() { return serviceName == null ? OpenTelemetryWorker.resolveServiceName(env) : serviceName; } + public Duration getMetricsReportInterval() { + return metricsReportInterval; + } + Builder setTelemetryFactory(OpenTelemetryWorker.TelemetryFactory telemetryFactory) { this.telemetryFactory = Objects.requireNonNull(telemetryFactory, "telemetryFactory"); return this; diff --git a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java index 90e7697aac..8d1eabfd15 100644 --- a/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java +++ b/contrib/temporal-opentelemetry/src/main/java/io/temporal/opentelemetry/OpenTelemetryWorker.java @@ -171,8 +171,37 @@ static String resolveEndpoint(Map env) { } static String resolveServiceName(Map env) { + return resolveServiceName(env, DEFAULT_SERVICE_NAME); + } + + /** + * Resolves an OpenTelemetry service name from the environment. + * + *

Resolution prefers {@code OTEL_SERVICE_NAME}, then each of {@code fallbackEnvVars} in order, + * and finally {@code defaultServiceName}. Environment values that are unset, empty, or blank are + * treated as absent. + * + * @param env environment variables to resolve from + * @param defaultServiceName value returned when no environment variable provides a service name + * @param fallbackEnvVars environment variable names consulted, in order, after {@code + * OTEL_SERVICE_NAME} + * @return the resolved service name + */ + public static String resolveServiceName( + Map env, String defaultServiceName, String... fallbackEnvVars) { String serviceName = nonEmptyEnv(env, OTEL_SERVICE_NAME); - return serviceName == null ? DEFAULT_SERVICE_NAME : serviceName; + if (serviceName != null) { + return serviceName; + } + if (fallbackEnvVars != null) { + for (String fallbackEnvVar : fallbackEnvVars) { + serviceName = nonEmptyEnv(env, fallbackEnvVar); + if (serviceName != null) { + return serviceName; + } + } + } + return defaultServiceName; } public static final class Builder { diff --git a/settings.gradle b/settings.gradle index 3699ff1508..6cbf879490 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,6 +15,8 @@ include 'temporal-workflowstreams' project(':temporal-workflowstreams').projectDir = file('contrib/temporal-workflowstreams') include 'temporal-aws-lambda' project(':temporal-aws-lambda').projectDir = file('contrib/temporal-aws-lambda') +include 'temporal-gcp-cloud-run' +project(':temporal-gcp-cloud-run').projectDir = file('contrib/temporal-gcp-cloud-run') include 'temporal-spring-boot-autoconfigure' include 'temporal-spring-boot-starter' include 'temporal-remote-data-encoder' diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index 031633473e..c79c4df44a 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -10,6 +10,7 @@ dependencies { api project(':temporal-opentelemetry') api project(':temporal-opentracing') api project(':temporal-aws-lambda') + api project(':temporal-gcp-cloud-run') api project(':temporal-remote-data-encoder') api project(':temporal-sdk') api project(':temporal-serviceclient') From 6c60097dea3f2e8ce7361522f0781bb5ac48e9bb Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Fri, 28 Aug 2026 13:27:41 -0400 Subject: [PATCH 079/107] External Storage Integration: Lazy resolving references, general refactoring (#3016) * refactor(extstore): general extstore refactoring. rename MessageTransformer to ExternalStorage, create a lazy extstore resolving data converter. * refactor(extstore): move external storage to data converter and rename to match other sdks. * refactor(extstore): stop threading the external storage runner through to workers. we've got the dataconverter already so we can just derive it where its needed. * Update ExternalStorageNotConfiguredException to refer to correct API for configuring external storage. * Add new ExternalStorageUnhandledReferenceException for when a reference payload makes it to fromPayload without being retrieved. This is a guard against SDK features that fail or fail to correctly integrate external storage. Normal misconfiguration errors should be caught at a level above fromPayload. * lint: fix whitespace formatting issue * address PR feedback * refactor(extstore): move external storage from data converter to workflow client options * remove isReference check from data converter. while adding a check to make sure users don't see a misleading error is nice it could also cause some nondeterminism if we fix the error and came with its own set of problems. maybe in the future we can find a better way to catch missing external storage integration in a better way. * remove unused tests and code * small fixes * move ExternalStorageDataConverter to foundation PR. * Update temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java Co-authored-by: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> * address naming feedback and override a few more methods on ExternalStorageDataConverter. * add comment to WorkflowClientOptions warning that external storage is a no-op until we've fully integrated it. * javadoc update * update tests --------- Co-authored-by: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> --- .../client/WorkflowClientInternalImpl.java | 12 + .../client/WorkflowClientOptions.java | 44 +- .../client/WorkflowClientInternal.java | 4 + .../storage/ExternalStorageDataConverter.java | 144 +++++++ .../ExternalStorageMessageTransformer.java | 75 ---- ...ExternalStorageNotConfiguredException.java | 17 + .../ExternalStoragePayloadTransformer.java | 4 +- .../storage/ExternalStorageReferences.java | 9 +- .../storage/ExternalStorageRunner.java | 131 ++++++ .../payload/visitor/MessageVisitor.java | 2 +- .../visitor/PayloadVisitorOptions.java | 2 +- .../internal/worker/SingleWorkerOptions.java | 22 +- ...orageOptions.java => ExternalStorage.java} | 37 +- .../payload/storage/StorageDriver.java | 4 +- .../storage/StorageDriverSelector.java | 2 +- .../main/java/io/temporal/worker/Worker.java | 32 +- ...kflowClientOptionsExternalStorageTest.java | 77 ++++ .../ExternalStorageDataConverterTest.java | 287 +++++++++++++ ...ExternalStorageMessageTransformerTest.java | 161 -------- ...ExternalStoragePayloadTransformerTest.java | 17 +- .../storage/ExternalStorageRunnerTest.java | 383 ++++++++++++++++++ ...ionsTest.java => ExternalStorageTest.java} | 41 +- ...WorkerPollerAutoEnrollEligibilityTest.java | 2 + .../WorkerPollerAutoEnrollStartupTest.java | 2 + .../temporal/worker/WorkerShutdownTest.java | 2 + 25 files changed, 1224 insertions(+), 289 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java delete mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageNotConfiguredException.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java rename temporal-sdk/src/main/java/io/temporal/payload/storage/{ExternalStorageOptions.java => ExternalStorage.java} (71%) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/WorkflowClientOptionsExternalStorageTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java delete mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java rename temporal-sdk/src/test/java/io/temporal/payload/storage/{ExternalStorageOptionsTest.java => ExternalStorageTest.java} (75%) diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java index e856ca4b46..c92d1d8390 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java @@ -22,8 +22,10 @@ import io.temporal.internal.client.external.GenericWorkflowClientImpl; import io.temporal.internal.client.external.ManualActivityCompletionClientFactory; import io.temporal.internal.common.PluginUtils; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.sync.StubMarker; import io.temporal.internal.worker.HeartbeatManager; +import io.temporal.payload.storage.ExternalStorage; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.serviceclient.WorkflowServiceStubsPlugin; @@ -56,6 +58,7 @@ final class WorkflowClientInternalImpl implements WorkflowClient, WorkflowClient private final WorkerFactoryRegistry workerFactoryRegistry = new WorkerFactoryRegistry(); private final String workerGroupingKey = java.util.UUID.randomUUID().toString(); private final @Nullable HeartbeatManager heartbeatManager; + private final @Nullable ExternalStorageRunner externalStorageRunner; /** * Creates client that connects to an instance of the Temporal Service. Cannot be used from within @@ -106,6 +109,9 @@ public static WorkflowClient newInstance( .getOptions() .getMetricsScope() .tagged(MetricsTag.defaultTags(options.getNamespace())); + ExternalStorage externalStorage = options.getExternalStorage(); + this.externalStorageRunner = + externalStorage == null ? null : ExternalStorageRunner.create(externalStorage); this.genericClient = new GenericWorkflowClientImpl(workflowServiceStubs, metricsScope); this.interceptors = options.getInterceptors(); this.workflowClientCallsInvoker = initializeClientInvoker(); @@ -815,6 +821,12 @@ public HeartbeatManager getHeartbeatManager() { return heartbeatManager; } + @Override + @Nullable + public ExternalStorageRunner getExternalStorageRunner() { + return externalStorageRunner; + } + @Override public NexusStartWorkflowResponse startNexus( NexusStartWorkflowRequest request, Functions.Proc workflow) { diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java index e10defba51..e0e6a5a7b6 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java @@ -7,12 +7,14 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.common.interceptors.WorkflowClientInterceptor; +import io.temporal.payload.storage.ExternalStorage; import java.lang.management.ManagementFactory; import java.time.Duration; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; +import javax.annotation.Nullable; /** Options for WorkflowClient configuration. */ public final class WorkflowClientOptions { @@ -52,6 +54,7 @@ public static final class Builder { private QueryRejectCondition queryRejectCondition; private WorkflowClientPlugin[] plugins; private Duration workerHeartbeatInterval; + private ExternalStorage externalStorage; private Builder() {} @@ -68,6 +71,7 @@ private Builder(WorkflowClientOptions options) { queryRejectCondition = options.queryRejectCondition; plugins = options.plugins; workerHeartbeatInterval = options.workerHeartbeatInterval; + externalStorage = options.externalStorage; } public Builder setNamespace(String namespace) { @@ -86,6 +90,19 @@ public Builder setDataConverter(DataConverter dataConverter) { return this; } + /** + * External storage configuration used to store/retrieve large payloads. + * + *

n.b. This is currently a no-op. External storage has not been fully integrated yet. + * + *

Defaults to null. + */ + @Experimental + public Builder setExternalStorage(@Nullable ExternalStorage externalStorage) { + this.externalStorage = externalStorage; + return this; + } + /** * Interceptor used to intercept workflow client calls. * @@ -180,7 +197,8 @@ public WorkflowClientOptions build() { contextPropagators, queryRejectCondition, plugins == null ? EMPTY_PLUGINS : plugins, - resolveHeartbeatInterval(workerHeartbeatInterval)); + resolveHeartbeatInterval(workerHeartbeatInterval), + externalStorage); } /** @@ -207,7 +225,8 @@ public WorkflowClientOptions validateAndBuildWithDefaults() { ? QueryRejectCondition.QUERY_REJECT_CONDITION_UNSPECIFIED : queryRejectCondition, plugins == null ? EMPTY_PLUGINS : plugins, - resolveHeartbeatInterval(workerHeartbeatInterval)); + resolveHeartbeatInterval(workerHeartbeatInterval), + externalStorage); } private static Duration resolveHeartbeatInterval(Duration raw) { @@ -250,6 +269,8 @@ private static Duration resolveHeartbeatInterval(Duration raw) { private final Duration workerHeartbeatInterval; + private final @Nullable ExternalStorage externalStorage; + private WorkflowClientOptions( String namespace, DataConverter dataConverter, @@ -259,7 +280,8 @@ private WorkflowClientOptions( List contextPropagators, QueryRejectCondition queryRejectCondition, WorkflowClientPlugin[] plugins, - Duration workerHeartbeatInterval) { + Duration workerHeartbeatInterval, + @Nullable ExternalStorage externalStorage) { this.namespace = namespace; this.dataConverter = dataConverter; this.interceptors = interceptors; @@ -269,6 +291,7 @@ private WorkflowClientOptions( this.queryRejectCondition = queryRejectCondition; this.plugins = plugins; this.workerHeartbeatInterval = workerHeartbeatInterval; + this.externalStorage = externalStorage; } /** @@ -284,6 +307,13 @@ public DataConverter getDataConverter() { return dataConverter; } + /** External storage used to offload large payloads or null when disabled. */ + @Experimental + @Nullable + public ExternalStorage getExternalStorage() { + return externalStorage; + } + public WorkflowClientInterceptor[] getInterceptors() { return interceptors; } @@ -359,6 +389,8 @@ public String toString() { + Arrays.toString(plugins) + ", workerHeartbeatInterval=" + workerHeartbeatInterval + + ", externalStorage=" + + externalStorage + '}'; } @@ -376,7 +408,8 @@ public boolean equals(Object o) { && queryRejectCondition == that.queryRejectCondition && Arrays.equals(plugins, that.plugins) && com.google.common.base.Objects.equal( - workerHeartbeatInterval, that.workerHeartbeatInterval); + workerHeartbeatInterval, that.workerHeartbeatInterval) + && com.google.common.base.Objects.equal(externalStorage, that.externalStorage); } @Override @@ -390,6 +423,7 @@ public int hashCode() { contextPropagators, queryRejectCondition, Arrays.hashCode(plugins), - workerHeartbeatInterval); + workerHeartbeatInterval, + externalStorage); } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java index fc034a366b..982ae56724 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java @@ -1,6 +1,7 @@ package io.temporal.internal.client; import io.temporal.client.WorkflowClient; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.worker.HeartbeatManager; import io.temporal.worker.WorkerFactory; import io.temporal.workflow.Functions; @@ -25,4 +26,7 @@ public interface WorkflowClientInternal { @Nullable HeartbeatManager getHeartbeatManager(); + + @Nullable + ExternalStorageRunner getExternalStorageRunner(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java new file mode 100644 index 0000000000..4c725752a2 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java @@ -0,0 +1,144 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.failure.v1.Failure; +import io.temporal.common.CancellationToken; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DataConverterException; +import io.temporal.payload.context.SerializationContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.lang.reflect.Type; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A {@link DataConverter} that stores/retrieves payloads to/from external storage. + * + *

This is an internal class that is not exposed to users or workflow code. The intent is to use + * this data converter to consolidate extstore usage within the SDK. + */ +public final class ExternalStorageDataConverter implements DataConverter { + + private final DataConverter delegate; + private final ExternalStorageRunner externalStorage; + private final @Nullable StorageDriverTargetInfo storageTarget; + + public ExternalStorageDataConverter( + @Nonnull DataConverter delegate, @Nonnull ExternalStorageRunner externalStorage) { + this(delegate, externalStorage, null); + } + + private ExternalStorageDataConverter( + @Nonnull DataConverter delegate, + @Nonnull ExternalStorageRunner externalStorage, + @Nullable StorageDriverTargetInfo storageTarget) { + this.delegate = delegate; + this.externalStorage = externalStorage; + this.storageTarget = storageTarget; + } + + public ExternalStorageDataConverter withStorageTarget( + @Nullable StorageDriverTargetInfo storageTarget) { + return new ExternalStorageDataConverter(delegate, externalStorage, storageTarget); + } + + @Override + public Optional toPayload(T value) throws DataConverterException { + Optional converted = delegate.toPayload(value); + if (!converted.isPresent()) { + return converted; + } + Payloads stored = store(Payloads.newBuilder().addPayloads(converted.get()).build()); + return Optional.of(stored.getPayloads(0)); + } + + @Override + public Optional toPayloads(Object... values) throws DataConverterException { + Optional converted = delegate.toPayloads(values); + if (!converted.isPresent()) { + return converted; + } + return Optional.of(store(converted.get())); + } + + @Override + public T fromPayload(Payload payload, Class valueClass, Type valueType) + throws DataConverterException { + return delegate.fromPayload(retrieve(payload), valueClass, valueType); + } + + @Override + public T fromPayloads( + int index, Optional content, Class parameterType, Type genericParameterType) + throws DataConverterException { + if (!content.isPresent() || index >= content.get().getPayloadsCount()) { + return delegate.fromPayloads(index, content, parameterType, genericParameterType); + } + Payload resolved = retrieve(content.get().getPayloads(index)); + return delegate.fromPayload(resolved, parameterType, genericParameterType); + } + + @Override + public Object[] fromPayloads( + Optional content, Class[] parameterTypes, Type[] genericParameterTypes) + throws DataConverterException { + if (!content.isPresent()) { + return delegate.fromPayloads(content, parameterTypes, genericParameterTypes); + } + return delegate.fromPayloads( + Optional.of(retrieveAll(content.get())), parameterTypes, genericParameterTypes); + } + + @Override + @Nonnull + public RuntimeException failureToException(@Nonnull Failure failure) { + return delegate.failureToException(retrieveMessage(failure)); + } + + @Override + @Nonnull + public Failure exceptionToFailure(@Nonnull Throwable throwable) { + return storeMessage(delegate.exceptionToFailure(throwable)); + } + + @Override + @Nonnull + public DataConverter withContext(@Nonnull SerializationContext context) { + return new ExternalStorageDataConverter( + delegate.withContext(context), externalStorage, storageTarget); + } + + private Payloads retrieveAll(Payloads payloads) { + for (Payload payload : payloads.getPayloadsList()) { + if (ExternalStorageReferences.isReference(payload)) { + return retrieveMessage(payloads); + } + } + return payloads; + } + + private Payload retrieve(Payload payload) { + if (!ExternalStorageReferences.isReference(payload)) { + return payload; + } + return retrieveMessage(Payloads.newBuilder().addPayloads(payload).build()).getPayloads(0); + } + + private Payloads store(Payloads payloads) { + Payloads.Builder builder = payloads.toBuilder(); + externalStorage.store(builder, storageTarget, null, CancellationToken.none()); + return builder.build(); + } + + private T retrieveMessage(T message) { + return externalStorage.retrieve(message, CancellationToken.none()); + } + + private Failure storeMessage(Failure failure) { + Failure.Builder builder = failure.toBuilder(); + externalStorage.store(builder, storageTarget, null, CancellationToken.none()); + return builder.build(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java deleted file mode 100644 index 7385f99009..0000000000 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformer.java +++ /dev/null @@ -1,75 +0,0 @@ -package io.temporal.internal.payload.storage; - -import com.google.protobuf.Message; -import io.temporal.common.CancellationToken; -import io.temporal.internal.payload.visitor.PayloadVisitorOptions; -import io.temporal.internal.payload.visitor.PayloadVisitors; -import io.temporal.payload.storage.StorageDriverTargetInfo; -import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; -import javax.annotation.Nullable; - -/** - * Transforms payload lists reachable from a proto message by delegating each visited list to {@link - * ExternalStoragePayloadTransformer}. - * - *

Search attributes stay inline because the server indexes and validates their payload values. - * - *

The {@link Message.Builder} overloads transform in place; the {@link Message} overloads copy - * through a builder and complete with the copy. - */ -final class ExternalStorageMessageTransformer { - private final ExternalStoragePayloadTransformer payloadTransformer; - private final int payloadVisitConcurrency; - - ExternalStorageMessageTransformer( - ExternalStoragePayloadTransformer payloadTransformer, int payloadVisitConcurrency) { - this.payloadTransformer = payloadTransformer; - this.payloadVisitConcurrency = payloadVisitConcurrency; - } - - CompletableFuture store( - T message, - @Nullable StorageDriverTargetInfo target, - CancellationToken cancellationToken) { - return PayloadVisitors.visit(message, storeOptions(target, cancellationToken)); - } - - CompletableFuture store( - Message.Builder builder, - @Nullable StorageDriverTargetInfo target, - CancellationToken cancellationToken) { - return PayloadVisitors.visit(builder, storeOptions(target, cancellationToken)); - } - - CompletableFuture retrieve( - T message, CancellationToken cancellationToken) { - return PayloadVisitors.visit(message, retrieveOptions(cancellationToken)); - } - - CompletableFuture retrieve( - Message.Builder builder, CancellationToken cancellationToken) { - return PayloadVisitors.visit(builder, retrieveOptions(cancellationToken)); - } - - private PayloadVisitorOptions storeOptions( - @Nullable StorageDriverTargetInfo target, - CancellationToken cancellationToken) { - return PayloadVisitorOptions.newBuilder( - (visitedTarget, payloads) -> - payloadTransformer.store(payloads, visitedTarget, cancellationToken)) - .setInitialContext(target) - .setConcurrency(payloadVisitConcurrency) - .setSkipSearchAttributes(true) - .build(); - } - - private PayloadVisitorOptions retrieveOptions( - CancellationToken cancellationToken) { - return PayloadVisitorOptions.newBuilder( - (context, payloads) -> payloadTransformer.retrieve(payloads, cancellationToken)) - .setConcurrency(payloadVisitConcurrency) - .setSkipSearchAttributes(true) - .build(); - } -} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageNotConfiguredException.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageNotConfiguredException.java new file mode 100644 index 0000000000..1f977c81db --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageNotConfiguredException.java @@ -0,0 +1,17 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.common.converter.DataConverterException; + +/** + * Signals that a payload referenced in external storage needs to be retrieved, but external storage + * is not configured. + */ +public final class ExternalStorageNotConfiguredException extends DataConverterException { + public ExternalStorageNotConfiguredException() { + super( + "[TMPRL1105] Encountered a reference to a payload in external storage, but no external " + + "storage is configured to retrieve it. Configure external storage with " + + "WorkflowClientOptions.Builder.setExternalStorage(...) and provide a driver " + + "able to retrieve it."); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java index 6e0d4d770c..ef13075f9e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java @@ -4,7 +4,7 @@ import io.temporal.common.CancellationToken; import io.temporal.internal.common.ListUtils; import io.temporal.internal.concurrent.structured.TaskScope; -import io.temporal.payload.storage.ExternalStorageOptions; +import io.temporal.payload.storage.ExternalStorage; import io.temporal.payload.storage.StorageDriver; import io.temporal.payload.storage.StorageDriverClaim; import io.temporal.payload.storage.StorageDriverRetrieveContext; @@ -30,7 +30,7 @@ final class ExternalStoragePayloadTransformer { private final StorageDriverSelector selector; private final int payloadSizeThreshold; - static ExternalStoragePayloadTransformer fromOptions(ExternalStorageOptions options) { + static ExternalStoragePayloadTransformer fromOptions(ExternalStorage options) { Map driversByName = new LinkedHashMap<>(); for (StorageDriver driver : options.getDrivers()) { driversByName.put(driver.getName(), driver); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java index 3a68c6bb66..8d2695e995 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageReferences.java @@ -64,8 +64,7 @@ static Payload toReferencePayload( * producer that omits it still yields a readable reference. */ static @Nullable ParsedReference tryParseReference(@Nonnull Payload payload) { - if (!hasMetadata(payload, EncodingKeys.METADATA_ENCODING_KEY, ENCODING_PROTOBUF_JSON) - || !hasMetadata(payload, EncodingKeys.METADATA_MESSAGE_TYPE_KEY, REFERENCE_MESSAGE_TYPE)) { + if (!isReference(payload)) { return null; } ExternalStorageReference.Builder builder = ExternalStorageReference.newBuilder(); @@ -79,6 +78,12 @@ static Payload toReferencePayload( reference.getDriverName(), new StorageDriverClaim(reference.getClaimDataMap())); } + /** True if {@code payload} has an external storage reference encoding and message type. */ + static boolean isReference(Payload payload) { + return hasMetadata(payload, EncodingKeys.METADATA_ENCODING_KEY, ENCODING_PROTOBUF_JSON) + && hasMetadata(payload, EncodingKeys.METADATA_MESSAGE_TYPE_KEY, REFERENCE_MESSAGE_TYPE); + } + private static boolean hasMetadata(Payload payload, String key, String expected) { ByteString value = payload.getMetadataMap().get(key); return value != null && expected.equals(value.toStringUtf8()); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java new file mode 100644 index 0000000000..c05f8c5d03 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java @@ -0,0 +1,131 @@ +package io.temporal.internal.payload.storage; + +import com.google.common.base.Throwables; +import com.google.protobuf.Message; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.sdk.v1.ExternalStorageReference; +import io.temporal.common.CancellationToken; +import io.temporal.internal.payload.visitor.MessageVisitor; +import io.temporal.internal.payload.visitor.PayloadVisitorOptions; +import io.temporal.internal.payload.visitor.PayloadVisitors; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import javax.annotation.Nullable; + +/** + * External storage offloads large payloads via {@link StorageDriver}s. It walks messages using + * {@link PayloadVisitors} transforming payloads to and from {@link ExternalStorageReference} using + * {@link ExternalStoragePayloadTransformer}. Use {@link ExternalStorage} via {@link #create} to + * configure external storage. + */ +public final class ExternalStorageRunner { + private final ExternalStoragePayloadTransformer payloadTransformer; + private final int payloadVisitConcurrency; + + public static ExternalStorageRunner create(ExternalStorage options) { + return new ExternalStorageRunner( + ExternalStoragePayloadTransformer.fromOptions(options), + options.getMaxConcurrentPayloadVisits()); + } + + ExternalStorageRunner( + ExternalStoragePayloadTransformer payloadTransformer, int payloadVisitConcurrency) { + this.payloadTransformer = payloadTransformer; + this.payloadVisitConcurrency = payloadVisitConcurrency; + } + + public void store( + Message.Builder builder, + @Nullable StorageDriverTargetInfo target, + @Nullable MessageVisitor targetVisitor, + CancellationToken cancellationToken) { + getOrThrowIfCancelled( + PayloadVisitors.visit(builder, storeOptions(target, targetVisitor, cancellationToken)), + cancellationToken); + } + + public T retrieve( + T message, CancellationToken cancellationToken) { + return getOrThrowIfCancelled(retrieveAsync(message, cancellationToken), cancellationToken); + } + + public CompletableFuture retrieveAsync( + T message, CancellationToken cancellationToken) { + return PayloadVisitors.visit(message, retrieveOptions(cancellationToken)); + } + + /** + * Throws {@link ExternalStorageNotConfiguredException} if {@code message} contains any reference + * payload. Used at inbound task boundaries when external storage is not configured. + */ + public static void throwIfContainsReference(Message message) { + PayloadVisitorOptions options = + PayloadVisitorOptions.newBuilder( + (context, payloads) -> { + for (Payload payload : payloads) { + if (ExternalStorageReferences.isReference(payload)) { + throw new ExternalStorageNotConfiguredException(); + } + } + return CompletableFuture.completedFuture(payloads); + }) + .setSkipSearchAttributes(true) + .build(); + try { + PayloadVisitors.visit(message.toBuilder(), options).join(); + } catch (CompletionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + Throwables.throwIfUnchecked(cause); + throw e; + } + } + + private static T getOrThrowIfCancelled( + CompletableFuture future, CancellationToken cancellationToken) { + CompletableFuture cancellation = cancellationToken.getCancellationFuture(); + try { + CompletableFuture.anyOf(future, cancellation).get(); + return future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + CancellationException cancelled = + new CancellationException("External storage operation interrupted"); + cancelled.initCause(e); + throw cancelled; + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + Throwables.throwIfUnchecked(cause); + throw new CompletionException(cause); + } finally { + cancellation.complete(null); + } + } + + private PayloadVisitorOptions storeOptions( + @Nullable StorageDriverTargetInfo target, + @Nullable MessageVisitor targetVisitor, + CancellationToken cancellationToken) { + return PayloadVisitorOptions.newBuilder( + (visitedTarget, payloads) -> + payloadTransformer.store(payloads, visitedTarget, cancellationToken)) + .setInitialContext(target) + .setMessageVisitor(targetVisitor) + .setConcurrency(payloadVisitConcurrency) + .setSkipSearchAttributes(true) + .build(); + } + + private PayloadVisitorOptions retrieveOptions( + CancellationToken cancellationToken) { + return PayloadVisitorOptions.newBuilder( + (context, payloads) -> payloadTransformer.retrieve(payloads, cancellationToken)) + .setConcurrency(payloadVisitConcurrency) + .setSkipSearchAttributes(true) + .build(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java index 21268e41d7..4bb6083e3e 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/MessageVisitor.java @@ -11,7 +11,7 @@ * @param type of the contextual value */ @FunctionalInterface -interface MessageVisitor { +public interface MessageVisitor { /** * Handles a message being entered and returns the contextual value for it and its contents. * diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java index 4eac39be46..e4d6c89e47 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/visitor/PayloadVisitorOptions.java @@ -69,7 +69,7 @@ private Builder(@Nonnull PayloadVisitor payloadVisitor) { this.payloadVisitor = Objects.requireNonNull(payloadVisitor, "payloadVisitor"); } - Builder setMessageVisitor(@Nullable MessageVisitor messageVisitor) { + public Builder setMessageVisitor(@Nullable MessageVisitor messageVisitor) { this.messageVisitor = messageVisitor; return this; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java index 8e0288566e..a34e55d904 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java @@ -7,10 +7,12 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.common.interceptors.WorkerInterceptor; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.worker.PreferredVersionProvider; import io.temporal.worker.WorkerDeploymentOptions; import java.time.Duration; import java.util.List; +import javax.annotation.Nullable; public final class SingleWorkerOptions { @@ -45,6 +47,7 @@ public static final class Builder { private boolean allowActivityHeartbeatDuringShutdown; private String workerControlTaskQueue; private PreferredVersionProvider preferredVersionProvider; + private @Nullable ExternalStorageRunner externalStorageRunner; private Builder() {} @@ -73,6 +76,7 @@ private Builder(SingleWorkerOptions options) { this.allowActivityHeartbeatDuringShutdown = options.getAllowActivityHeartbeatDuringShutdown(); this.workerControlTaskQueue = options.getWorkerControlTaskQueue(); this.preferredVersionProvider = options.getPreferredVersionProvider(); + this.externalStorageRunner = options.getExternalStorageRunner(); } public Builder setIdentity(String identity) { @@ -185,6 +189,11 @@ public Builder setPreferredVersionProvider(PreferredVersionProvider preferredVer return this; } + public Builder setExternalStorageRunner(@Nullable ExternalStorageRunner externalStorageRunner) { + this.externalStorageRunner = externalStorageRunner; + return this; + } + public SingleWorkerOptions build() { PollerOptions pollerOptions = this.pollerOptions; if (pollerOptions == null) { @@ -227,7 +236,8 @@ public SingleWorkerOptions build() { this.workerInstanceKey, this.allowActivityHeartbeatDuringShutdown, this.workerControlTaskQueue, - this.preferredVersionProvider); + this.preferredVersionProvider, + this.externalStorageRunner); } } @@ -252,6 +262,7 @@ public SingleWorkerOptions build() { private final boolean allowActivityHeartbeatDuringShutdown; private final String workerControlTaskQueue; private final PreferredVersionProvider preferredVersionProvider; + private final @Nullable ExternalStorageRunner externalStorageRunner; private SingleWorkerOptions( String identity, @@ -274,7 +285,8 @@ private SingleWorkerOptions( String workerInstanceKey, boolean allowActivityHeartbeatDuringShutdown, String workerControlTaskQueue, - PreferredVersionProvider preferredVersionProvider) { + PreferredVersionProvider preferredVersionProvider, + @Nullable ExternalStorageRunner externalStorageRunner) { this.identity = identity; this.binaryChecksum = binaryChecksum; this.buildId = buildId; @@ -296,6 +308,7 @@ private SingleWorkerOptions( this.allowActivityHeartbeatDuringShutdown = allowActivityHeartbeatDuringShutdown; this.workerControlTaskQueue = workerControlTaskQueue; this.preferredVersionProvider = preferredVersionProvider; + this.externalStorageRunner = externalStorageRunner; } public String getIdentity() { @@ -393,6 +406,11 @@ public PreferredVersionProvider getPreferredVersionProvider() { return preferredVersionProvider; } + @Nullable + public ExternalStorageRunner getExternalStorageRunner() { + return externalStorageRunner; + } + public WorkerVersioningOptions getWorkerVersioningOptions() { return new WorkerVersioningOptions( this.getBuildId(), this.isUsingBuildIdForVersioning(), this.getDeploymentOptions()); diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java similarity index 71% rename from temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java rename to temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java index 1486fb76b0..854254ef04 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorageOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/ExternalStorage.java @@ -13,8 +13,9 @@ /** Configuration for offloading large payloads to external storage. */ @Experimental -public final class ExternalStorageOptions { +public final class ExternalStorage { static final int DEFAULT_PAYLOAD_SIZE_THRESHOLD = 256 * 1024; + static final int DEFAULT_MAX_CONCURRENT_PAYLOAD_VISITS = 3; public static Builder newBuilder() { return new Builder(); @@ -23,14 +24,17 @@ public static Builder newBuilder() { private final @Nonnull List drivers; private final @Nonnull StorageDriverSelector driverSelector; private final int payloadSizeThreshold; + private final int maxConcurrentPayloadVisits; - private ExternalStorageOptions( + private ExternalStorage( @Nonnull List drivers, @Nonnull StorageDriverSelector driverSelector, - int payloadSizeThreshold) { + int payloadSizeThreshold, + int maxConcurrentPayloadVisits) { this.drivers = Collections.unmodifiableList(new ArrayList<>(drivers)); this.driverSelector = driverSelector; this.payloadSizeThreshold = payloadSizeThreshold; + this.maxConcurrentPayloadVisits = maxConcurrentPayloadVisits; } @Nonnull @@ -51,10 +55,19 @@ public int getPayloadSizeThreshold() { return payloadSizeThreshold; } + /** + * Maximum number of payload lists visited concurrently while offloading or restoring the payloads + * of a single message. Defaults to 3. + */ + public int getMaxConcurrentPayloadVisits() { + return maxConcurrentPayloadVisits; + } + public static final class Builder { private List drivers = Collections.emptyList(); private StorageDriverSelector driverSelector; - private int payloadSizeThreshold = ExternalStorageOptions.DEFAULT_PAYLOAD_SIZE_THRESHOLD; + private int payloadSizeThreshold = ExternalStorage.DEFAULT_PAYLOAD_SIZE_THRESHOLD; + private int maxConcurrentPayloadVisits = ExternalStorage.DEFAULT_MAX_CONCURRENT_PAYLOAD_VISITS; private Builder() {} @@ -84,10 +97,21 @@ public Builder setPayloadSizeThreshold(int payloadSizeThreshold) { return this; } - public ExternalStorageOptions build() { + /** + * Maximum number of payload lists visited concurrently while offloading or restoring the + * payloads of a single message. Must be at least 1. Defaults to 3. + */ + public Builder setMaxConcurrentPayloadVisits(int maxConcurrentPayloadVisits) { + this.maxConcurrentPayloadVisits = maxConcurrentPayloadVisits; + return this; + } + + public ExternalStorage build() { Preconditions.checkState(!drivers.isEmpty(), "At least one driver must be provided"); Preconditions.checkState( payloadSizeThreshold >= 0, "payloadSizeThreshold must be greater than or equal to zero"); + Preconditions.checkState( + maxConcurrentPayloadVisits >= 1, "maxConcurrentPayloadVisits must be at least 1"); Set names = new HashSet<>(); for (StorageDriver driver : drivers) { String name = driver.getName(); @@ -102,7 +126,8 @@ public ExternalStorageOptions build() { StorageDriver driver = drivers.get(0); selector = (context, payload) -> driver; } - return new ExternalStorageOptions(drivers, selector, payloadSizeThreshold); + return new ExternalStorage( + drivers, selector, payloadSizeThreshold, maxConcurrentPayloadVisits); } } } diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java index 01d851fbe6..bf332c38d7 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriver.java @@ -11,8 +11,8 @@ public interface StorageDriver { /** * Name of this driver instance, unique among the drivers registered in a single {@link - * ExternalStorageOptions}. Used as the routing key recorded in a stored payload's reference and - * resolved back to this driver on retrieval. + * ExternalStorage}. Used as the routing key recorded in a stored payload's reference and resolved + * back to this driver on retrieval. */ @Nonnull String getName(); diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java index 431622e2fa..966e52e68d 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java @@ -11,7 +11,7 @@ public interface StorageDriverSelector { /** * Returns the driver to store {@code payload}, which must be one of the drivers registered in the - * {@link ExternalStorageOptions}, or {@code null} to leave the payload stored inline. + * {@link ExternalStorage}, or {@code null} to leave the payload stored inline. */ @Nullable StorageDriver selectDriver(@Nonnull StorageDriverStoreContext context, @Nonnull Payload payload); diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index b755134448..818308aace 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -22,6 +22,8 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.EncodedValues; import io.temporal.failure.TemporalFailure; +import io.temporal.internal.client.WorkflowClientInternal; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.sync.WorkflowInternal; import io.temporal.internal.sync.WorkflowThreadExecutor; import io.temporal.internal.worker.*; @@ -123,6 +125,8 @@ private static final class TaskSnapshot { this.options = WorkerOptions.newBuilder(options).validateAndBuildWithDefaults(); this.clientOptions = client.getOptions(); this.cache = cache; + ExternalStorageRunner externalStorageRunner = + ((WorkflowClientInternal) client.getInternal()).getExternalStorageRunner(); factoryOptions = WorkerFactoryOptions.newBuilder(factoryOptions).validateAndBuildWithDefaults(); WorkflowClientOptions clientOptions = client.getOptions(); String namespace = clientOptions.getNamespace(); @@ -150,6 +154,7 @@ private static final class TaskSnapshot { taggedScope, workerInstanceKey, workerControlTaskQueue, + externalStorageRunner, activityTaskAutoEnrollEligible); if (this.options.isLocalActivityWorkerOnly()) { activityWorker = null; @@ -185,6 +190,7 @@ private static final class TaskSnapshot { taggedScope, workerInstanceKey, workerControlTaskQueue, + externalStorageRunner, nexusTaskAutoEnrollEligible); SlotSupplier nexusSlotSupplier = this.options.getWorkerTuner() == null @@ -206,6 +212,7 @@ private static final class TaskSnapshot { taggedScope, workerInstanceKey, workerControlTaskQueue, + externalStorageRunner, workflowTaskAutoEnrollEligible); SingleWorkerOptions localActivityOptions = toLocalActivityOptions( @@ -215,7 +222,8 @@ private static final class TaskSnapshot { contextPropagators, taggedScope, workerInstanceKey, - workerControlTaskQueue); + workerControlTaskQueue, + externalStorageRunner); SlotSupplier workflowSlotSupplier = this.options.getWorkerTuner() == null @@ -915,6 +923,7 @@ private static SingleWorkerOptions toActivityOptions( Scope metricsScope, String workerInstanceKey, String workerControlTaskQueue, + @Nullable ExternalStorageRunner externalStorageRunner, boolean autoEnrollEligible) { return toSingleWorkerOptions( factoryOptions, @@ -922,7 +931,8 @@ private static SingleWorkerOptions toActivityOptions( clientOptions, contextPropagators, workerInstanceKey, - workerControlTaskQueue) + workerControlTaskQueue, + externalStorageRunner) .setUsingVirtualThreads(options.isUsingVirtualThreadsOnActivityWorker()) .setAllowActivityHeartbeatDuringShutdown(options.getAllowActivityHeartbeatDuringShutdown()) .setPollerOptions( @@ -948,6 +958,7 @@ private static SingleWorkerOptions toNexusOptions( Scope metricsScope, String workerInstanceKey, String workerControlTaskQueue, + @Nullable ExternalStorageRunner externalStorageRunner, boolean autoEnrollEligible) { return toSingleWorkerOptions( factoryOptions, @@ -955,7 +966,8 @@ private static SingleWorkerOptions toNexusOptions( clientOptions, contextPropagators, workerInstanceKey, - workerControlTaskQueue) + workerControlTaskQueue, + externalStorageRunner) .setPollerOptions( PollerOptions.newBuilder() .setPollerBehavior( @@ -980,6 +992,7 @@ private static SingleWorkerOptions toWorkflowWorkerOptions( Scope metricsScope, String workerInstanceKey, String workerControlTaskQueue, + @Nullable ExternalStorageRunner externalStorageRunner, boolean autoEnrollEligible) { Map tags = new ImmutableMap.Builder(1).put(MetricsTag.TASK_QUEUE, taskQueue).build(); @@ -1015,7 +1028,8 @@ private static SingleWorkerOptions toWorkflowWorkerOptions( clientOptions, contextPropagators, workerInstanceKey, - workerControlTaskQueue) + workerControlTaskQueue, + externalStorageRunner) .setPollerOptions( PollerOptions.newBuilder() .setPollerBehavior( @@ -1040,14 +1054,16 @@ private static SingleWorkerOptions toLocalActivityOptions( List contextPropagators, Scope metricsScope, String workerInstanceKey, - String workerControlTaskQueue) { + String workerControlTaskQueue, + @Nullable ExternalStorageRunner externalStorageRunner) { return toSingleWorkerOptions( factoryOptions, options, clientOptions, contextPropagators, workerInstanceKey, - workerControlTaskQueue) + workerControlTaskQueue, + externalStorageRunner) .setPollerOptions( PollerOptions.newBuilder() .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) @@ -1066,7 +1082,8 @@ private static SingleWorkerOptions.Builder toSingleWorkerOptions( WorkflowClientOptions clientOptions, List contextPropagators, String workerInstanceKey, - String workerControlTaskQueue) { + String workerControlTaskQueue, + @Nullable ExternalStorageRunner externalStorageRunner) { String buildId = null; if (options.getBuildId() != null) { buildId = options.getBuildId(); @@ -1081,6 +1098,7 @@ private static SingleWorkerOptions.Builder toSingleWorkerOptions( return SingleWorkerOptions.newBuilder() .setDataConverter(clientOptions.getDataConverter()) + .setExternalStorageRunner(externalStorageRunner) .setIdentity(identity) .setBuildId(buildId) .setUseBuildIdForVersioning(options.isUsingBuildIdForVersioning()) diff --git a/temporal-sdk/src/test/java/io/temporal/client/WorkflowClientOptionsExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/client/WorkflowClientOptionsExternalStorageTest.java new file mode 100644 index 0000000000..d02e0c8cb0 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/WorkflowClientOptionsExternalStorageTest.java @@ -0,0 +1,77 @@ +package io.temporal.client; + +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; + +import io.temporal.api.common.v1.Payload; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +public class WorkflowClientOptionsExternalStorageTest { + + @Test + public void defaultsToDisabled() { + assertNull(WorkflowClientOptions.newBuilder().build().getExternalStorage()); + assertNull(WorkflowClientOptions.getDefaultInstance().getExternalStorage()); + } + + @Test + public void buildsWithDefaults() { + ExternalStorage storage = storage(); + + WorkflowClientOptions options = + WorkflowClientOptions.newBuilder() + .setExternalStorage(storage) + .validateAndBuildWithDefaults(); + + assertSame(storage, options.getExternalStorage()); + } + + /** Plugins reconfigure a client by rebuilding its options, so a round trip must not drop it. */ + @Test + public void survivesRoundTripThroughBuilder() { + ExternalStorage storage = storage(); + + WorkflowClientOptions original = + WorkflowClientOptions.newBuilder().setExternalStorage(storage).build(); + + assertSame(storage, original.toBuilder().build().getExternalStorage()); + assertSame(storage, WorkflowClientOptions.newBuilder(original).build().getExternalStorage()); + } + + private static ExternalStorage storage() { + return ExternalStorage.newBuilder().setDriver(driver()).build(); + } + + private static StorageDriver driver() { + return new StorageDriver() { + @Override + public String getName() { + return "test-driver"; + } + + @Override + public String getType() { + return "test"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + }; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java new file mode 100644 index 0000000000..b4fd9cc787 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java @@ -0,0 +1,287 @@ +package io.temporal.internal.payload.storage; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.failure.v1.Failure; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class ExternalStorageDataConverterTest { + + private final DataConverter plain = DefaultDataConverter.newDefaultInstance(); + + @Test + public void payloadsRoundTripThroughStorage() { + RecordingDriver driver = new RecordingDriver(); + DataConverter converter = resolving(driver, 0); + + Optional stored = converter.toPayloads("a", "b"); + + assertTrue(ExternalStorageReferences.isReference(stored.get().getPayloads(0))); + assertTrue(ExternalStorageReferences.isReference(stored.get().getPayloads(1))); + + assertEquals("a", converter.fromPayloads(0, stored, String.class, String.class)); + assertEquals("b", converter.fromPayloads(1, stored, String.class, String.class)); + } + + @Test + public void payloadsBelowThresholdStayInline() { + RecordingDriver driver = new RecordingDriver(); + DataConverter converter = resolving(driver, 1024); + + Optional stored = converter.toPayloads("small"); + + assertFalse(ExternalStorageReferences.isReference(stored.get().getPayloads(0))); + assertTrue(driver.objects.isEmpty()); + assertEquals("small", converter.fromPayloads(0, stored, String.class, String.class)); + } + + @Test + public void readingOneArgumentDoesNotFetchTheRest() { + RecordingDriver driver = new RecordingDriver(); + DataConverter converter = resolving(driver, 0); + + Optional stored = converter.toPayloads("first", "second", "third"); + driver.retrievedKeys.clear(); + + assertEquals("second", converter.fromPayloads(1, stored, String.class, String.class)); + + assertEquals(1, driver.retrievedKeys.size()); + } + + @Test + public void singlePayloadRoundTrips() { + DataConverter converter = resolving(new RecordingDriver(), 0); + + Optional stored = converter.toPayload("value"); + + assertTrue(ExternalStorageReferences.isReference(stored.get())); + assertEquals("value", converter.fromPayload(stored.get(), String.class, String.class)); + } + + @Test + public void failureDetailsRoundTrip() { + DataConverter converter = resolving(new RecordingDriver(), 0); + + Failure failure = + converter.exceptionToFailure( + ApplicationFailure.newFailure("boom", "TestType", "detail-value")); + + Payload detail = failure.getApplicationFailureInfo().getDetails().getPayloads(0); + assertTrue(ExternalStorageReferences.isReference(detail)); + + RuntimeException restored = converter.failureToException(failure); + assertTrue(restored instanceof ApplicationFailure); + assertEquals("detail-value", ((ApplicationFailure) restored).getDetails().get(0, String.class)); + } + + @Test + public void storageTargetReachesTheDriver() { + RecordingDriver driver = new RecordingDriver(); + StorageDriverWorkflowInfo target = new StorageDriverWorkflowInfo("ns", "wf-1", null, null); + + ExternalStorageDataConverter converter = + new ExternalStorageDataConverter(plain, runner(driver, 0)).withStorageTarget(target); + converter.toPayloads("x"); + + assertEquals(target, driver.lastTarget); + } + + @Test + public void withoutATargetTheDriverSeesNone() { + RecordingDriver driver = new RecordingDriver(); + resolving(driver, 0).toPayloads("x"); + + assertNull(driver.lastTarget); + } + + @Test + public void arrayFromPayloadsRoundTrips() { + RecordingDriver driver = new RecordingDriver(); + DataConverter converter = resolving(driver, 0); + + Optional stored = converter.toPayloads("a", 42); + + Object[] values = + converter.fromPayloads( + stored, + new Class[] {String.class, Integer.class}, + new Type[] {String.class, Integer.class}); + + assertEquals("a", values[0]); + assertEquals(42, values[1]); + } + + @Test + public void arrayFromPayloadsWithAbsentContentUsesDefaults() { + DataConverter converter = resolving(new RecordingDriver(), 0); + + Object[] values = + converter.fromPayloads( + Optional.empty(), new Class[] {String.class}, new Type[] {String.class}); + + assertNull(values[0]); + } + + @Test + public void arrayFromPayloadsDecodesThroughTheCodecInOneBatch() { + RecordingDriver driver = new RecordingDriver(); + CountingCodec codec = new CountingCodec(); + DataConverter converter = codecBacked(driver, codec); + + Optional stored = converter.toPayloads("a", "b", "c"); + assertEquals(1, codec.encodeCalls.get()); + + Object[] values = + converter.fromPayloads( + stored, + new Class[] {String.class, String.class, String.class}, + new Type[] {String.class, String.class, String.class}); + + assertArrayEquals(new Object[] {"a", "b", "c"}, values); + assertEquals(1, codec.decodeCalls.get()); + } + + /** + * A codec encrypts payloads, so a driver must never see the plaintext: conversion has to run + * before the payload is handed to storage. + */ + @Test + public void driversOnlyEverSeeCodecEncodedPayloads() { + RecordingDriver driver = new RecordingDriver(); + CountingCodec codec = new CountingCodec(); + DataConverter converter = codecBacked(driver, codec); + + Optional stored = converter.toPayloads("a", "b", "c"); + + assertEquals(3, driver.objects.size()); + for (Payload payload : driver.objects.values()) { + String data = payload.getData().toStringUtf8(); + assertFalse(data.contains("\"a\"")); + assertFalse(data.contains("\"b\"")); + assertFalse(data.contains("\"c\"")); + } + + assertArrayEquals( + new Object[] {"a", "b", "c"}, + converter.fromPayloads( + stored, + new Class[] {String.class, String.class, String.class}, + new Type[] {String.class, String.class, String.class})); + } + + private DataConverter codecBacked(StorageDriver driver, PayloadCodec codec) { + return new ExternalStorageDataConverter( + new CodecDataConverter(plain, Collections.singletonList(codec)), runner(driver, 0)); + } + + private DataConverter resolving(StorageDriver driver, int threshold) { + return new ExternalStorageDataConverter(plain, runner(driver, threshold)); + } + + private static ExternalStorageRunner runner(StorageDriver driver, int threshold) { + return ExternalStorageRunner.create( + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(threshold).build()); + } + + /** Obscures payload bytes so plaintext reaching a driver is detectable. */ + private static final class CountingCodec implements PayloadCodec { + private static final byte KEY = 0x5A; + + final AtomicInteger encodeCalls = new AtomicInteger(); + final AtomicInteger decodeCalls = new AtomicInteger(); + + @Override + public List encode(List payloads) { + encodeCalls.incrementAndGet(); + return apply(payloads); + } + + @Override + public List decode(List payloads) { + decodeCalls.incrementAndGet(); + return apply(payloads); + } + + private static List apply(List payloads) { + List out = new ArrayList<>(); + for (Payload payload : payloads) { + byte[] bytes = payload.getData().toByteArray(); + for (int i = 0; i < bytes.length; i++) { + bytes[i] ^= KEY; + } + out.add(payload.toBuilder().setData(ByteString.copyFrom(bytes)).build()); + } + return out; + } + } + + private static final class RecordingDriver implements StorageDriver { + final Map objects = new HashMap<>(); + final List retrievedKeys = new ArrayList<>(); + volatile StorageDriverTargetInfo lastTarget; + private int counter = 0; + + @Override + public String getName() { + return "test"; + } + + @Override + public String getType() { + return "test.inmemory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + lastTarget = context.getTarget(); + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = "k-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + String key = claim.getClaimData().get("key"); + retrievedKeys.add(key); + payloads.add(objects.get(key)); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java deleted file mode 100644 index f17bcff47a..0000000000 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageMessageTransformerTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package io.temporal.internal.payload.storage; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; -import static org.junit.Assert.assertTrue; - -import com.google.protobuf.ByteString; -import io.temporal.api.command.v1.Command; -import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; -import io.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes; -import io.temporal.api.common.v1.Payload; -import io.temporal.api.common.v1.Payloads; -import io.temporal.api.common.v1.SearchAttributes; -import io.temporal.common.CancellationToken; -import io.temporal.payload.storage.ExternalStorageOptions; -import io.temporal.payload.storage.StorageDriver; -import io.temporal.payload.storage.StorageDriverClaim; -import io.temporal.payload.storage.StorageDriverRetrieveContext; -import io.temporal.payload.storage.StorageDriverStoreContext; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import org.junit.Test; - -/** Tests external storage message conversion. */ -public class ExternalStorageMessageTransformerTest { - - @Test - public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); - ExternalStorageMessageTransformer transformer = transformer(driver, 0); - Payloads message = - Payloads.newBuilder().addPayloads(payload("a")).addPayloads(payload("b")).build(); - - Payloads stored = transformer.store(message, null, CancellationToken.none()).get(); - - assertNotNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(0))); - assertNotNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(1))); - - Payloads retrieved = transformer.retrieve(stored, CancellationToken.none()).get(); - assertEquals(message, retrieved); - } - - @Test - public void walksNestedPayloads() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); - ExternalStorageMessageTransformer transformer = transformer(driver, 0); - Command command = - Command.newBuilder() - .setScheduleActivityTaskCommandAttributes( - ScheduleActivityTaskCommandAttributes.newBuilder() - .setInput(Payloads.newBuilder().addPayloads(payload("deep")))) - .build(); - - Command stored = transformer.store(command, null, CancellationToken.none()).get(); - - Payload nested = stored.getScheduleActivityTaskCommandAttributes().getInput().getPayloads(0); - assertNotNull(ExternalStorageReferences.tryParseReference(nested)); - assertEquals(command, transformer.retrieve(stored, CancellationToken.none()).get()); - } - - @Test - public void payloadBelowThresholdLeavesMessageUnchanged() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); - ExternalStorageMessageTransformer transformer = transformer(driver, 1024); - Payloads message = Payloads.newBuilder().addPayloads(payload("small")).build(); - - Payloads stored = transformer.store(message, null, CancellationToken.none()).get(); - - assertNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(0))); - assertEquals(message, stored); - assertTrue(driver.storeBatchSizes.isEmpty()); - } - - @Test - public void searchAttributesAreNotOffloaded() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); - ExternalStorageMessageTransformer transformer = transformer(driver, 0); - Command command = - Command.newBuilder() - .setStartChildWorkflowExecutionCommandAttributes( - StartChildWorkflowExecutionCommandAttributes.newBuilder() - .setInput(Payloads.newBuilder().addPayloads(payload("input"))) - .setSearchAttributes( - SearchAttributes.newBuilder() - .putIndexedFields("k", payload("indexed-value")))) - .build(); - - Command stored = transformer.store(command, null, CancellationToken.none()).get(); - - StartChildWorkflowExecutionCommandAttributes attrs = - stored.getStartChildWorkflowExecutionCommandAttributes(); - assertNotNull(ExternalStorageReferences.tryParseReference(attrs.getInput().getPayloads(0))); - Payload indexed = attrs.getSearchAttributes().getIndexedFieldsOrThrow("k"); - assertNull(ExternalStorageReferences.tryParseReference(indexed)); - assertEquals(payload("indexed-value"), indexed); - } - - private static ExternalStorageMessageTransformer transformer( - StorageDriver driver, int threshold) { - ExternalStoragePayloadTransformer payloadTransformer = - ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() - .setDriver(driver) - .setPayloadSizeThreshold(threshold) - .build()); - return new ExternalStorageMessageTransformer(payloadTransformer, 4); - } - - private static Payload payload(String data) { - return Payload.newBuilder().setData(ByteString.copyFromUtf8(data)).build(); - } - - private static final class InMemoryDriver implements StorageDriver { - private final String name; - private final Map objects = new HashMap<>(); - final List storeBatchSizes = new ArrayList<>(); - private int counter = 0; - - InMemoryDriver(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getType() { - return "test.inmemory"; - } - - @Override - public synchronized CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - storeBatchSizes.add(payloads.size()); - List claims = new ArrayList<>(); - for (Payload payload : payloads) { - String key = name + "-" + (counter++); - objects.put(key, payload); - claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); - } - return CompletableFuture.completedFuture(claims); - } - - @Override - public synchronized CompletableFuture> retrieve( - StorageDriverRetrieveContext context, List claims) { - List payloads = new ArrayList<>(); - for (StorageDriverClaim claim : claims) { - payloads.add(objects.get(claim.getClaimData().get("key"))); - } - return CompletableFuture.completedFuture(payloads); - } - } -} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java index f1632ca81e..2dcb58f384 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java @@ -12,7 +12,7 @@ import io.temporal.api.common.v1.Payload; import io.temporal.common.CancellationToken; import io.temporal.internal.concurrent.structured.CancelSource; -import io.temporal.payload.storage.ExternalStorageOptions; +import io.temporal.payload.storage.ExternalStorage; import io.temporal.payload.storage.StorageDriver; import io.temporal.payload.storage.StorageDriverClaim; import io.temporal.payload.storage.StorageDriverRetrieveContext; @@ -75,7 +75,7 @@ public void selectorReturningNullKeepsInline() throws Exception { InMemoryDriver driver = new InMemoryDriver("d1"); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() + ExternalStorage.newBuilder() .setDriver(driver) .setDriverSelector((context, payload) -> null) .setPayloadSizeThreshold(0) @@ -101,7 +101,7 @@ public void multipleDriversBatchPerDriverAndPreserveOrder() throws Exception { (context, payload) -> byPrefix.get(payload.getData().toStringUtf8().substring(0, 1)); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() + ExternalStorage.newBuilder() .setDrivers(Arrays.asList(d1, d2)) .setDriverSelector(selector) .setPayloadSizeThreshold(0) @@ -198,7 +198,7 @@ public void selectorReturningUnregisteredDriverFails() { InMemoryDriver stranger = new InMemoryDriver("d2"); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() + ExternalStorage.newBuilder() .setDriver(registered) .setDriverSelector((context, payload) -> stranger) .setPayloadSizeThreshold(0) @@ -232,7 +232,7 @@ public CompletableFuture> store( byPrefix.put("2", doomed); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() + ExternalStorage.newBuilder() .setDrivers(Arrays.asList(slow, doomed)) .setDriverSelector( (context, payload) -> @@ -314,7 +314,7 @@ public void selectorObservesCallerCancellationToken() { AtomicReference> observed = new AtomicReference<>(); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() + ExternalStorage.newBuilder() .setDriver(driver) .setDriverSelector( (context, payload) -> { @@ -332,10 +332,7 @@ public void selectorObservesCallerCancellationToken() { private static ExternalStoragePayloadTransformer transformer( StorageDriver driver, int threshold) { return ExternalStoragePayloadTransformer.fromOptions( - ExternalStorageOptions.newBuilder() - .setDriver(driver) - .setPayloadSizeThreshold(threshold) - .build()); + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(threshold).build()); } private static Payload payload(String data) { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java new file mode 100644 index 0000000000..7cef800eb5 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java @@ -0,0 +1,383 @@ +package io.temporal.internal.payload.storage; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributesOrBuilder; +import io.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes; +import io.temporal.api.common.v1.ActivityType; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.common.v1.SearchAttributes; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import io.temporal.common.CancellationToken; +import io.temporal.internal.concurrent.structured.CancelSource; +import io.temporal.internal.payload.visitor.MessageVisitor; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +/** Tests external storage message conversion. */ +public class ExternalStorageRunnerTest { + + @Test + public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 0); + Payloads message = + Payloads.newBuilder().addPayloads(payload("a")).addPayloads(payload("b")).build(); + + Payloads.Builder builder = message.toBuilder(); + transformer.store(builder, null, null, CancellationToken.none()); + Payloads stored = builder.build(); + + assertNotNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(0))); + assertNotNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(1))); + + Payloads retrieved = transformer.retrieve(stored, CancellationToken.none()); + assertEquals(message, retrieved); + } + + @Test + public void walksNestedPayloads() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 0); + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setInput(Payloads.newBuilder().addPayloads(payload("deep")))) + .build(); + + Command.Builder builder = command.toBuilder(); + transformer.store(builder, null, null, CancellationToken.none()); + Command stored = builder.build(); + + Payload nested = stored.getScheduleActivityTaskCommandAttributes().getInput().getPayloads(0); + assertNotNull(ExternalStorageReferences.tryParseReference(nested)); + assertEquals(command, transformer.retrieve(stored, CancellationToken.none())); + } + + @Test + public void payloadBelowThresholdLeavesMessageUnchanged() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 1024); + Payloads message = Payloads.newBuilder().addPayloads(payload("small")).build(); + + Payloads.Builder builder = message.toBuilder(); + transformer.store(builder, null, null, CancellationToken.none()); + Payloads stored = builder.build(); + + assertNull(ExternalStorageReferences.tryParseReference(stored.getPayloads(0))); + assertEquals(message, stored); + assertTrue(driver.storeBatchSizes.isEmpty()); + } + + @Test + public void searchAttributesAreNotOffloaded() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 0); + Command command = + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setInput(Payloads.newBuilder().addPayloads(payload("input"))) + .setSearchAttributes( + SearchAttributes.newBuilder() + .putIndexedFields("k", payload("indexed-value")))) + .build(); + + Command.Builder builder = command.toBuilder(); + transformer.store(builder, null, null, CancellationToken.none()); + Command stored = builder.build(); + + StartChildWorkflowExecutionCommandAttributes attrs = + stored.getStartChildWorkflowExecutionCommandAttributes(); + assertNotNull(ExternalStorageReferences.tryParseReference(attrs.getInput().getPayloads(0))); + Payload indexed = attrs.getSearchAttributes().getIndexedFieldsOrThrow("k"); + assertNull(ExternalStorageReferences.tryParseReference(indexed)); + assertEquals(payload("indexed-value"), indexed); + } + + @Test + public void throwIfContainsReferenceThrowsOnANestedReference() throws Exception { + ExternalStorageRunner transformer = transformer(new InMemoryDriver("d1"), 0); + RespondWorkflowTaskCompletedRequest.Builder request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setActivityId("act-1") + .setInput( + Payloads.newBuilder().addPayloads(payload("activity-input"))))); + transformer.store(request, null, null, CancellationToken.none()); + RespondWorkflowTaskCompletedRequest stored = request.build(); + + assertThrows( + ExternalStorageNotConfiguredException.class, + () -> ExternalStorageRunner.throwIfContainsReference(stored)); + } + + @Test + public void throwIfContainsReferenceThrowsOnReference() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 0); + Payloads.Builder builder = Payloads.newBuilder().addPayloads(payload("a")); + transformer.store(builder, null, null, CancellationToken.none()); + Payloads stored = builder.build(); + + assertThrows( + ExternalStorageNotConfiguredException.class, + () -> ExternalStorageRunner.throwIfContainsReference(stored)); + } + + @Test + public void throwIfContainsReferenceAllowsInlinePayloads() { + Payloads inline = Payloads.newBuilder().addPayloads(payload("a")).build(); + ExternalStorageRunner.throwIfContainsReference(inline); + } + + @Test + public void storeAppliesPerCommandTargetFromMessageVisitor() { + TargetCapturingDriver driver = new TargetCapturingDriver("d1"); + ExternalStorageRunner storage = transformer(driver, 0); + + RespondWorkflowTaskCompletedRequest.Builder request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setActivityId("act-1") + .setActivityType(ActivityType.newBuilder().setName("MyActivity")) + .setInput( + Payloads.newBuilder().addPayloads(payload("activity-input"))))) + .addCommands( + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder() + .setResult(Payloads.newBuilder().addPayloads(payload("wf-result"))))); + + StorageDriverTargetInfo workflowTarget = + new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "MyWorkflow"); + MessageVisitor visitor = + (current, message) -> { + if (message instanceof ScheduleActivityTaskCommandAttributesOrBuilder) { + ScheduleActivityTaskCommandAttributesOrBuilder attrs = + (ScheduleActivityTaskCommandAttributesOrBuilder) message; + return new StorageDriverActivityInfo( + "ns", attrs.getActivityId(), null, attrs.getActivityType().getName()); + } + return current; + }; + + storage.store(request, workflowTarget, visitor, CancellationToken.none()); + + assertEquals( + new StorageDriverActivityInfo("ns", "act-1", null, "MyActivity"), + driver.targetFor("activity-input")); + assertEquals(workflowTarget, driver.targetFor("wf-result")); + } + + @Test + public void callerCancellationAbortsStore() { + ExternalStorageRunner storage = transformer(new HangingDriver("d1"), 0); + CancelSource caller = new CancelSource<>(CancellationException::new); + caller.cancel(); + Payloads message = Payloads.newBuilder().addPayloads(payload("big")).build(); + + assertThrows( + CancellationException.class, + () -> storage.store(message.toBuilder(), null, null, caller.token())); + } + + @Test + public void completedOperationsReleaseTheirCancellationRegistrations() { + RegistrationCountingToken token = new RegistrationCountingToken(); + ExternalStorageRunner storage = transformer(new InMemoryDriver("d1"), 0); + + for (int i = 0; i < 5; i++) { + Payloads.Builder builder = Payloads.newBuilder().addPayloads(payload("a")); + storage.store(builder, null, null, token); + storage.retrieve(builder.build(), token); + } + + assertEquals(0, token.open()); + } + + private static ExternalStorageRunner transformer(StorageDriver driver, int threshold) { + ExternalStoragePayloadTransformer payloadTransformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorage.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(threshold) + .build()); + return new ExternalStorageRunner(payloadTransformer, 4); + } + + private static Payload payload(String data) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(data)).build(); + } + + private static final class RegistrationCountingToken + implements CancellationToken { + private final AtomicInteger open = new AtomicInteger(); + + int open() { + return open.get(); + } + + @Override + public boolean isCancellationRequested() { + return false; + } + + @Override + public void throwIfCancellationRequested() {} + + @Override + public Registration onCancel(Runnable callback) { + open.incrementAndGet(); + return open::decrementAndGet; + } + } + + private static final class InMemoryDriver implements StorageDriver { + private final String name; + private final Map objects = new HashMap<>(); + final List storeBatchSizes = new ArrayList<>(); + private int counter = 0; + + InMemoryDriver(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.inmemory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + storeBatchSizes.add(payloads.size()); + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = name + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } + + private static final class TargetCapturingDriver implements StorageDriver { + private final String name; + private final Map targetByData = new HashMap<>(); + private int counter = 0; + + TargetCapturingDriver(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.capture"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + targetByData.put(payload.getData().toStringUtf8(), context.getTarget()); + claims.add( + new StorageDriverClaim(Collections.singletonMap("key", name + "-" + (counter++)))); + } + return CompletableFuture.completedFuture(claims); + } + + synchronized StorageDriverTargetInfo targetFor(String data) { + return targetByData.get(data); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + } + + /** Driver whose operations never settle, so only cancellation can end a blocking call. */ + private static final class HangingDriver implements StorageDriver { + private final String name; + + HangingDriver(String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.hanging"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + return new CompletableFuture<>(); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + return new CompletableFuture<>(); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java similarity index 75% rename from temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java rename to temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index 2c7ffc782f..e68b13b3a0 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -12,7 +12,7 @@ import org.junit.Test; /** Tests external storage option validation and defaults. */ -public class ExternalStorageOptionsTest { +public class ExternalStorageTest { private static StorageDriverStoreContext storeContext(StorageDriverTargetInfo target) { return new StorageDriverStoreContext() { @@ -52,7 +52,7 @@ public CompletableFuture> retrieve( @Test public void singleDriverNoSelectorSynthesizesSelector() { StorageDriver a = driver("a"); - ExternalStorageOptions storage = ExternalStorageOptions.newBuilder().setDriver(a).build(); + ExternalStorage storage = ExternalStorage.newBuilder().setDriver(a).build(); assertEquals(1, storage.getDrivers().size()); StorageDriverSelector selector = storage.getDriverSelector(); assertNotNull(selector); @@ -62,8 +62,8 @@ public void singleDriverNoSelectorSynthesizesSelector() { @Test public void multipleDriversWithSelectorIsValid() { StorageDriver a = driver("a"); - ExternalStorageOptions storage = - ExternalStorageOptions.newBuilder() + ExternalStorage storage = + ExternalStorage.newBuilder() .setDrivers(Arrays.asList(a, driver("b"))) .setDriverSelector((context, payload) -> a) .build(); @@ -76,8 +76,8 @@ public void lastSetDriversWins() { StorageDriver a = driver("a"); StorageDriver b = driver("b"); StorageDriver c = driver("c"); - ExternalStorageOptions storage = - ExternalStorageOptions.newBuilder() + ExternalStorage storage = + ExternalStorage.newBuilder() .setDrivers(Arrays.asList(a, b)) .setDrivers(Collections.singletonList(c)) .build(); @@ -86,8 +86,8 @@ public void lastSetDriversWins() { @Test public void zeroThresholdStoresAll() { - ExternalStorageOptions storage = - ExternalStorageOptions.newBuilder() + ExternalStorage storage = + ExternalStorage.newBuilder() .setDrivers(Collections.singletonList(driver("a"))) .setPayloadSizeThreshold(0) .build(); @@ -96,26 +96,39 @@ public void zeroThresholdStoresAll() { @Test(expected = IllegalStateException.class) public void noDriversRejected() { - ExternalStorageOptions.newBuilder().build(); + ExternalStorage.newBuilder().build(); } @Test(expected = IllegalStateException.class) public void duplicateDriverNamesRejected() { - ExternalStorageOptions.newBuilder() - .setDrivers(Arrays.asList(driver("dup"), driver("dup"))) - .build(); + ExternalStorage.newBuilder().setDrivers(Arrays.asList(driver("dup"), driver("dup"))).build(); } @Test(expected = IllegalStateException.class) public void multipleDriversRequireSelector() { - ExternalStorageOptions.newBuilder().setDrivers(Arrays.asList(driver("a"), driver("b"))).build(); + ExternalStorage.newBuilder().setDrivers(Arrays.asList(driver("a"), driver("b"))).build(); } @Test(expected = IllegalStateException.class) public void negativeThresholdRejected() { - ExternalStorageOptions.newBuilder() + ExternalStorage.newBuilder() .setDrivers(Collections.singletonList(driver("a"))) .setPayloadSizeThreshold(-1) .build(); } + + @Test + public void maxConcurrentPayloadVisitsDefaultsToThree() { + assertEquals( + 3, + ExternalStorage.newBuilder() + .setDriver(driver("a")) + .build() + .getMaxConcurrentPayloadVisits()); + } + + @Test(expected = IllegalStateException.class) + public void zeroMaxConcurrentPayloadVisitsRejected() { + ExternalStorage.newBuilder().setDriver(driver("a")).setMaxConcurrentPayloadVisits(0).build(); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java index 46ae4d37de..b7ffce91ca 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollEligibilityTest.java @@ -12,6 +12,7 @@ import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; +import io.temporal.internal.client.WorkflowClientInternal; import io.temporal.internal.sync.WorkflowThreadExecutor; import io.temporal.internal.worker.NamespaceCapabilities; import io.temporal.internal.worker.WorkflowExecutorCache; @@ -43,6 +44,7 @@ private Worker buildWorker(WorkerOptions options) { when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); WorkflowClient client = mock(WorkflowClient.class); + when(client.getInternal()).thenReturn(mock(WorkflowClientInternal.class)); when(client.getWorkflowServiceStubs()).thenReturn(service); when(client.getOptions()) .thenReturn( diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java index 00ea0d69be..1d5f5df30d 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerPollerAutoEnrollStartupTest.java @@ -21,6 +21,7 @@ import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; +import io.temporal.internal.client.WorkflowClientInternal; import io.temporal.internal.sync.WorkflowThreadExecutor; import io.temporal.internal.worker.NamespaceCapabilities; import io.temporal.internal.worker.ShutdownManager; @@ -97,6 +98,7 @@ public void autoEnrollAtStartupSwitchesPollersToAutoscaling() throws Exception { when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); WorkflowClient client = mock(WorkflowClient.class); + when(client.getInternal()).thenReturn(mock(WorkflowClientInternal.class)); when(client.getWorkflowServiceStubs()).thenReturn(service); when(client.getOptions()) .thenReturn( diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java index 23a63cda8b..390efe1e7d 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java @@ -21,6 +21,7 @@ import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowClientOptions; +import io.temporal.internal.client.WorkflowClientInternal; import io.temporal.internal.sync.WorkflowThreadExecutor; import io.temporal.internal.worker.NamespaceCapabilities; import io.temporal.internal.worker.ShutdownManager; @@ -92,6 +93,7 @@ public void activeTaskQueueTypesEvaluatedAtShutdownTime() throws Exception { when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); WorkflowClient client = mock(WorkflowClient.class); + when(client.getInternal()).thenReturn(mock(WorkflowClientInternal.class)); when(client.getWorkflowServiceStubs()).thenReturn(service); when(client.getOptions()) .thenReturn( From 4f5a1079e3ed396b523a00347674f0db84b7cd0c Mon Sep 17 00:00:00 2001 From: Baekgyu Kim Date: Tue, 1 Sep 2026 01:29:51 +0900 Subject: [PATCH 080/107] Fix custom slot supplier SlotInfo fields (#3014) --- .../internal/worker/SingleWorkerOptions.java | 3 + .../internal/worker/WorkflowPollTask.java | 2 +- .../worker/tuning/WorkflowSlotInfo.java | 13 +- .../internal/worker/SlotInfoTest.java | 268 ++++++++++++++++++ .../internal/worker/WorkflowSlotInfoTest.java | 227 +++++++++++++++ .../testUtils/RecordingSlotSupplier.java | 64 +++++ 6 files changed, 574 insertions(+), 3 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/SlotInfoTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowSlotInfoTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/testUtils/RecordingSlotSupplier.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java index a34e55d904..f6692e2144 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java @@ -321,6 +321,9 @@ public String getBinaryChecksum() { } public String getBuildId() { + if (deploymentOptions != null && deploymentOptions.getVersion() != null) { + return deploymentOptions.getVersion().getBuildId(); + } if (buildId == null) { return binaryChecksum; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java index 1b6c8cf7dc..f9c3e2f103 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowPollTask.java @@ -163,7 +163,7 @@ public WorkflowTask poll() { isSuccessful = true; tracker.pollSucceeded(); stickyQueueBalancer.finishPoll(taskQueueKind, response.getBacklogCountHint()); - slotSupplier.markSlotUsed(new WorkflowSlotInfo(response, pollRequest), permit); + slotSupplier.markSlotUsed(new WorkflowSlotInfo(response, request), permit); return new WorkflowTask(response, (rr) -> slotSupplier.releaseSlot(rr, permit)); } finally { if (isSticky) { diff --git a/temporal-sdk/src/main/java/io/temporal/worker/tuning/WorkflowSlotInfo.java b/temporal-sdk/src/main/java/io/temporal/worker/tuning/WorkflowSlotInfo.java index 9310bc64cf..e0afe31d11 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/tuning/WorkflowSlotInfo.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/tuning/WorkflowSlotInfo.java @@ -22,11 +22,20 @@ public WorkflowSlotInfo( @Nonnull PollWorkflowTaskQueueResponse response, @Nonnull PollWorkflowTaskQueueRequest request) { this.workflowType = response.getWorkflowType().getName(); - this.taskQueue = request.getTaskQueue().getNormalName(); + this.taskQueue = + request.getTaskQueue().getKind() == TaskQueueKind.TASK_QUEUE_KIND_STICKY + ? request.getTaskQueue().getNormalName() + : request.getTaskQueue().getName(); this.workflowId = response.getWorkflowExecution().getWorkflowId(); this.runId = response.getWorkflowExecution().getRunId(); this.workerIdentity = request.getIdentity(); - this.workerBuildId = request.getWorkerVersionCapabilities().getBuildId(); + if (request.hasDeploymentOptions()) { + this.workerBuildId = request.getDeploymentOptions().getBuildId(); + } else if (request.hasWorkerVersionCapabilities()) { + this.workerBuildId = request.getWorkerVersionCapabilities().getBuildId(); + } else { + this.workerBuildId = request.getBinaryChecksum(); + } this.fromStickyQueue = request.getTaskQueue().getKind() == TaskQueueKind.TASK_QUEUE_KIND_STICKY; } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotInfoTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotInfoTest.java new file mode 100644 index 0000000000..02054b8a5c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/SlotInfoTest.java @@ -0,0 +1,268 @@ +package io.temporal.internal.worker; + +import static io.temporal.testUtils.Eventually.assertEventually; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.activity.LocalActivityOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.RetryOptions; +import io.temporal.common.WorkerDeploymentVersion; +import io.temporal.testUtils.RecordingSlotSupplier; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkerDeploymentOptions; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.tuning.ActivitySlotInfo; +import io.temporal.worker.tuning.CompositeTuner; +import io.temporal.worker.tuning.LocalActivitySlotInfo; +import io.temporal.worker.tuning.NexusSlotInfo; +import io.temporal.worker.tuning.SlotInfo; +import io.temporal.worker.tuning.SlotMarkUsedContext; +import io.temporal.worker.tuning.SlotPermit; +import io.temporal.worker.tuning.SlotReleaseContext; +import io.temporal.worker.tuning.WorkflowSlotInfo; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.TestNexusServices; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; + +@SuppressWarnings("deprecation") +public class SlotInfoTest { + private static final String WORKFLOW_TYPE = "slot-info-workflow"; + private static final String ACTIVITY_TYPE = "slot-info-activity"; + private static final String WORKFLOW_ID = "slot-info-workflow-id"; + private static final String WORKER_IDENTITY = "slot-info-worker-identity"; + private static final String WORKER_BUILD_ID = "slot-info-worker-build-id"; + + private final RecordingSlotSupplier workflowSlotSupplier = + new RecordingSlotSupplier<>(100); + private final RecordingSlotSupplier activitySlotSupplier = + new RecordingSlotSupplier<>(100); + private final RecordingSlotSupplier localActivitySlotSupplier = + new RecordingSlotSupplier<>(100); + private final RecordingSlotSupplier nexusSlotSupplier = + new RecordingSlotSupplier<>(100); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkerOptions( + WorkerOptions.newBuilder() + .setIdentity(WORKER_IDENTITY) + .setDeploymentOptions( + WorkerDeploymentOptions.newBuilder() + .setVersion( + new WorkerDeploymentVersion("slot-info-deployment", WORKER_BUILD_ID)) + .setUseVersioning(false) + .build()) + .setWorkerTuner( + new CompositeTuner( + workflowSlotSupplier, + activitySlotSupplier, + localActivitySlotSupplier, + nexusSlotSupplier)) + .build()) + .setWorkflowTypes(SlotInfoWorkflowImpl.class) + .setActivityImplementations(new SlotInfoActivityImpl()) + .setNexusServiceImplementation(new SlotInfoNexusService()) + .build(); + + @Test + public void customSlotSuppliersReceiveExpectedSlotInfo() { + SlotInfoWorkflow workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + SlotInfoWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(WORKFLOW_ID) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .build()); + + assertEquals("done", workflow.execute()); + String runId = WorkflowStub.fromTyped(workflow).getExecution().getRunId(); + + List workflowSlotInfos = getSlotInfos(workflowSlotSupplier); + assertFalse(workflowSlotInfos.isEmpty()); + for (WorkflowSlotInfo slotInfo : workflowSlotInfos) { + assertEquals(WORKFLOW_TYPE, slotInfo.getWorkflowType()); + assertEquals(testWorkflowRule.getTaskQueue(), slotInfo.getTaskQueue()); + assertEquals(WORKFLOW_ID, slotInfo.getWorkflowId()); + assertEquals(runId, slotInfo.getRunId()); + assertEquals(WORKER_IDENTITY, slotInfo.getWorkerIdentity()); + assertEquals(WORKER_BUILD_ID, slotInfo.getWorkerBuildId()); + } + + ActivitySlotInfo activitySlotInfo = getOnlySlotInfo(activitySlotSupplier); + assertActivityInfo(activitySlotInfo, runId, false); + + LocalActivitySlotInfo localActivitySlotInfo = getOnlySlotInfo(localActivitySlotSupplier); + assertActivityInfo(localActivitySlotInfo, runId, true); + + NexusSlotInfo nexusSlotInfo = getOnlySlotInfo(nexusSlotSupplier); + assertEquals( + TestNexusServices.TestNexusService1.class.getSimpleName(), nexusSlotInfo.getService()); + assertEquals("operation", nexusSlotInfo.getOperation()); + assertEquals(testWorkflowRule.getTaskQueue(), nexusSlotInfo.getTaskQueue()); + assertEquals(WORKER_IDENTITY, nexusSlotInfo.getWorkerIdentity()); + assertEquals(WORKER_BUILD_ID, nexusSlotInfo.getWorkerBuildId()); + + workflowSlotInfos.forEach( + slotInfo -> assertReleasedWithSameSlotInfo(workflowSlotSupplier, slotInfo)); + assertReleasedWithSameSlotInfo(activitySlotSupplier, activitySlotInfo); + assertReleasedWithSameSlotInfo(localActivitySlotSupplier, localActivitySlotInfo); + assertReleasedWithSameSlotInfo(nexusSlotSupplier, nexusSlotInfo); + } + + private static List getSlotInfos( + RecordingSlotSupplier slotSupplier) { + List result = new ArrayList<>(); + for (SlotMarkUsedContext context : slotSupplier.getMarkUsedContexts()) { + result.add(context.getSlotInfo()); + } + return result; + } + + private static SI getOnlySlotInfo(RecordingSlotSupplier slotSupplier) { + List slotInfos = getSlotInfos(slotSupplier); + assertEquals(1, slotInfos.size()); + return slotInfos.get(0); + } + + private static void assertReleasedWithSameSlotInfo( + RecordingSlotSupplier slotSupplier, SI expectedSlotInfo) { + SlotPermit permit = null; + for (SlotMarkUsedContext context : slotSupplier.getMarkUsedContexts()) { + if (context.getSlotInfo() == expectedSlotInfo) { + permit = context.getSlotPermit(); + break; + } + } + assertNotNull(permit); + SlotPermit markedPermit = permit; + assertEventually( + Duration.ofSeconds(1), + () -> { + SI releasedSlotInfo = null; + for (SlotReleaseContext context : slotSupplier.getReleaseContexts()) { + if (context.getSlotPermit() == markedPermit) { + releasedSlotInfo = context.getSlotInfo(); + break; + } + } + assertSame(expectedSlotInfo, releasedSlotInfo); + }); + } + + private void assertActivityInfo(ActivitySlotInfo slotInfo, String runId, boolean expectedLocal) { + assertActivityInfo( + slotInfo.getActivityInfo(), + slotInfo.getWorkerIdentity(), + slotInfo.getWorkerBuildId(), + runId, + expectedLocal); + } + + private void assertActivityInfo( + LocalActivitySlotInfo slotInfo, String runId, boolean expectedLocal) { + assertActivityInfo( + slotInfo.getActivityInfo(), + slotInfo.getWorkerIdentity(), + slotInfo.getWorkerBuildId(), + runId, + expectedLocal); + } + + private void assertActivityInfo( + io.temporal.activity.ActivityInfo activityInfo, + String workerIdentity, + String workerBuildId, + String runId, + boolean expectedLocal) { + assertEquals(ACTIVITY_TYPE, activityInfo.getActivityType()); + assertFalse(activityInfo.getActivityId().isEmpty()); + assertEquals(WORKFLOW_ID, activityInfo.getWorkflowId()); + assertEquals(runId, activityInfo.getWorkflowRunId()); + assertEquals(WORKFLOW_TYPE, activityInfo.getWorkflowType()); + assertEquals(testWorkflowRule.getTaskQueue(), activityInfo.getActivityTaskQueue()); + assertEquals(SDKTestWorkflowRule.NAMESPACE, activityInfo.getNamespace()); + assertEquals(1, activityInfo.getAttempt()); + assertEquals(expectedLocal, activityInfo.isLocal()); + assertEquals(WORKER_IDENTITY, workerIdentity); + assertEquals(WORKER_BUILD_ID, workerBuildId); + } + + @WorkflowInterface + public interface SlotInfoWorkflow { + @WorkflowMethod(name = WORKFLOW_TYPE) + String execute(); + } + + public static class SlotInfoWorkflowImpl implements SlotInfoWorkflow { + private final SlotInfoActivity activity = + Workflow.newActivityStub( + SlotInfoActivity.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + private final SlotInfoActivity localActivity = + Workflow.newLocalActivityStub( + SlotInfoActivity.class, + LocalActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build()); + private final TestNexusServices.TestNexusService1 nexusService = + Workflow.newNexusServiceStub( + TestNexusServices.TestNexusService1.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()) + .build()); + + @Override + public String execute() { + localActivity.execute(); + activity.execute(); + nexusService.operation("input"); + return "done"; + } + } + + @ActivityInterface + public interface SlotInfoActivity { + @ActivityMethod(name = ACTIVITY_TYPE) + String execute(); + } + + public static class SlotInfoActivityImpl implements SlotInfoActivity { + @Override + public String execute() { + return "done"; + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class SlotInfoNexusService { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync((ctx, details, input) -> "done"); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowSlotInfoTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowSlotInfoTest.java new file mode 100644 index 0000000000..b4679f3fcb --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowSlotInfoTest.java @@ -0,0 +1,227 @@ +package io.temporal.internal.worker; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.common.util.concurrent.Futures; +import com.google.protobuf.ByteString; +import com.uber.m3.tally.NoopScope; +import io.temporal.api.common.v1.WorkerVersionCapabilities; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.common.v1.WorkflowType; +import io.temporal.api.enums.v1.TaskQueueKind; +import io.temporal.api.taskqueue.v1.TaskQueue; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueRequest; +import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.testUtils.RecordingSlotSupplier; +import io.temporal.worker.tuning.SlotInfo; +import io.temporal.worker.tuning.SlotMarkUsedContext; +import io.temporal.worker.tuning.SlotPermit; +import io.temporal.worker.tuning.SlotReleaseContext; +import io.temporal.worker.tuning.WorkflowSlotInfo; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +@SuppressWarnings("deprecation") +public class WorkflowSlotInfoTest { + private static final String WORKFLOW_TYPE = "test-workflow-type"; + private static final String TASK_QUEUE = "test-task-queue"; + private static final String STICKY_TASK_QUEUE = "test-sticky-task-queue"; + private static final String WORKFLOW_ID = "test-workflow-id"; + private static final String RUN_ID = "test-run-id"; + private static final String WORKER_IDENTITY = "test-worker-identity"; + private static final String WORKER_BUILD_ID = "test-worker-build-id"; + + @Test + public void normalWorkflowSlotInfoHasExpectedFields() { + PollWorkflowTaskQueueRequest request = + PollWorkflowTaskQueueRequest.newBuilder() + .setIdentity(WORKER_IDENTITY) + .setTaskQueue( + TaskQueue.newBuilder() + .setName(TASK_QUEUE) + .setKind(TaskQueueKind.TASK_QUEUE_KIND_NORMAL)) + .setWorkerVersionCapabilities( + WorkerVersionCapabilities.newBuilder().setBuildId(WORKER_BUILD_ID)) + .build(); + + WorkflowSlotInfo slotInfo = new WorkflowSlotInfo(workflowResponse(), request); + + assertWorkflowSlotInfo(slotInfo, false); + } + + @Test + public void deploymentBuildIdIsIncludedInWorkflowSlotInfo() { + PollWorkflowTaskQueueRequest request = + normalPollRequestBuilder() + .setDeploymentOptions( + io.temporal.api.deployment.v1.WorkerDeploymentOptions.newBuilder() + .setBuildId(WORKER_BUILD_ID)) + .build(); + + WorkflowSlotInfo slotInfo = new WorkflowSlotInfo(workflowResponse(), request); + + assertEquals(WORKER_BUILD_ID, slotInfo.getWorkerBuildId()); + } + + @Test + public void binaryChecksumIsIncludedInWorkflowSlotInfo() { + PollWorkflowTaskQueueRequest request = + normalPollRequestBuilder().setBinaryChecksum(WORKER_BUILD_ID).build(); + + WorkflowSlotInfo slotInfo = new WorkflowSlotInfo(workflowResponse(), request); + + assertEquals(WORKER_BUILD_ID, slotInfo.getWorkerBuildId()); + } + + @Test + public void synchronousStickyPollUsesSelectedRequestForSlotInfo() { + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(client.blockingStub()).thenReturn(blockingStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + when(blockingStub.pollWorkflowTaskQueue(any())).thenReturn(workflowResponse()); + + RecordingSlotSupplier recordingSupplier = new RecordingSlotSupplier<>(1); + TrackingSlotSupplier trackingSupplier = + new TrackingSlotSupplier<>(recordingSupplier, new NoopScope()); + WorkflowPollTask pollTask = + new WorkflowPollTask( + client, + "default", + TASK_QUEUE, + STICKY_TASK_QUEUE, + WORKER_IDENTITY, + "test-instance-key", + new WorkerVersioningOptions(WORKER_BUILD_ID, false, null), + trackingSupplier, + new StickyQueueBalancer(1, true), + new NoopScope(), + WorkflowSlotInfoTest::buildIdCapabilities, + new PollerTracker(), + new PollerTracker(), + null); + + WorkflowTask task = pollTask.poll(); + + assertNotNull(task); + SlotMarkUsedContext markUsedContext = + getOnlyMarkUsedContext(recordingSupplier); + List reservedPermits = recordingSupplier.getReservedPermits(); + assertEquals(1, reservedPermits.size()); + assertSame(reservedPermits.get(0), markUsedContext.getSlotPermit()); + assertWorkflowSlotInfo(markUsedContext.getSlotInfo(), true); + + task.getCompletionCallback().apply(io.temporal.worker.tuning.SlotReleaseReason.taskComplete()); + SlotReleaseContext releaseContext = getOnlyReleaseContext(recordingSupplier); + assertSame(markUsedContext.getSlotPermit(), releaseContext.getSlotPermit()); + assertSame(markUsedContext.getSlotInfo(), releaseContext.getSlotInfo()); + } + + @Test + public void asynchronousPollsIncludeNormalAndStickyQueueFields() throws Exception { + assertAsyncWorkflowSlotInfo(null, false); + assertAsyncWorkflowSlotInfo(STICKY_TASK_QUEUE, true); + } + + private static void assertAsyncWorkflowSlotInfo(String stickyTaskQueue, boolean expectedSticky) + throws Exception { + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + when(client.futureStub()).thenReturn(futureStub); + when(futureStub.withOption(any(), any())).thenReturn(futureStub); + when(futureStub.pollWorkflowTaskQueue(any())) + .thenReturn(Futures.immediateFuture(workflowResponse())); + + RecordingSlotSupplier recordingSupplier = new RecordingSlotSupplier<>(1); + TrackingSlotSupplier trackingSupplier = + new TrackingSlotSupplier<>(recordingSupplier, new NoopScope()); + AsyncWorkflowPollTask pollTask = + new AsyncWorkflowPollTask( + client, + "default", + TASK_QUEUE, + stickyTaskQueue, + WORKER_IDENTITY, + "test-instance-key", + new WorkerVersioningOptions(WORKER_BUILD_ID, false, null), + trackingSupplier, + new NoopScope(), + WorkflowSlotInfoTest::buildIdCapabilities, + new PollerTracker(), + null); + SlotPermit permit = new SlotPermit(); + + CompletableFuture future = pollTask.poll(permit); + WorkflowTask task = future.get(); + + assertNotNull(task); + SlotMarkUsedContext markUsedContext = + getOnlyMarkUsedContext(recordingSupplier); + assertSame(permit, markUsedContext.getSlotPermit()); + assertWorkflowSlotInfo(markUsedContext.getSlotInfo(), expectedSticky); + } + + private static SlotMarkUsedContext getOnlyMarkUsedContext( + RecordingSlotSupplier slotSupplier) { + List> contexts = slotSupplier.getMarkUsedContexts(); + assertEquals(1, contexts.size()); + return contexts.get(0); + } + + private static SlotReleaseContext getOnlyReleaseContext( + RecordingSlotSupplier slotSupplier) { + List> contexts = slotSupplier.getReleaseContexts(); + assertEquals(1, contexts.size()); + return contexts.get(0); + } + + private static PollWorkflowTaskQueueRequest.Builder normalPollRequestBuilder() { + return PollWorkflowTaskQueueRequest.newBuilder() + .setIdentity(WORKER_IDENTITY) + .setTaskQueue( + TaskQueue.newBuilder() + .setName(TASK_QUEUE) + .setKind(TaskQueueKind.TASK_QUEUE_KIND_NORMAL)); + } + + private static PollWorkflowTaskQueueResponse workflowResponse() { + return PollWorkflowTaskQueueResponse.newBuilder() + .setTaskToken(ByteString.copyFrom("token", UTF_8)) + .setWorkflowExecution( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).setRunId(RUN_ID)) + .setWorkflowType(WorkflowType.newBuilder().setName(WORKFLOW_TYPE)) + .build(); + } + + private static GetSystemInfoResponse.Capabilities buildIdCapabilities() { + return GetSystemInfoResponse.Capabilities.newBuilder().setBuildIdBasedVersioning(true).build(); + } + + private static void assertWorkflowSlotInfo(WorkflowSlotInfo slotInfo, boolean expectedSticky) { + assertEquals(WORKFLOW_TYPE, slotInfo.getWorkflowType()); + assertEquals(TASK_QUEUE, slotInfo.getTaskQueue()); + assertEquals(WORKFLOW_ID, slotInfo.getWorkflowId()); + assertEquals(RUN_ID, slotInfo.getRunId()); + assertEquals(WORKER_IDENTITY, slotInfo.getWorkerIdentity()); + assertEquals(WORKER_BUILD_ID, slotInfo.getWorkerBuildId()); + if (expectedSticky) { + assertTrue(slotInfo.isFromStickyQueue()); + } else { + assertFalse(slotInfo.isFromStickyQueue()); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/testUtils/RecordingSlotSupplier.java b/temporal-sdk/src/test/java/io/temporal/testUtils/RecordingSlotSupplier.java new file mode 100644 index 0000000000..adeec204d9 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/testUtils/RecordingSlotSupplier.java @@ -0,0 +1,64 @@ +package io.temporal.testUtils; + +import io.temporal.worker.tuning.FixedSizeSlotSupplier; +import io.temporal.worker.tuning.SlotInfo; +import io.temporal.worker.tuning.SlotMarkUsedContext; +import io.temporal.worker.tuning.SlotPermit; +import io.temporal.worker.tuning.SlotReleaseContext; +import io.temporal.worker.tuning.SlotReserveContext; +import io.temporal.worker.tuning.SlotSupplierFuture; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** A fixed-size slot supplier that records slot usage for tests. */ +public final class RecordingSlotSupplier extends FixedSizeSlotSupplier { + private final ConcurrentLinkedQueue reservedPermits = new ConcurrentLinkedQueue<>(); + private final ConcurrentLinkedQueue> markUsedContexts = + new ConcurrentLinkedQueue<>(); + private final ConcurrentLinkedQueue> releaseContexts = + new ConcurrentLinkedQueue<>(); + + public RecordingSlotSupplier(int numSlots) { + super(numSlots); + } + + @Override + public SlotSupplierFuture reserveSlot(SlotReserveContext ctx) throws Exception { + SlotSupplierFuture future = super.reserveSlot(ctx); + future.thenAccept(reservedPermits::add); + return future; + } + + @Override + public Optional tryReserveSlot(SlotReserveContext ctx) { + Optional permit = super.tryReserveSlot(ctx); + permit.ifPresent(reservedPermits::add); + return permit; + } + + @Override + public void markSlotUsed(SlotMarkUsedContext ctx) { + markUsedContexts.add(ctx); + super.markSlotUsed(ctx); + } + + @Override + public void releaseSlot(SlotReleaseContext ctx) { + releaseContexts.add(ctx); + super.releaseSlot(ctx); + } + + public List> getMarkUsedContexts() { + return new ArrayList<>(markUsedContexts); + } + + public List getReservedPermits() { + return new ArrayList<>(reservedPermits); + } + + public List> getReleaseContexts() { + return new ArrayList<>(releaseContexts); + } +} From 496ddc0e17cc848e1afcf5d7c362ce4c1efeaa54 Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Wed, 2 Sep 2026 10:54:53 -0400 Subject: [PATCH 081/107] Add envconfig support to test harness (#2998) * Add envconfig support to test harness * Keep envconfig harness PR focused * Preserve test options with envconfig * Handle envconfig profiles without metadata * Simplify envconfig test harness integration --- CONTRIBUTING.md | 18 ++++ temporal-sdk/build.gradle | 4 +- temporal-testing/build.gradle | 4 + .../docker/RegisterTestNamespace.java | 6 +- .../ExternalServiceTestConfigurator.java | 57 ++++++++++- .../ExternalServiceTestConfiguratorTest.java | 97 +++++++++++++++++++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 temporal-testing/src/test/java/io/temporal/testing/internal/ExternalServiceTestConfiguratorTest.java diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5b8881133..1dc11851ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -94,6 +94,24 @@ Normal Gradle test filtering works, so a single dev-server-backed test can be ru Java 11 must be available to Gradle for these commands. +To run an SDK test against an externally managed server using the standard Temporal client +environment configuration, set `TEMPORAL_TEST_ENV_CONFIG_SERVER`. For example, the following runs +one Cloud-safe workflow test: + +```bash +TEMPORAL_TEST_ENV_CONFIG_SERVER=true \ +TEMPORAL_ADDRESS=your-namespace.tmprl.cloud:7233 \ +TEMPORAL_NAMESPACE=your-namespace \ +TEMPORAL_API_KEY=your-api-key \ +./gradlew :temporal-sdk:test \ + --tests 'io.temporal.client.functional.SignalTest.signalCompletedWorkflow' +``` + +The harness also supports the standard `TEMPORAL_CONFIG_FILE` and `TEMPORAL_PROFILE` variables. +Values from `TEMPORAL_ADDRESS`, `TEMPORAL_NAMESPACE`, `TEMPORAL_API_KEY`, `TEMPORAL_TLS_*`, and +`TEMPORAL_GRPC_META_*` override the selected profile. Envconfig mode connects to an existing server +and namespace; it does not create or register either one. + ## Things to Avoid Avoid changes that make review harder without improving the contribution: diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle index d7b090e5b5..7fe5441252 100644 --- a/temporal-sdk/build.gradle +++ b/temporal-sdk/build.gradle @@ -25,6 +25,8 @@ dependencies { } testImplementation project(':temporal-testing') + // The optional envconfig-backed test harness is loaded only while SDK tests are running. + testRuntimeOnly project(':temporal-envconfig') testImplementation "junit:junit:${junitVersion}" testImplementation "org.mockito:mockito-core:${mockitoVersion}" testImplementation 'pl.pragmatists:JUnitParams:1.1.1' @@ -287,4 +289,4 @@ testing { tasks.named('check') { dependsOn(testing.suites.jackson3Tests) dependsOn(testing.suites.virtualThreadTests) -} \ No newline at end of file +} diff --git a/temporal-testing/build.gradle b/temporal-testing/build.gradle index f9ca013456..3715605b8c 100644 --- a/temporal-testing/build.gradle +++ b/temporal-testing/build.gradle @@ -16,6 +16,8 @@ java { dependencies { api project(':temporal-sdk') api project(':temporal-test-server') + // Envconfig is optional for consumers of temporal-testing. + compileOnly project(':temporal-envconfig') implementation 'org.apache.commons:commons-compress:1.28.0' @@ -32,6 +34,8 @@ dependencies { junit5Api 'org.junit.jupiter:junit-jupiter-api' testRuntimeOnly group: 'org.junit.jupiter', name: 'junit-jupiter' + // Tests construct envconfig profiles directly. + testImplementation project(':temporal-envconfig') testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" } diff --git a/temporal-testing/src/main/java/io/temporal/internal/docker/RegisterTestNamespace.java b/temporal-testing/src/main/java/io/temporal/internal/docker/RegisterTestNamespace.java index c840adf55c..cc45b8b614 100644 --- a/temporal-testing/src/main/java/io/temporal/internal/docker/RegisterTestNamespace.java +++ b/temporal-testing/src/main/java/io/temporal/internal/docker/RegisterTestNamespace.java @@ -7,6 +7,7 @@ import io.temporal.api.workflowservice.v1.ListNamespacesRequest; import io.temporal.api.workflowservice.v1.ListNamespacesResponse; import io.temporal.api.workflowservice.v1.RegisterNamespaceRequest; +import io.temporal.internal.common.env.EnvironmentVariableUtils; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.serviceclient.WorkflowServiceStubsOptions; @@ -15,10 +16,13 @@ public class RegisterTestNamespace { public static final String NAMESPACE = "UnitTest"; private static final boolean useExternalService = Boolean.parseBoolean(System.getenv("USE_EXTERNAL_SERVICE")); + private static final boolean useEnvConfig = + EnvironmentVariableUtils.readBooleanFlag("TEMPORAL_TEST_ENV_CONFIG_SERVER"); private static final String serviceAddress = System.getenv("TEMPORAL_SERVICE_ADDRESS"); public static void main(String[] args) throws InterruptedException { - if (!useExternalService) { + // Envconfig mode connects to an existing namespace and must not register UnitTest. + if (useEnvConfig || !useExternalService) { return; } diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java index 6c68d5f2b5..01ca6b2fb4 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/ExternalServiceTestConfigurator.java @@ -1,12 +1,16 @@ package io.temporal.testing.internal; +import io.temporal.envconfig.ClientConfigProfile; import io.temporal.internal.common.env.EnvironmentVariableUtils; import io.temporal.testing.TestEnvironmentOptions; import io.temporal.testing.TestWorkflowRule; import io.temporal.testing.internal.devserver.SdkJavaTestServerProfile; +import java.io.IOException; import javax.annotation.Nonnull; public class ExternalServiceTestConfigurator { + private static boolean USE_ENV_CONFIG = + EnvironmentVariableUtils.readBooleanFlag("TEMPORAL_TEST_ENV_CONFIG_SERVER"); private static boolean USE_EXTERNAL_SERVICE = EnvironmentVariableUtils.readBooleanFlag("USE_EXTERNAL_SERVICE"); private static String TEMPORAL_SERVICE_ADDRESS = @@ -15,7 +19,7 @@ public class ExternalServiceTestConfigurator { EnvironmentVariableUtils.readBooleanFlag("USE_VIRTUAL_THREADS"); public static boolean isUseExternalService() { - return USE_EXTERNAL_SERVICE || SdkJavaTestServerProfile.isActive(); + return USE_ENV_CONFIG || USE_EXTERNAL_SERVICE || SdkJavaTestServerProfile.isActive(); } public static boolean isUseVirtualThreads() { @@ -23,6 +27,9 @@ public static boolean isUseVirtualThreads() { } public static String getTemporalServiceAddress() { + if (USE_ENV_CONFIG) { + return loadEnvConfigProfile().getAddress(); + } if (SdkJavaTestServerProfile.isActive()) { return SdkJavaTestServerProfile.getTarget(); } @@ -33,6 +40,9 @@ public static String getTemporalServiceAddress() { public static TestWorkflowRule.Builder configure( @Nonnull TestWorkflowRule.Builder testWorkflowRule) { + if (USE_ENV_CONFIG) { + return configureFromEnvConfig(testWorkflowRule, loadEnvConfigProfile()); + } if (isUseExternalService()) { testWorkflowRule.setUseExternalService(true); String target = getTemporalServiceAddress(); @@ -45,6 +55,9 @@ public static TestWorkflowRule.Builder configure( public static TestEnvironmentOptions.Builder configure( @Nonnull TestEnvironmentOptions.Builder testEnvironmentOptions) { + if (USE_ENV_CONFIG) { + return configureFromEnvConfig(testEnvironmentOptions, loadEnvConfigProfile()); + } if (isUseExternalService()) { testEnvironmentOptions.setUseExternalService(true); String target = getTemporalServiceAddress(); @@ -58,4 +71,46 @@ public static TestEnvironmentOptions.Builder configure( public static TestEnvironmentOptions.Builder configuredTestEnvironmentOptions() { return configure(TestEnvironmentOptions.newBuilder()); } + + static TestWorkflowRule.Builder configureFromEnvConfig( + TestWorkflowRule.Builder testWorkflowRule, ClientConfigProfile profile) { + validateEnvConfigProfile(profile); + testWorkflowRule.setUseExternalService(true); + testWorkflowRule.setTarget(profile.getAddress()); + testWorkflowRule.setNamespace(profile.getNamespace()); + testWorkflowRule.setWorkflowServiceStubsOptions(profile.toWorkflowServiceStubsOptions()); + testWorkflowRule.setWorkflowClientOptions(profile.toWorkflowClientOptions()); + return testWorkflowRule; + } + + static TestEnvironmentOptions.Builder configureFromEnvConfig( + TestEnvironmentOptions.Builder testEnvironmentOptions, ClientConfigProfile profile) { + validateEnvConfigProfile(profile); + testEnvironmentOptions.setUseExternalService(true); + testEnvironmentOptions.setTarget(profile.getAddress()); + testEnvironmentOptions.setWorkflowServiceStubsOptions(profile.toWorkflowServiceStubsOptions()); + testEnvironmentOptions.setWorkflowClientOptions(profile.toWorkflowClientOptions()); + return testEnvironmentOptions; + } + + private static ClientConfigProfile loadEnvConfigProfile() { + ClientConfigProfile profile; + try { + profile = ClientConfigProfile.load(); + } catch (IOException e) { + throw new IllegalStateException( + "Unable to load client configuration for the Temporal test harness.", e); + } + validateEnvConfigProfile(profile); + return profile; + } + + private static void validateEnvConfigProfile(ClientConfigProfile profile) { + if (profile.getAddress() == null || profile.getAddress().isEmpty()) { + throw new IllegalStateException("Envconfig test harness requires a Temporal server address."); + } + if (profile.getNamespace() == null || profile.getNamespace().isEmpty()) { + throw new IllegalStateException("Envconfig test harness requires a Temporal namespace."); + } + } } diff --git a/temporal-testing/src/test/java/io/temporal/testing/internal/ExternalServiceTestConfiguratorTest.java b/temporal-testing/src/test/java/io/temporal/testing/internal/ExternalServiceTestConfiguratorTest.java new file mode 100644 index 0000000000..6dd808a18d --- /dev/null +++ b/temporal-testing/src/test/java/io/temporal/testing/internal/ExternalServiceTestConfiguratorTest.java @@ -0,0 +1,97 @@ +package io.temporal.testing.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.grpc.Metadata; +import io.temporal.envconfig.ClientConfigProfile; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.testing.TestEnvironmentOptions; +import io.temporal.testing.TestWorkflowRule; +import org.junit.jupiter.api.Test; + +public class ExternalServiceTestConfiguratorTest { + + @Test + public void configureTestWorkflowRuleFromEnvConfig() { + TestWorkflowRule rule = + ExternalServiceTestConfigurator.configureFromEnvConfig( + TestWorkflowRule.newBuilder(), newProfile()) + .build(); + try { + assertEquals( + "envconfig-address:7233", rule.getWorkflowServiceStubs().getOptions().getTarget()); + assertEquals("envconfig-namespace", rule.getWorkflowClient().getOptions().getNamespace()); + } finally { + rule.getTestEnvironment().close(); + } + } + + @Test + public void configureTestEnvironmentFromEnvConfig() { + TestEnvironmentOptions options = + ExternalServiceTestConfigurator.configureFromEnvConfig( + TestEnvironmentOptions.newBuilder(), newProfile()) + .build(); + + assertTrue(options.isUseExternalService()); + assertEquals("envconfig-address:7233", options.getTarget()); + assertEquals("envconfig-address:7233", options.getWorkflowServiceStubsOptions().getTarget()); + assertEquals("envconfig-namespace", options.getWorkflowClientOptions().getNamespace()); + assertTrue(options.getWorkflowServiceStubsOptions().getEnableHttps()); + + Metadata metadata = metadata(options.getWorkflowServiceStubsOptions()); + assertEquals( + "metadata-value", + metadata.get(Metadata.Key.of("test-header", Metadata.ASCII_STRING_MARSHALLER))); + assertEquals( + "Bearer api-key", + metadata.get(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER))); + } + + @Test + public void requireAddressAndNamespaceInEnvConfigMode() { + ClientConfigProfile missingAddress = + ClientConfigProfile.newBuilder().setNamespace("envconfig-namespace").build(); + IllegalStateException missingAddressException = + assertThrows( + IllegalStateException.class, + () -> + ExternalServiceTestConfigurator.configureFromEnvConfig( + TestEnvironmentOptions.newBuilder(), missingAddress)); + assertEquals( + "Envconfig test harness requires a Temporal server address.", + missingAddressException.getMessage()); + + ClientConfigProfile missingNamespace = + ClientConfigProfile.newBuilder().setAddress("envconfig-address:7233").build(); + IllegalStateException missingNamespaceException = + assertThrows( + IllegalStateException.class, + () -> + ExternalServiceTestConfigurator.configureFromEnvConfig( + TestEnvironmentOptions.newBuilder(), missingNamespace)); + assertEquals( + "Envconfig test harness requires a Temporal namespace.", + missingNamespaceException.getMessage()); + } + + private static ClientConfigProfile newProfile() { + Metadata metadata = new Metadata(); + metadata.put( + Metadata.Key.of("test-header", Metadata.ASCII_STRING_MARSHALLER), "metadata-value"); + return ClientConfigProfile.newBuilder() + .setAddress("envconfig-address:7233") + .setNamespace("envconfig-namespace") + .setApiKey("api-key") + .setMetadata(metadata) + .build(); + } + + private static Metadata metadata(WorkflowServiceStubsOptions options) { + Metadata metadata = new Metadata(); + options.getGrpcMetadataProviders().forEach(provider -> metadata.merge(provider.getMetadata())); + return metadata; + } +} From 8d6c936e3ec64c9a5f8f505f5d460bbbc55a923e Mon Sep 17 00:00:00 2001 From: Thomas Hardy Date: Thu, 3 Sep 2026 13:26:54 -0400 Subject: [PATCH 082/107] Provision isolated Cloud namespaces for SDK tests (#3032) * Add envconfig support to test harness * Keep envconfig harness PR focused * Preserve test options with envconfig * Handle envconfig profiles without metadata * Simplify envconfig test harness integration * Provision Cloud namespaces for SDK tests --- .github/workflows/ci.yml | 69 +++++- temporal-sdk/build.gradle | 24 ++ .../client/CloudTestNamespaceManager.java | 209 ++++++++++++++++++ .../client/CloudTestNamespaceManagerTest.java | 92 ++++++++ 4 files changed, 386 insertions(+), 8 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c147f7e9a..173d54c93c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,7 +114,7 @@ jobs: unit_test_cloud: name: Unit test with cloud runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 60 steps: - name: Checkout repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -132,17 +132,70 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@ac396bf1a80af16236baf54bd7330ae21dc6ece5 # v6 + - name: Check Cloud test eligibility + id: cloud-test-eligibility + # Secrets are unavailable to Dependabot and pull requests from forks. + if: ${{ github.actor != 'dependabot[bot]' && (github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-java') }} + env: + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + run: | + if [[ -n "$TEMPORAL_CLIENT_CLOUD_API_KEY" ]]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "::notice title=Cloud tests skipped::TEMPORAL_CLIENT_CLOUD_API_KEY is unavailable" + fi + + - name: Generate Cloud test certificates + if: ${{ steps.cloud-test-eligibility.outputs.enabled == 'true' }} + run: | + cert_dir="$RUNNER_TEMP/cloud-test-certs" + mkdir "$cert_dir" + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout "$cert_dir/ca.key" -out "$cert_dir/ca.pem" \ + -subj '/CN=Temporal Java SDK Cloud CI CA' + openssl req -newkey rsa:2048 -nodes \ + -keyout "$cert_dir/client.key" -out "$cert_dir/client.csr" \ + -subj '/CN=Temporal Java SDK Cloud CI' + openssl x509 -req -days 1 -in "$cert_dir/client.csr" \ + -CA "$cert_dir/ca.pem" -CAkey "$cert_dir/ca.key" -CAcreateserial \ + -out "$cert_dir/client.pem" -extfile <(printf 'extendedKeyUsage=clientAuth') + { + echo "TEMPORAL_CLOUD_CLIENT_CA_PATH=$cert_dir/ca.pem" + echo "TEMPORAL_TLS_CLIENT_CERT_PATH=$cert_dir/client.pem" + echo "TEMPORAL_TLS_CLIENT_KEY_PATH=$cert_dir/client.key" + } >> "$GITHUB_ENV" + + - name: Create Cloud namespace + id: create-cloud-namespace + if: ${{ steps.cloud-test-eligibility.outputs.enabled == 'true' }} + env: + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 + run: ./gradlew --no-daemon :temporal-sdk:createCloudTestNamespace + - name: Run cloud test - # Only supported in non-fork runs, since secrets are not available in forks. We intentionally - # are only doing this check on the step instead of the job so we require job passing in CI - # even for those that can't run this step. - if: ${{ github.event.pull_request.head.repo.full_name == '' || github.event.pull_request.head.repo.full_name == 'temporalio/sdk-java' }} + if: ${{ steps.cloud-test-eligibility.outputs.enabled == 'true' }} + timeout-minutes: 15 env: USER: unittest - TEMPORAL_CLIENT_CLOUD_NAMESPACE: sdk-ci.a2dd6 + TEMPORAL_TEST_ENV_CONFIG_SERVER: "true" + TEMPORAL_ADDRESS: ${{ steps.create-cloud-namespace.outputs.namespace }}.tmprl.cloud:7233 + TEMPORAL_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }} + TEMPORAL_CLIENT_CLOUD_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }} + TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 + run: | + ./gradlew --no-daemon :temporal-sdk:test \ + --tests '*CloudOperationsClientTest' \ + --tests 'io.temporal.client.functional.SignalTest.signalCompletedWorkflow' + + - name: Delete Cloud namespace + if: ${{ always() && steps.create-cloud-namespace.outputs.namespace != '' }} + env: TEMPORAL_CLIENT_CLOUD_API_KEY: ${{ secrets.TEMPORAL_CLIENT_CLOUD_API_KEY }} - TEMPORAL_CLIENT_CLOUD_API_VERSION: 2024-05-13-00 - run: ./gradlew --no-daemon :temporal-sdk:test --tests '*CloudOperationsClientTest' + TEMPORAL_CLIENT_CLOUD_API_VERSION: v0.19.1 + TEMPORAL_CLOUD_TEST_NAMESPACE: ${{ steps.create-cloud-namespace.outputs.namespace }} + run: ./gradlew --no-daemon :temporal-sdk:deleteCloudTestNamespace - name: Publish Test Report uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6 diff --git a/temporal-sdk/build.gradle b/temporal-sdk/build.gradle index 7fe5441252..d981a0c20a 100644 --- a/temporal-sdk/build.gradle +++ b/temporal-sdk/build.gradle @@ -150,6 +150,30 @@ task registerNamespace(type: JavaExec) { test.dependsOn 'registerNamespace' +tasks.register('createCloudTestNamespace', JavaExec) { + group = 'verification' + description = 'Creates an isolated Temporal Cloud namespace for SDK tests.' + dependsOn testClasses + getMainClass().set('io.temporal.client.CloudTestNamespaceManager') + classpath = sourceSets.test.runtimeClasspath + args 'create' +} + +tasks.register('deleteCloudTestNamespace', JavaExec) { + group = 'verification' + description = 'Deletes the isolated Temporal Cloud namespace used by SDK tests.' + dependsOn testClasses + getMainClass().set('io.temporal.client.CloudTestNamespaceManager') + classpath = sourceSets.test.runtimeClasspath + doFirst { + String namespace = System.getenv('TEMPORAL_CLOUD_TEST_NAMESPACE') + if (namespace == null || namespace.isEmpty()) { + throw new GradleException('TEMPORAL_CLOUD_TEST_NAMESPACE must be set.') + } + setArgs(['delete', namespace]) + } +} + test { useJUnit { excludeCategories 'io.temporal.worker.IndependentResourceBasedTests' diff --git a/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java new file mode 100644 index 0000000000..524cda36c7 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManager.java @@ -0,0 +1,209 @@ +package io.temporal.client; + +import com.google.protobuf.ByteString; +import com.google.protobuf.util.Durations; +import io.temporal.api.cloud.cloudservice.v1.CloudServiceGrpc; +import io.temporal.api.cloud.cloudservice.v1.CreateNamespaceRequest; +import io.temporal.api.cloud.cloudservice.v1.CreateNamespaceResponse; +import io.temporal.api.cloud.cloudservice.v1.DeleteNamespaceRequest; +import io.temporal.api.cloud.cloudservice.v1.DeleteNamespaceResponse; +import io.temporal.api.cloud.cloudservice.v1.GetAsyncOperationRequest; +import io.temporal.api.cloud.cloudservice.v1.GetAsyncOperationResponse; +import io.temporal.api.cloud.cloudservice.v1.GetNamespaceRequest; +import io.temporal.api.cloud.cloudservice.v1.GetNamespaceResponse; +import io.temporal.api.cloud.namespace.v1.MtlsAuthSpec; +import io.temporal.api.cloud.namespace.v1.NamespaceSpec; +import io.temporal.api.cloud.namespace.v1.ReplicaSpec; +import io.temporal.api.cloud.operation.v1.AsyncOperation; +import io.temporal.serviceclient.CloudServiceStubs; +import io.temporal.serviceclient.CloudServiceStubsOptions; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** Creates and deletes an isolated Temporal Cloud namespace for SDK CI. */ +public final class CloudTestNamespaceManager { + static final String CLOUD_REGION = "aws-ca-central-1"; + static final Duration OPERATION_TIMEOUT = Duration.ofMinutes(10); + static final Duration DEFAULT_POLL_DELAY = Duration.ofSeconds(10); + static final Duration MIN_POLL_DELAY = Duration.ofSeconds(1); + + private CloudTestNamespaceManager() {} + + public static void main(String[] args) throws Exception { + boolean createRequested = args.length == 1 && "create".equals(args[0]); + boolean deleteRequested = args.length == 2 && "delete".equals(args[0]); + if (!createRequested && !deleteRequested) { + throw new IllegalArgumentException( + "Usage: CloudTestNamespaceManager create | delete "); + } + + CloudServiceStubs serviceStubs = connect(); + try { + CloudServiceGrpc.CloudServiceBlockingStub cloudService = + CloudOperationsClient.newInstance(serviceStubs).getCloudServiceStubs().blockingStub(); + if (createRequested) { + create(cloudService); + } else { + delete(cloudService, args[1]); + } + } finally { + serviceStubs.shutdownNow(); + } + } + + private static void create(CloudServiceGrpc.CloudServiceBlockingStub cloudService) + throws Exception { + String namespaceName = + "sdk-java-ci-" + + requiredEnvironmentVariable("GITHUB_RUN_ID") + + "-" + + requiredEnvironmentVariable("GITHUB_RUN_ATTEMPT"); + byte[] clientCa = + Files.readAllBytes(Paths.get(requiredEnvironmentVariable("TEMPORAL_CLOUD_CLIENT_CA_PATH"))); + + CreateNamespaceResponse response = + cloudService.createNamespace(createNamespaceRequest(namespaceName, clientCa)); + if (response.getNamespace().isEmpty()) { + throw new IllegalStateException("Create namespace response did not include a namespace."); + } + + // Persist the namespace before polling so cleanup can run if provisioning later fails. + Files.write( + Paths.get(requiredEnvironmentVariable("GITHUB_OUTPUT")), + ("namespace=" + response.getNamespace() + System.lineSeparator()) + .getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); + waitForOperation(cloudService, response.getAsyncOperation()); + } + + private static void delete( + CloudServiceGrpc.CloudServiceBlockingStub cloudService, String namespace) throws Exception { + if (namespace == null || namespace.isEmpty()) { + throw new IllegalArgumentException("Namespace to delete must not be empty."); + } + GetNamespaceResponse existing = + cloudService.getNamespace(GetNamespaceRequest.newBuilder().setNamespace(namespace).build()); + String resourceVersion = existing.getNamespace().getResourceVersion(); + if (resourceVersion.isEmpty()) { + throw new IllegalStateException( + "Cloud namespace " + namespace + " did not include a resource version."); + } + + DeleteNamespaceResponse response = + cloudService.deleteNamespace(deleteNamespaceRequest(namespace, resourceVersion)); + waitForOperation(cloudService, response.getAsyncOperation()); + } + + static CreateNamespaceRequest createNamespaceRequest(String namespaceName, byte[] clientCa) { + return CreateNamespaceRequest.newBuilder() + .setSpec( + NamespaceSpec.newBuilder() + .setName(namespaceName) + .setRetentionDays(1) + .addReplicas(ReplicaSpec.newBuilder().setRegion(CLOUD_REGION)) + .setMtlsAuth( + MtlsAuthSpec.newBuilder() + .setAcceptedClientCa(ByteString.copyFrom(clientCa)) + .setEnabled(true))) + .build(); + } + + static DeleteNamespaceRequest deleteNamespaceRequest(String namespace, String resourceVersion) { + return DeleteNamespaceRequest.newBuilder() + .setNamespace(namespace) + .setResourceVersion(resourceVersion) + .build(); + } + + static void waitForOperation( + CloudServiceGrpc.CloudServiceBlockingStub cloudService, AsyncOperation initialOperation) + throws InterruptedException { + String operationId = initialOperation.getId(); + if (operationId.isEmpty()) { + throw new IllegalStateException("Cloud operation response did not include an ID."); + } + + long deadline = System.nanoTime() + OPERATION_TIMEOUT.toNanos(); + while (true) { + if (System.nanoTime() >= deadline) { + throw new IllegalStateException( + "Timed out waiting for Cloud operation " + operationId + "."); + } + + GetAsyncOperationResponse response = + cloudService.getAsyncOperation( + GetAsyncOperationRequest.newBuilder().setAsyncOperationId(operationId).build()); + if (!response.hasAsyncOperation()) { + throw new IllegalStateException("Cloud operation " + operationId + " could not be read."); + } + AsyncOperation operation = response.getAsyncOperation(); + if (operationComplete(operation)) { + return; + } + + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0) { + throw new IllegalStateException( + "Timed out waiting for Cloud operation " + operationId + "."); + } + long remainingMillis = Math.max(TimeUnit.NANOSECONDS.toMillis(remainingNanos), 1); + try { + Thread.sleep(Math.min(pollDelayMillis(operation), remainingMillis)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + + static boolean operationComplete(AsyncOperation operation) { + switch (operation.getState()) { + case STATE_FULFILLED: + return true; + case STATE_FAILED: + case STATE_CANCELLED: + case STATE_REJECTED: + throw new IllegalStateException( + "Cloud operation " + + operation.getId() + + " " + + operation.getState() + + ": " + + operation.getFailureReason()); + default: + return false; + } + } + + static long pollDelayMillis(AsyncOperation operation) { + long delayMillis = + operation.hasCheckDuration() + ? Durations.toMillis(operation.getCheckDuration()) + : DEFAULT_POLL_DELAY.toMillis(); + return Math.max(delayMillis, MIN_POLL_DELAY.toMillis()); + } + + private static CloudServiceStubs connect() { + String apiKey = requiredEnvironmentVariable("TEMPORAL_CLIENT_CLOUD_API_KEY"); + String apiVersion = requiredEnvironmentVariable("TEMPORAL_CLIENT_CLOUD_API_VERSION"); + return CloudServiceStubs.newServiceStubs( + CloudServiceStubsOptions.newBuilder() + .addApiKey(() -> apiKey) + .setVersion(apiVersion) + .setRpcTimeout(Duration.ofSeconds(30)) + .build()); + } + + private static String requiredEnvironmentVariable(String name) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) { + throw new IllegalStateException("Missing required environment variable " + name + "."); + } + return value; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java new file mode 100644 index 0000000000..641b4da051 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/CloudTestNamespaceManagerTest.java @@ -0,0 +1,92 @@ +package io.temporal.client; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.google.protobuf.util.Durations; +import io.temporal.api.cloud.cloudservice.v1.CreateNamespaceRequest; +import io.temporal.api.cloud.cloudservice.v1.DeleteNamespaceRequest; +import io.temporal.api.cloud.operation.v1.AsyncOperation; +import org.junit.Test; + +public class CloudTestNamespaceManagerTest { + + @Test + public void createNamespaceRequestUsesIsolatedMtlsSpec() { + byte[] clientCa = new byte[] {1, 2, 3}; + + CreateNamespaceRequest request = + CloudTestNamespaceManager.createNamespaceRequest("sdk-java-ci-123-2", clientCa); + + assertEquals("sdk-java-ci-123-2", request.getSpec().getName()); + assertEquals(1, request.getSpec().getRetentionDays()); + assertEquals(1, request.getSpec().getReplicasCount()); + assertEquals( + CloudTestNamespaceManager.CLOUD_REGION, request.getSpec().getReplicas(0).getRegion()); + assertTrue(request.getSpec().getMtlsAuth().getEnabled()); + assertArrayEquals( + clientCa, request.getSpec().getMtlsAuth().getAcceptedClientCa().toByteArray()); + } + + @Test + public void deleteNamespaceRequestUsesResourceVersion() { + DeleteNamespaceRequest request = + CloudTestNamespaceManager.deleteNamespaceRequest( + "sdk-java-ci-123-2.account", "resource-version"); + + assertEquals("sdk-java-ci-123-2.account", request.getNamespace()); + assertEquals("resource-version", request.getResourceVersion()); + } + + @Test + public void operationStatesDistinguishPendingFulfilledAndRejected() { + assertFalse( + CloudTestNamespaceManager.operationComplete(operation(AsyncOperation.State.STATE_PENDING))); + assertTrue( + CloudTestNamespaceManager.operationComplete( + operation(AsyncOperation.State.STATE_FULFILLED))); + + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + CloudTestNamespaceManager.operationComplete( + operation(AsyncOperation.State.STATE_REJECTED).toBuilder() + .setFailureReason("not allowed") + .build())); + assertTrue(failure.getMessage().contains("STATE_REJECTED")); + assertTrue(failure.getMessage().contains("not allowed")); + } + + @Test + public void pollingUsesDefaultAndMinimumDelays() { + assertEquals( + CloudTestNamespaceManager.DEFAULT_POLL_DELAY.toMillis(), + CloudTestNamespaceManager.pollDelayMillis(operation(AsyncOperation.State.STATE_PENDING))); + assertEquals( + CloudTestNamespaceManager.MIN_POLL_DELAY.toMillis(), + CloudTestNamespaceManager.pollDelayMillis( + operation(AsyncOperation.State.STATE_PENDING).toBuilder() + .setCheckDuration(Durations.fromMillis(100)) + .build())); + } + + @Test + public void pollingRequiresOperationId() { + IllegalStateException failure = + assertThrows( + IllegalStateException.class, + () -> + CloudTestNamespaceManager.waitForOperation( + null, AsyncOperation.getDefaultInstance())); + + assertEquals("Cloud operation response did not include an ID.", failure.getMessage()); + } + + private static AsyncOperation operation(AsyncOperation.State state) { + return AsyncOperation.newBuilder().setId("operation-id").setState(state).build(); + } +} From dc0ad21c1da1886d3b99314aa434f81a84ad7b37 Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:44:44 -0700 Subject: [PATCH 083/107] Add workflow task completion pagination (#3051) --- .../worker/NamespaceCapabilities.java | 31 ++++ .../WorkflowTaskCompletionPaginator.java | 113 ++++++++++++ .../internal/worker/WorkflowWorker.java | 173 ++++++++++++++++-- .../io/temporal/worker/WorkerFactory.java | 1 + .../WorkflowTaskCompletionPaginatorTest.java | 120 ++++++++++++ ...skCompletionPaginationIntegrationTest.java | 107 +++++++++++ .../io/temporal/serviceclient/MetricsTag.java | 1 + 7 files changed, 534 insertions(+), 12 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginator.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginatorTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/worker/WorkflowTaskCompletionPaginationIntegrationTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java index 4bddd45d9e..e9866089dc 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NamespaceCapabilities.java @@ -1,7 +1,9 @@ package io.temporal.internal.worker; import io.temporal.api.namespace.v1.NamespaceInfo.Capabilities; +import io.temporal.api.namespace.v1.NamespaceInfo.Limits; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; /** * Holds namespace-level capabilities discovered from the server's DescribeNamespace response. A @@ -14,6 +16,8 @@ public final class NamespaceCapabilities { private final AtomicBoolean gracefulPollShutdown = new AtomicBoolean(false); private final AtomicBoolean workerHeartbeats = new AtomicBoolean(false); private final AtomicBoolean workerCommands = new AtomicBoolean(false); + private final AtomicBoolean workflowTaskCompletionPagination = new AtomicBoolean(false); + private final AtomicLong workflowTaskCompletionSizeLimit = new AtomicLong(0); public void setFromCapabilities(Capabilities capabilities) { if (capabilities.getPollerAutoscalingAutoEnroll()) { @@ -31,6 +35,13 @@ public void setFromCapabilities(Capabilities capabilities) { if (capabilities.getWorkerCommands()) { workerCommands.set(true); } + if (capabilities.getWorkflowTaskCompletionPagination()) { + workflowTaskCompletionPagination.set(true); + } + } + + public void setFromLimits(Limits limits) { + workflowTaskCompletionSizeLimit.set(limits.getWorkflowTaskCompletionSizeLimitError()); } public boolean isPollerAutoscaling() { @@ -64,4 +75,24 @@ public boolean isWorkerCommands() { public void setWorkerCommands(boolean value) { workerCommands.set(value); } + + public boolean isWorkflowTaskCompletionPagination() { + return workflowTaskCompletionPagination.get(); + } + + public void setWorkflowTaskCompletionPagination(boolean value) { + workflowTaskCompletionPagination.set(value); + } + + /** + * The namespace's limit on the recombined size in bytes of a single workflow task completion, or + * 0 when the namespace advertises no explicit limit. + */ + public long getWorkflowTaskCompletionSizeLimit() { + return workflowTaskCompletionSizeLimit.get(); + } + + public void setWorkflowTaskCompletionSizeLimit(long value) { + workflowTaskCompletionSizeLimit.set(value); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginator.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginator.java new file mode 100644 index 0000000000..d72fef25d1 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginator.java @@ -0,0 +1,113 @@ +package io.temporal.internal.worker; + +import io.temporal.api.command.v1.Command; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import java.util.ArrayList; +import java.util.List; + +/** + * Splits an oversized {@link RespondWorkflowTaskCompletedRequest} into pages that each stay under + * the gRPC request size limit, so a completion carrying more command bytes than a single request + * can hold is delivered across multiple requests sharing one task token. The server buffers the + * commands of the intermediate pages and merges them with the final page when it arrives. + */ +final class WorkflowTaskCompletionPaginator { + + /** + * Maximum encoded size of a single completion page, kept below the ~4 MiB gRPC frame limit. This + * per-page cap is distinct from the namespace's limit on the recombined completion size. + * + *

Pages are packed by summing command body sizes only; the 512 KiB of headroom below 4 MiB + * absorbs everything that sum omits: the per-request overhead (task token, identity, namespace) + * and the per-command wire framing (a field tag plus a length varint, up to 6 bytes each). At the + * server's default per-workflow history-count limit (~51,200 events), worst-case framing is ~300 + * KiB, so this headroom covers even a page of many tiny commands and lets us skip per-command + * accounting. + */ + static final int MAX_PAGE_BYTES = 4 * 1024 * 1024 - 512 * 1024; + + /** The result of splitting a completion: zero or more intermediate pages plus the final page. */ + static final class Pages { + final List intermediatePages; + final RespondWorkflowTaskCompletedRequest finalPage; + + Pages( + List intermediatePages, + RespondWorkflowTaskCompletedRequest finalPage) { + this.intermediatePages = intermediatePages; + this.finalPage = finalPage; + } + + /** True when the completion was split; false when the final page should be sent as-is. */ + boolean isPaginated() { + return !intermediatePages.isEmpty(); + } + } + + /** + * Splits {@code request} into intermediate pages that each stay under {@code maxPageBytes} by + * distributing its commands across them in order. The final page carries the remaining metadata + * and messages, and its page number is the count of intermediate pages. + * + *

Returns a {@link Pages} with no intermediate pages (send {@code request} as-is) when the + * request already fits, has no commands to distribute, or has a single command that alone exceeds + * a page (which the server then rejects). + */ + static Pages paginate(RespondWorkflowTaskCompletedRequest request, int maxPageBytes) { + if (request.getSerializedSize() <= maxPageBytes) { + return new Pages(new ArrayList<>(), request); + } + + List commands = request.getCommandsList(); + // Only commands can be split across pages, so pagination cannot help when there are none, or + // when + // a single command alone exceeds a page. + if (commands.isEmpty()) { + return new Pages(new ArrayList<>(), request); + } + for (Command command : commands) { + if (command.getSerializedSize() > maxPageBytes) { + return new Pages(new ArrayList<>(), request); + } + } + + List intermediatePages = new ArrayList<>(); + List current = new ArrayList<>(); + int currentLen = 0; + for (Command command : commands) { + int commandLen = command.getSerializedSize(); + if (!current.isEmpty() && currentLen + commandLen > maxPageBytes) { + intermediatePages.add(newIntermediatePage(request, current, intermediatePages.size())); + current = new ArrayList<>(); + currentLen = 0; + } + currentLen += commandLen; + current.add(command); + } + if (!current.isEmpty()) { + intermediatePages.add(newIntermediatePage(request, current, intermediatePages.size())); + } + + RespondWorkflowTaskCompletedRequest finalPage = + request.toBuilder() + .clearCommands() + .setPageNumber(intermediatePages.size()) + .setIntermediatePage(false) + .build(); + return new Pages(intermediatePages, finalPage); + } + + private static RespondWorkflowTaskCompletedRequest newIntermediatePage( + RespondWorkflowTaskCompletedRequest request, List commands, int pageNumber) { + return RespondWorkflowTaskCompletedRequest.newBuilder() + .setTaskToken(request.getTaskToken()) + .setIdentity(request.getIdentity()) + .setNamespace(request.getNamespace()) + .setIntermediatePage(true) + .setPageNumber(pageNumber) + .addAllCommands(commands) + .build(); + } + + private WorkflowTaskCompletionPaginator() {} +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index 98660034d4..1da77d85eb 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -9,11 +9,14 @@ import com.uber.m3.tally.Scope; import com.uber.m3.tally.Stopwatch; import com.uber.m3.util.ImmutableMap; +import io.grpc.Status; import io.grpc.StatusRuntimeException; +import io.temporal.api.command.v1.Command; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.QueryResultType; import io.temporal.api.enums.v1.TaskQueueKind; import io.temporal.api.enums.v1.WorkflowTaskFailedCause; +import io.temporal.api.errordetails.v1.WorkflowTaskCompletionBufferLostFailure; import io.temporal.api.failure.v1.Failure; import io.temporal.api.workflowservice.v1.*; import io.temporal.failure.ApplicationFailure; @@ -23,6 +26,7 @@ import io.temporal.payload.context.WorkflowSerializationContext; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.RpcRetryOptions; +import io.temporal.serviceclient.StatusUtils; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.worker.*; import io.temporal.worker.tuning.*; @@ -39,6 +43,13 @@ final class WorkflowWorker implements SuspendableWorker { private static final Logger log = LoggerFactory.getLogger(WorkflowWorker.class); + // Backoff between resends of a paginated completion after the server reports its buffered pages + // were lost. Buffer loss is transient; the loop is bounded by the server eventually timing the + // task + // out (after which the stale token fails with a different error) or by worker shutdown. + private static final long WFT_COMPLETION_PAGE_RESEND_INITIAL_BACKOFF_MS = 100; + private static final long WFT_COMPLETION_PAGE_RESEND_MAX_BACKOFF_MS = 5000; + private final WorkflowRunLockManager runLocks; private final WorkflowServiceStubs service; @@ -477,7 +488,28 @@ public void handle(WorkflowTask task) throws Exception { } } else { try { - if (taskCompleted != null) { + WorkflowTaskFailedCause requestTooLargeCause = + taskCompleted == null + ? null + : completionExceedingSizeLimitCause(taskCompleted); + if (requestTooLargeCause != null) { + // A completion whose recombined command bytes exceed the namespace limit would + // be + // rejected and the workflow terminated by the server, so fail it proactively + // rather than sending doomed pages. + taskFailedCause = requestTooLargeCause; + RespondWorkflowTaskFailedRequest.Builder taskFailedBuilder = + RespondWorkflowTaskFailedRequest.newBuilder() + .setFailure( + requestTooLargeFailure( + workflowExecution.getWorkflowId(), taskCompleted)) + .setCause(requestTooLargeCause); + sendTaskFailed( + currentTask.getTaskToken(), + taskFailedBuilder, + result.getRequestRetryOptions(), + workflowTypeScope); + } else if (taskCompleted != null) { RespondWorkflowTaskCompletedRequest.Builder requestBuilder = taskCompleted.toBuilder(); try (EagerActivitySlotsReservation activitySlotsReservation = @@ -566,6 +598,9 @@ public void handle(WorkflowTask task) throws Exception { case WORKFLOW_TASK_FAILED_CAUSE_GRPC_MESSAGE_TOO_LARGE: taskFailureType = MetricsTag.TASK_FAILURE_VALUE_GRPC_MESSAGE_TOO_LARGE; break; + case WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE: + taskFailureType = MetricsTag.TASK_FAILURE_VALUE_REQUEST_TOO_LARGE; + break; default: taskFailureType = MetricsTag.TASK_FAILURE_VALUE_WORKFLOW_ERROR; } @@ -654,10 +689,6 @@ private RespondWorkflowTaskCompletedResponse sendTaskCompleted( RespondWorkflowTaskCompletedRequest.Builder taskCompleted, RpcRetryOptions retryOptions, Scope workflowTypeMetricsScope) { - GrpcRetryer.GrpcRetryerOptions grpcRetryOptions = - new GrpcRetryer.GrpcRetryerOptions( - RpcRetryOptions.newBuilder().buildWithDefaultsFrom(retryOptions), null); - taskCompleted .setIdentity(options.getIdentity()) .setNamespace(namespace) @@ -676,13 +707,81 @@ private RespondWorkflowTaskCompletedResponse sendTaskCompleted( taskCompleted.setBinaryChecksum(options.getBuildId()); } - return grpcRetryer.retryWithResult( - () -> - service - .blockingStub() - .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, workflowTypeMetricsScope) - .respondWorkflowTaskCompleted(taskCompleted.build()), - grpcRetryOptions); + RespondWorkflowTaskCompletedRequest request = taskCompleted.build(); + GrpcRetryer.GrpcRetryerOptions grpcRetryOptions = + new GrpcRetryer.GrpcRetryerOptions( + RpcRetryOptions.newBuilder().buildWithDefaultsFrom(retryOptions), null); + + if (!namespaceCapabilities.isWorkflowTaskCompletionPagination()) { + return grpcRetryer.retryWithResult( + () -> respondWorkflowTaskCompleted(request, workflowTypeMetricsScope), + grpcRetryOptions); + } + + WorkflowTaskCompletionPaginator.Pages pages = + WorkflowTaskCompletionPaginator.paginate( + request, WorkflowTaskCompletionPaginator.MAX_PAGE_BYTES); + if (!pages.isPaginated()) { + return grpcRetryer.retryWithResult( + () -> respondWorkflowTaskCompleted(pages.finalPage, workflowTypeMetricsScope), + grpcRetryOptions); + } + return sendPaginatedTaskCompleted(pages, retryOptions, workflowTypeMetricsScope); + } + + /** + * Sends a paginated completion, resending every page from page 0 on buffer loss. Buffer loss — + * the server dropping the pages it had buffered for this token — is transient, so this backs + * off and retries. The gRPC retry layer does not retry buffer loss (it is excluded via a + * DoNotRetryItem below), so this loop is its sole handler; it bails on worker shutdown, and the + * server bounds it by eventually timing the task out. + */ + private RespondWorkflowTaskCompletedResponse sendPaginatedTaskCompleted( + WorkflowTaskCompletionPaginator.Pages pages, + RpcRetryOptions retryOptions, + Scope workflowTypeMetricsScope) { + // Buffer loss requires resending every page, which a single-page gRPC retry cannot do, so it + // is handled by this loop instead of the retryer. + GrpcRetryer.GrpcRetryerOptions pageRetryOptions = + new GrpcRetryer.GrpcRetryerOptions( + RpcRetryOptions.newBuilder( + RpcRetryOptions.newBuilder().buildWithDefaultsFrom(retryOptions)) + .addDoNotRetry(Status.Code.ABORTED, WorkflowTaskCompletionBufferLostFailure.class) + .validateBuildWithDefaults(), + null); + long backoffMs = WFT_COMPLETION_PAGE_RESEND_INITIAL_BACKOFF_MS; + while (true) { + try { + for (RespondWorkflowTaskCompletedRequest page : pages.intermediatePages) { + grpcRetryer.retryWithResult( + () -> respondWorkflowTaskCompleted(page, workflowTypeMetricsScope), + pageRetryOptions); + } + return grpcRetryer.retryWithResult( + () -> respondWorkflowTaskCompleted(pages.finalPage, workflowTypeMetricsScope), + pageRetryOptions); + } catch (StatusRuntimeException e) { + if (!StatusUtils.hasFailure(e, WorkflowTaskCompletionBufferLostFailure.class) + || isShutdown()) { + throw e; + } + try { + Thread.sleep(backoffMs); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw e; + } + backoffMs = Math.min(backoffMs * 2, WFT_COMPLETION_PAGE_RESEND_MAX_BACKOFF_MS); + } + } + } + + private RespondWorkflowTaskCompletedResponse respondWorkflowTaskCompleted( + RespondWorkflowTaskCompletedRequest request, Scope workflowTypeMetricsScope) { + return service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, workflowTypeMetricsScope) + .respondWorkflowTaskCompleted(request); } @SuppressWarnings("deprecation") @@ -760,6 +859,56 @@ private void handleReportingFailure( workflowExecution, workflowTypeScope, "Failed result reporting to the server", e); } + /** + * Returns the fail cause when {@code taskCompleted}'s recombined command bytes exceed the + * namespace's completion size limit, or null otherwise. The limit governs the server's + * recombined page buffer, so it only applies when pagination is enabled and the completion is + * large enough to be paginated; a completion that fits in a single request is never buffered + * and is left for the server to accept. Only command bytes count toward the limit, not messages + * or metadata. + */ + private WorkflowTaskFailedCause completionExceedingSizeLimitCause( + RespondWorkflowTaskCompletedRequest taskCompleted) { + if (!namespaceCapabilities.isWorkflowTaskCompletionPagination() + || taskCompleted.getSerializedSize() <= WorkflowTaskCompletionPaginator.MAX_PAGE_BYTES) { + return null; + } + long sizeLimit = namespaceCapabilities.getWorkflowTaskCompletionSizeLimit(); + if (sizeLimit <= 0) { + return null; + } + long commandBytes = 0; + for (Command command : taskCompleted.getCommandsList()) { + commandBytes += command.getSerializedSize(); + } + if (commandBytes <= sizeLimit) { + return null; + } + return WorkflowTaskFailedCause.WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE; + } + + private Failure requestTooLargeFailure( + String workflowId, RespondWorkflowTaskCompletedRequest taskCompleted) { + long commandBytes = 0; + for (Command command : taskCompleted.getCommandsList()) { + commandBytes += command.getSerializedSize(); + } + String message = + String.format( + "Workflow task completion command size %d exceeds the namespace limit of %d bytes", + commandBytes, namespaceCapabilities.getWorkflowTaskCompletionSizeLimit()); + ApplicationFailure applicationFailure = + ApplicationFailure.newBuilder() + .setMessage(message) + .setType("WorkflowTaskCompletionRequestTooLarge") + .build(); + applicationFailure.setStackTrace(new StackTraceElement[0]); // don't serialize stack trace + return options + .getDataConverter() + .withContext(new WorkflowSerializationContext(namespace, workflowId)) + .exceptionToFailure(applicationFailure); + } + private Failure grpcMessageTooLargeFailure( String workflowId, GrpcMessageTooLargeException e, String messagePrefix) { ApplicationFailure applicationFailure = diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java index 70bcf28c76..b6fa12650f 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java @@ -275,6 +275,7 @@ public synchronized void start() { .build()); namespaceCapabilities.setFromCapabilities( describeNamespaceResponse.getNamespaceInfo().getCapabilities()); + namespaceCapabilities.setFromLimits(describeNamespaceResponse.getNamespaceInfo().getLimits()); // Build plugin execution chain (reverse order for proper nesting) Consumer startChain = WorkerFactory::doStart; diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginatorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginatorTest.java new file mode 100644 index 0000000000..e77c93d713 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowTaskCompletionPaginatorTest.java @@ -0,0 +1,120 @@ +package io.temporal.internal.worker; + +import static org.junit.Assert.*; + +import com.google.protobuf.ByteString; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.RecordMarkerCommandAttributes; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.enums.v1.CommandType; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import java.util.ArrayList; +import java.util.List; +import org.junit.Test; + +public class WorkflowTaskCompletionPaginatorTest { + + private static Command commandWithPayload(int dataSize) { + return Command.newBuilder() + .setCommandType(CommandType.COMMAND_TYPE_RECORD_MARKER) + .setRecordMarkerCommandAttributes( + RecordMarkerCommandAttributes.newBuilder() + .setMarkerName("marker") + .putDetails( + "data", + Payloads.newBuilder() + .addPayloads( + Payload.newBuilder().setData(ByteString.copyFrom(new byte[dataSize]))) + .build())) + .build(); + } + + private static RespondWorkflowTaskCompletedRequest requestWith(List commands) { + return RespondWorkflowTaskCompletedRequest.newBuilder() + .setTaskToken(ByteString.copyFromUtf8("task-token")) + .setIdentity("identity") + .setNamespace("namespace") + .addAllCommands(commands) + .build(); + } + + @Test + public void completionWithinLimitIsASingleFinalPage() { + RespondWorkflowTaskCompletedRequest request = + requestWith(java.util.Collections.singletonList(commandWithPayload(16))); + + WorkflowTaskCompletionPaginator.Pages pages = + WorkflowTaskCompletionPaginator.paginate(request, 4096); + + assertFalse(pages.isPaginated()); + assertEquals(0, pages.finalPage.getPageNumber()); + assertFalse(pages.finalPage.getIntermediatePage()); + assertEquals(1, pages.finalPage.getCommandsCount()); + } + + @Test + public void largeCompletionSplitsCommandsAcrossPages() { + int maxPageBytes = 1024; + int commandCount = 6; + List commands = new ArrayList<>(); + for (int i = 0; i < commandCount; i++) { + commands.add(commandWithPayload(400)); + } + RespondWorkflowTaskCompletedRequest request = requestWith(commands); + assertTrue(request.getSerializedSize() > maxPageBytes); + + WorkflowTaskCompletionPaginator.Pages pages = + WorkflowTaskCompletionPaginator.paginate(request, maxPageBytes); + + assertTrue(pages.isPaginated()); + assertFalse(pages.finalPage.getIntermediatePage()); + assertEquals(0, pages.finalPage.getCommandsCount()); + assertEquals(pages.intermediatePages.size(), pages.finalPage.getPageNumber()); + assertTrue(pages.finalPage.getSerializedSize() <= maxPageBytes); + assertEquals(ByteString.copyFromUtf8("task-token"), pages.finalPage.getTaskToken()); + + int totalCommands = 0; + for (int i = 0; i < pages.intermediatePages.size(); i++) { + RespondWorkflowTaskCompletedRequest page = pages.intermediatePages.get(i); + assertTrue(page.getIntermediatePage()); + assertEquals(i, page.getPageNumber()); + assertEquals(ByteString.copyFromUtf8("task-token"), page.getTaskToken()); + assertTrue( + "intermediate page " + i + " over limit", page.getSerializedSize() <= maxPageBytes); + totalCommands += page.getCommandsCount(); + } + // Every command is preserved exactly once across the intermediate pages. + assertEquals(commandCount, totalCommands); + } + + @Test + public void singleCommandLargerThanAPageIsNotSplit() { + int maxPageBytes = 1024; + RespondWorkflowTaskCompletedRequest request = + requestWith(java.util.Collections.singletonList(commandWithPayload(4096))); + + WorkflowTaskCompletionPaginator.Pages pages = + WorkflowTaskCompletionPaginator.paginate(request, maxPageBytes); + + assertFalse(pages.isPaginated()); + assertEquals(1, pages.finalPage.getCommandsCount()); + assertFalse(pages.finalPage.getIntermediatePage()); + } + + @Test + public void noCommandsIsNotSplit() { + RespondWorkflowTaskCompletedRequest request = + RespondWorkflowTaskCompletedRequest.newBuilder() + .setTaskToken(ByteString.copyFromUtf8("task-token")) + .setIdentity("identity") + .setNamespace("namespace") + .build(); + + WorkflowTaskCompletionPaginator.Pages pages = + WorkflowTaskCompletionPaginator.paginate(request, 1); + + assertFalse(pages.isPaginated()); + assertEquals(0, pages.finalPage.getCommandsCount()); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkflowTaskCompletionPaginationIntegrationTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkflowTaskCompletionPaginationIntegrationTest.java new file mode 100644 index 0000000000..52c3140bbc --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkflowTaskCompletionPaginationIntegrationTest.java @@ -0,0 +1,107 @@ +package io.temporal.worker; + +import static org.junit.Assume.assumeTrue; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.api.namespace.v1.NamespaceInfo.Capabilities; +import io.temporal.api.workflowservice.v1.DescribeNamespaceRequest; +import io.temporal.api.workflowservice.v1.DescribeNamespaceResponse; +import io.temporal.client.WorkflowOptions; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Async; +import io.temporal.workflow.Promise; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.Rule; +import org.junit.Test; + +public class WorkflowTaskCompletionPaginationIntegrationTest { + + // Six 1 MiB activity inputs scheduled in a single workflow task produce a ~6 MiB completion, well + // over the ~4 MiB gRPC request limit, so the workflow completes only if the completion is + // paginated. + private static final int ACTIVITY_COUNT = 6; + private static final int ACTIVITY_INPUT_BYTES = 1024 * 1024; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(LargeCompletionWorkflowImpl.class) + .setActivityImplementations(new NoopActivityImpl()) + .build(); + + @Test + public void largeCompletionIsPaginated() { + assumeTrue( + "Requires a real server with workflow task completion pagination support", + SDKTestWorkflowRule.useExternalService); + assumeTrue( + "Server does not support workflow task completion pagination", + getNamespaceCapabilities().getWorkflowTaskCompletionPagination()); + + LargeCompletionWorkflow workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + LargeCompletionWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowExecutionTimeout(Duration.ofMinutes(1)) + .build()); + // Completes without error only when the oversized completion is delivered across pages. + workflow.run(); + } + + private Capabilities getNamespaceCapabilities() { + DescribeNamespaceResponse response = + testWorkflowRule + .getWorkflowClient() + .getWorkflowServiceStubs() + .blockingStub() + .describeNamespace( + DescribeNamespaceRequest.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .build()); + return response.getNamespaceInfo().getCapabilities(); + } + + @WorkflowInterface + public interface LargeCompletionWorkflow { + @WorkflowMethod + void run(); + } + + public static class LargeCompletionWorkflowImpl implements LargeCompletionWorkflow { + private final NoopActivity activity = + Workflow.newActivityStub( + NoopActivity.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + @Override + public void run() { + byte[] input = new byte[ACTIVITY_INPUT_BYTES]; + List> promises = new ArrayList<>(ACTIVITY_COUNT); + for (int i = 0; i < ACTIVITY_COUNT; i++) { + promises.add(Async.procedure(activity::process, input)); + } + Promise.allOf(promises).get(); + } + } + + @ActivityInterface + public interface NoopActivity { + @ActivityMethod + void process(byte[] input); + } + + public static class NoopActivityImpl implements NoopActivity { + @Override + public void process(byte[] input) {} + } +} diff --git a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java index d894709d74..69b64ecc6b 100644 --- a/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java +++ b/temporal-serviceclient/src/main/java/io/temporal/serviceclient/MetricsTag.java @@ -26,6 +26,7 @@ public class MetricsTag { public static final String TASK_FAILURE_TYPE = "failure_reason"; public static final String TASK_FAILURE_VALUE_NON_DETERMINISM_ERROR = "NonDeterminismError"; public static final String TASK_FAILURE_VALUE_GRPC_MESSAGE_TOO_LARGE = "GrpcMessageTooLarge"; + public static final String TASK_FAILURE_VALUE_REQUEST_TOO_LARGE = "RequestTooLarge"; public static final String TASK_FAILURE_VALUE_WORKFLOW_ERROR = "WorkflowError"; public static final String TASK_FAILURE_VALUE_ACTIVITY_ERROR = "ActivityError"; public static final String TASK_FAILURE_VALUE_OPERATION_FAILED = "operation_failed"; From 108462ea6336d4998512a2e6d72541f5e1d1c895 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 4 Sep 2026 12:40:56 -0700 Subject: [PATCH 084/107] Bumping test CLI version (#3054) --- .../functional/StandaloneActivityTest.java | 24 ++++++++++++++++++- .../WorkflowClosedRunningActivityTest.java | 10 +++++--- .../devserver/SdkJavaTestServerProfile.java | 2 +- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java index 5be3226dcd..3cc94cb26a 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java @@ -15,8 +15,11 @@ import io.temporal.api.enums.v1.ActivityExecutionStatus; import io.temporal.api.enums.v1.ActivityIdConflictPolicy; import io.temporal.api.enums.v1.ActivityIdReusePolicy; +import io.temporal.api.workflowservice.v1.DescribeActivityExecutionRequest; +import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.client.*; import io.temporal.common.RetryOptions; +import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.common.interceptors.ActivityClientCallsInterceptor.*; import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; @@ -854,7 +857,26 @@ public void testDescribeLastFailureIsPopulatedDuringRetryBackoff() { assertEventually( Duration.ofSeconds(60), () -> { - ActivityExecutionDescription desc = handle.describe(); + // last_failure carries a payload, so the server returns it only when the + // DescribeActivityExecution request opts in via include_last_failure. handle.describe() + // has no way to request it yet (sdk-java PR #3013 adds DescribeActivityOptions), so the + // request is issued directly here and wrapped in the same description type the handle + // would return, keeping the failure-conversion assertions below intact. + DescribeActivityExecutionResponse raw = + testWorkflowRule + .getWorkflowServiceStubs() + .blockingStub() + .describeActivityExecution( + DescribeActivityExecutionRequest.newBuilder() + .setNamespace(SDKTestWorkflowRule.NAMESPACE) + .setActivityId(handle.getActivityId()) + .setIncludeLastFailure(true) + .build()); + ActivityExecutionDescription desc = + new ActivityExecutionDescription( + raw.getInfo(), + DefaultDataConverter.STANDARD_INSTANCE, + SDKTestWorkflowRule.NAMESPACE); Exception lastFailure = desc.getLastFailure(); assertNotNull("last_failure should be set after a failed attempt", lastFailure); assertThat(lastFailure, instanceOf(ApplicationFailure.class)); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/WorkflowClosedRunningActivityTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/WorkflowClosedRunningActivityTest.java index fcd16f6681..3c8e38dac1 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/WorkflowClosedRunningActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/WorkflowClosedRunningActivityTest.java @@ -105,9 +105,13 @@ public String activity1(String input) { while (true) { try { Activity.getExecutionContext().heartbeat(System.currentTimeMillis() - start); - } catch (ActivityNotExistsException e) { - // in case of the whole workflow gets cancelled, we are getting - // ActivityNotExistsException + } catch (ActivityCompletionException e) { + // The parent type covers both ways the server reports that the workflow has closed. Older + // servers fail the next heartbeat with NOT_FOUND, surfacing as + // ActivityNotExistsException. + // With system.enableCancelActivityWorkerCommand and frontend.workerCommandsEnabled (both + // set by this repository's dev server profile) the server instead pushes a CancelActivity + // worker command, which surfaces as ActivityCanceledException. activityCancelled.signal(); } diff --git a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java index 18215bd2f8..334eb42550 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java +++ b/temporal-testing/src/main/java/io/temporal/testing/internal/devserver/SdkJavaTestServerProfile.java @@ -13,7 +13,7 @@ public final class SdkJavaTestServerProfile { public static final String ACTIVE_PROPERTY = "io.temporal.testing.internal.devServerProfile"; // This is intentionally the sole Temporal CLI version used by sdk-java repository tests. - private static final String TEST_CLI_VERSION = "1.7.4-standalone-nexus-operations"; + private static final String TEST_CLI_VERSION = "1.8.3-server-1.32.0-162.0"; private static final String TEST_NAMESPACE = "UnitTest"; private static final String DATABASE_FILENAME = "temporal.sqlite"; From 081716299b1e387c3ba7042de73497024440ad06 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Fri, 4 Sep 2026 16:52:39 -0400 Subject: [PATCH 085/107] External Storage Integration: NexusWorker (#3018) * feat(extstore): integrate into nexus pipeline. * Explicitly pass cancellation tokens to extstore. * add more cancellation threading * NexusClientImpl integration * externalStorage -> externalStorageRunner * add nexus e2e tests * fix(extstore): handle extstore errors for inbound nexus tasks. report failures properly. * fix(extstore): markSlotUsed before payload retrieval. * fix(extstore): handle outbound extstore failures properly. reports backs a retryable failure. * formatting * add doc comments + test * report extstore failures without extstore * refactor(extstore): add new nexus resolved options that only exposes necessary fields (e.g. data converter) for internal use) * only retry failures once and don't send through external storage. address other minor feedback items. --- .../io/temporal/client/NexusClientImpl.java | 7 +- .../temporal/client/NexusClientOptions.java | 52 +- .../client/NexusServiceClientImpl.java | 5 +- .../client/UntypedNexusServiceClientImpl.java | 3 +- .../client/NexusClientResolvedOptions.java | 41 ++ .../client/RootNexusClientInvoker.java | 5 +- .../temporal/internal/worker/NexusWorker.java | 149 +++++- .../client/NexusClientOptionsTest.java | 70 +++ .../NexusExternalStorageFailureTest.java | 293 +++++++++++ .../nexus/NexusExternalStorageTest.java | 189 +++++++ .../client/RootNexusClientInvokerTest.java | 8 +- .../internal/worker/NexusWorkerTest.java | 461 ++++++++++++++++++ 12 files changed, 1259 insertions(+), 24 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/NexusClientResolvedOptions.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageFailureTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/NexusWorkerTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java index d19c9f13be..a9248e80b1 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientImpl.java @@ -12,6 +12,7 @@ import io.temporal.common.interceptors.NexusClientInterceptor; import io.temporal.internal.WorkflowThreadMarker; import io.temporal.internal.client.NamespaceInjectWorkflowServiceStubs; +import io.temporal.internal.client.NexusClientResolvedOptions; import io.temporal.internal.client.NexusOperationHandleImpl; import io.temporal.internal.client.RootNexusClientInvoker; import io.temporal.internal.client.external.GenericWorkflowClient; @@ -30,7 +31,7 @@ public class NexusClientImpl implements NexusClient { private static final Logger log = LoggerFactory.getLogger(NexusClientImpl.class); private final WorkflowServiceStubs workflowServiceStubs; - private final NexusClientOptions options; + private final NexusClientResolvedOptions options; private final GenericWorkflowClient genericClient; private final Scope metricsScope; private final NexusClientCallsInterceptor nexusClientCallsInvoker; @@ -39,10 +40,10 @@ public class NexusClientImpl implements NexusClient { public static NexusClient newInstance(WorkflowServiceStubs service, NexusClientOptions options) { enforceNonWorkflowThread(); return WorkflowThreadMarker.protectFromWorkflowThread( - new NexusClientImpl(service, options), NexusClient.class); + new NexusClientImpl(service, options.toResolvedOptions()), NexusClient.class); } - NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientOptions options) { + NexusClientImpl(WorkflowServiceStubs workflowServiceStubs, NexusClientResolvedOptions options) { workflowServiceStubs = new NamespaceInjectWorkflowServiceStubs(workflowServiceStubs, options.getNamespace()); this.workflowServiceStubs = workflowServiceStubs; diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java index 9c64fe7ac6..414cd3c568 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusClientOptions.java @@ -4,9 +4,14 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.internal.client.NexusClientResolvedOptions; +import io.temporal.internal.payload.storage.ExternalStorageDataConverter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.storage.ExternalStorage; import java.lang.management.ManagementFactory; import java.util.Collections; import java.util.List; +import javax.annotation.Nullable; /** * Options that configure a {@link NexusClient} (and the service-bound clients it produces). @@ -36,16 +41,19 @@ public class NexusClientOptions { private final List interceptors; private final DataConverter dataConverter; private final String identity; + private final @Nullable ExternalStorage externalStorage; private NexusClientOptions( String namespace, List interceptors, DataConverter dataConverter, - String identity) { + String identity, + @Nullable ExternalStorage externalStorage) { this.namespace = namespace; this.interceptors = interceptors; this.dataConverter = dataConverter; this.identity = identity; + this.externalStorage = externalStorage; } /** Get the namespace this client will operate on. */ @@ -63,6 +71,15 @@ public DataConverter getDataConverter() { return dataConverter; } + /** + * Get the external storage used to offload large operation payloads, or null if payloads are sent + * inline. + */ + @Nullable + public ExternalStorage getExternalStorage() { + return externalStorage; + } + /** * Human-readable identity of this client. Stamped onto outgoing write requests (start, cancel, * terminate) so server-side history and audit trails can attribute the action to a caller. @@ -71,6 +88,22 @@ public String getIdentity() { return identity; } + /** + * Converts this {@link NexusClientOptions} instance into a {@link NexusClientResolvedOptions} + * instance, which contains the fully resolved runtime settings used by the internal Nexus client. + * + * @return a {@link NexusClientResolvedOptions} instance with the resolved options + */ + NexusClientResolvedOptions toResolvedOptions() { + DataConverter resolvedDataConverter = dataConverter; + if (externalStorage != null) { + resolvedDataConverter = + new ExternalStorageDataConverter( + resolvedDataConverter, ExternalStorageRunner.create(externalStorage)); + } + return new NexusClientResolvedOptions(namespace, interceptors, resolvedDataConverter, identity); + } + /** Returns a fresh builder. */ public static NexusClientOptions.Builder newBuilder() { return new NexusClientOptions.Builder(); @@ -101,6 +134,7 @@ public static class Builder { private List interceptors = Collections.emptyList(); private DataConverter dataConverter = GlobalDataConverter.get(); private String identity; + private ExternalStorage externalStorage; private Builder() {} @@ -112,6 +146,7 @@ private Builder(NexusClientOptions options) { interceptors = options.interceptors; dataConverter = options.dataConverter; identity = options.identity; + externalStorage = options.externalStorage; } /** Set the namespace this client will operate on. */ @@ -148,6 +183,18 @@ public NexusClientOptions.Builder setIdentity(String identity) { return this; } + /** + * Offload operation payloads that exceed the storage threshold to {@code externalStorage}, + * sending a reference to the server in their place. The client wraps its {@link DataConverter} + * to store outbound payloads and resolve inbound references. Defaults to null, meaning all + * payloads are sent inline. + */ + public NexusClientOptions.Builder setExternalStorage( + @Nullable ExternalStorage externalStorage) { + this.externalStorage = externalStorage; + return this; + } + public NexusClientOptions build() { String resolvedIdentity = identity == null ? ManagementFactory.getRuntimeMXBean().getName() : identity; @@ -155,7 +202,8 @@ public NexusClientOptions build() { namespace == null ? DEFAULT_NAMESPACE : namespace, interceptors, dataConverter, - resolvedIdentity); + resolvedIdentity, + externalStorage); } } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java index d827bbdcc7..b982067ebe 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusServiceClientImpl.java @@ -4,6 +4,7 @@ import io.nexusrpc.ServiceDefinition; import io.temporal.common.Experimental; import io.temporal.common.interceptors.NexusClientCallsInterceptor; +import io.temporal.internal.client.NexusClientResolvedOptions; import io.temporal.internal.util.MethodExtractor; import io.temporal.workflow.Functions; import java.lang.reflect.Method; @@ -26,7 +27,7 @@ class NexusServiceClientImpl extends UntypedNexusServiceClientImpl NexusClientCallsInterceptor invoker, Class serviceInterface, String endpoint, - NexusClientOptions options) { + NexusClientResolvedOptions options) { this( invoker, serviceInterface, @@ -40,7 +41,7 @@ private NexusServiceClientImpl( Class serviceInterface, ServiceDefinition serviceDef, String endpoint, - NexusClientOptions options) { + NexusClientResolvedOptions options) { super(invoker, endpoint, serviceDef.getName(), options); this.serviceInterface = serviceInterface; this.serviceDef = serviceDef; diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java index 0750d22250..b7dd9334a9 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -6,6 +6,7 @@ import io.temporal.common.interceptors.NexusClientCallsInterceptor; import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionInput; import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput; +import io.temporal.internal.client.NexusClientResolvedOptions; import io.temporal.internal.client.NexusOperationHandleImpl; import java.lang.reflect.Type; import java.util.Collections; @@ -28,7 +29,7 @@ class UntypedNexusServiceClientImpl implements UntypedNexusServiceClient { NexusClientCallsInterceptor invoker, String endpoint, String serviceName, - NexusClientOptions clientOptions) { + NexusClientResolvedOptions clientOptions) { if (invoker == null || endpoint == null || serviceName == null || clientOptions == null) { throw new IllegalArgumentException( "invoker, endpoint, serviceName, and clientOptions are all required"); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusClientResolvedOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusClientResolvedOptions.java new file mode 100644 index 0000000000..3f236278be --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusClientResolvedOptions.java @@ -0,0 +1,41 @@ +package io.temporal.internal.client; + +import io.temporal.common.converter.DataConverter; +import io.temporal.common.interceptors.NexusClientInterceptor; +import java.util.List; + +/** Resolved runtime settings used by the internal Nexus client implementation. */ +public final class NexusClientResolvedOptions { + + private final String namespace; + private final List interceptors; + private final DataConverter dataConverter; + private final String identity; + + public NexusClientResolvedOptions( + String namespace, + List interceptors, + DataConverter dataConverter, + String identity) { + this.namespace = namespace; + this.interceptors = interceptors; + this.dataConverter = dataConverter; + this.identity = identity; + } + + public String getNamespace() { + return namespace; + } + + public List getInterceptors() { + return interceptors; + } + + public DataConverter getDataConverter() { + return dataConverter; + } + + public String getIdentity() { + return identity; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 78d7bc39a7..415dbceea1 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -20,7 +20,6 @@ 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.NexusClientOptions; import io.temporal.client.NexusOperationAlreadyStartedException; import io.temporal.client.NexusOperationExecutionCount; import io.temporal.client.NexusOperationExecutionDescription; @@ -53,10 +52,10 @@ public class RootNexusClientInvoker implements NexusClientCallsInterceptor { private final GenericWorkflowClient genericClient; - private final NexusClientOptions clientOptions; + private final NexusClientResolvedOptions clientOptions; public RootNexusClientInvoker( - GenericWorkflowClient genericClient, NexusClientOptions clientOptions) { + GenericWorkflowClient genericClient, NexusClientResolvedOptions clientOptions) { this.genericClient = genericClient; this.clientOptions = clientOptions; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java index 33416a807b..1e5e4105a4 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java @@ -4,6 +4,7 @@ import static io.temporal.serviceclient.MetricsTag.TASK_FAILURE_TYPE; import com.google.protobuf.ByteString; +import com.google.protobuf.Message; import com.uber.m3.tally.Scope; import com.uber.m3.tally.Stopwatch; import com.uber.m3.util.Duration; @@ -16,7 +17,10 @@ import io.temporal.common.converter.DataConverter; import io.temporal.internal.common.NexusUtil; import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.concurrent.structured.CancelSource; import io.temporal.internal.logging.LoggerTag; +import io.temporal.internal.payload.storage.ExternalStorageNotConfiguredException; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.retryer.GrpcRetryer; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.WorkflowServiceStubs; @@ -27,6 +31,7 @@ import io.temporal.worker.tuning.PollerBehaviorAutoscaling; import java.util.Collections; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -53,6 +58,9 @@ final class NexusWorker implements SuspendableWorker { private final GrpcRetryer.GrpcRetryerOptions replyGrpcRetryerOptions; private final TrackingSlotSupplier slotSupplier; private final NamespaceCapabilities namespaceCapabilities; + + final CancelSource storageCancellation = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); private final boolean forceOldFailureFormat; private final boolean workerCommandsTaskQueue; private final TaskCounter taskCounter = new TaskCounter(); @@ -182,6 +190,9 @@ public boolean start() { @Override public CompletableFuture shutdown(ShutdownManager shutdownManager, boolean interruptTasks) { + if (interruptTasks) { + storageCancellation.cancel(); + } String supplierName = this + "#executorSlots"; return poller .shutdown(shutdownManager, interruptTasks) @@ -274,6 +285,12 @@ public String toString() { options.getIdentity(), namespace, taskQueue); } + private static final class ExternalStorageTaskFailure extends RuntimeException { + ExternalStorageTaskFailure(String message, Throwable cause) { + super(message, cause); + } + } + private class TaskHandlerImpl implements PollTaskExecutor.TaskHandler { final NexusTaskHandler handler; @@ -319,6 +336,7 @@ public void handle(NexusTask task) { MDC.put(LoggerTag.NEXUS_OPERATION, operation); metricsScope = metricsScope.tagged(ImmutableMap.of(MetricsTag.NEXUS_OPERATION, operation)); } + // Must happen before payload retrieval so the slot is accounted for while storage runs. slotSupplier.markSlotUsed( new NexusSlotInfo( service, operation, taskQueue, options.getIdentity(), options.getBuildId()), @@ -326,8 +344,26 @@ public void handle(NexusTask task) { boolean taskFailed = false; try { + try { + task = retrieveInboundPayloads(task); + } catch (Throwable e) { + if (isShutdownCancellation(e)) { + log.trace("Abandoned a nexus task while the worker was shutting down", e); + return; + } + taskFailed = true; + recordStorageFailure(metricsScope); + sendStorageFailure( + pollResponse.getTaskToken(), supportsTemporalFailure(pollResponse), metricsScope, e); + return; + } + taskFailed = handleNexusTask(task, metricsScope); } catch (Throwable e) { + if (isShutdownCancellation(e)) { + log.trace("Abandoned a nexus task while the worker was shutting down", e); + return; + } taskFailed = true; throw e; } finally { @@ -416,14 +452,17 @@ private boolean handleNexusTask(NexusTask task, Scope metricsScope) { } try { - // Check if the server supports using the Failure directly in responses - boolean supportTemporalFailure = - task.getResponse().getRequest().getCapabilities().getTemporalFailureResponses(); - if (forceOldFailureFormat) { - supportTemporalFailure = false; + sendReply(taskToken, supportsTemporalFailure(pollResponse), result, metricsScope, true); + } catch (ExternalStorageTaskFailure e) { + if (!failed) { + recordStorageFailure(metricsScope); } - - sendReply(taskToken, supportTemporalFailure, result, metricsScope); + sendStorageFailure( + taskToken, supportsTemporalFailure(pollResponse), metricsScope, e.getCause()); + return true; + } catch (CancellationException e) { + // Absorbed by handle() when this worker is shutting down. + throw e; } catch (Exception e) { logExceptionDuringResultReporting(e, pollResponse, result); throw e; @@ -476,7 +515,8 @@ private void sendReply( ByteString taskToken, boolean supportTemporalFailure, NexusTaskHandler.Result response, - Scope metricsScope) { + Scope metricsScope, + boolean useExternalStorage) { Response taskResponse = response.getResponse(); if (taskResponse != null) { // For old servers that do not support TemporalFailure in Failure proto, @@ -484,13 +524,16 @@ private void sendReply( if (!supportTemporalFailure && taskResponse.getStartOperation().hasFailure()) { taskResponse = getResponseForOldServer(taskResponse); } - RespondNexusTaskCompletedRequest request = + RespondNexusTaskCompletedRequest.Builder requestBuilder = RespondNexusTaskCompletedRequest.newBuilder() .setTaskToken(taskToken) .setIdentity(options.getIdentity()) .setNamespace(namespace) - .setResponse(taskResponse) - .build(); + .setResponse(taskResponse); + if (useExternalStorage) { + storeOutbound(requestBuilder); + } + RespondNexusTaskCompletedRequest request = requestBuilder.build(); grpcRetryer.retry( () -> @@ -512,17 +555,99 @@ private void sendReply( } else { request.setError(NexusUtil.handlerErrorToNexusError(handlerException, dataConverter)); } + if (useExternalStorage) { + storeOutbound(request); + } + RespondNexusTaskFailedRequest failedRequest = request.build(); grpcRetryer.retry( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .respondNexusTaskFailed(request.build()), + .respondNexusTaskFailed(failedRequest), replyGrpcRetryerOptions); } else { throw new IllegalArgumentException("[BUG] Either response or failure must be set"); } } } + + private boolean supportsTemporalFailure(PollNexusTaskQueueResponseOrBuilder pollResponse) { + return !forceOldFailureFormat + && pollResponse.getRequest().getCapabilities().getTemporalFailureResponses(); + } + + private void recordStorageFailure(Scope metricsScope) { + metricsScope + .tagged( + Collections.singletonMap( + TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL)) + .counter(MetricsType.NEXUS_EXEC_FAILED_COUNTER) + .inc(1); + } + + /** + * Reports a storage failure to the server as an internal handler error. This is the only + * recovery attempt and it bypasses external storage. + */ + private void sendStorageFailure( + ByteString taskToken, boolean supportTemporalFailure, Scope metricsScope, Throwable e) { + String message = + e instanceof ExternalStorageNotConfiguredException + ? "Nexus task has externally stored payloads but this worker has no external storage" + + " configured" + : "External storage failed for a nexus task"; + log.warn(message, e); + sendReply( + taskToken, + supportTemporalFailure, + new NexusTaskHandler.Result( + new HandlerException(HandlerException.ErrorType.INTERNAL, message, e)), + metricsScope, + false); + } + + /** + * True when {@code e} is external storage aborting because this worker is shutting down. A + * storage driver that genuinely breaks at the same moment is a different thing and must still + * be reported to the server. + */ + private boolean isShutdownCancellation(Throwable e) { + return e instanceof CancellationException + && storageCancellation.token().isCancellationRequested(); + } + + private NexusTask retrieveInboundPayloads(NexusTask task) { + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + PollNexusTaskQueueResponseOrBuilder response = task.getResponse(); + PollNexusTaskQueueResponse built = + response instanceof PollNexusTaskQueueResponse + ? (PollNexusTaskQueueResponse) response + : ((PollNexusTaskQueueResponse.Builder) response).build(); + if (externalStorageRunner == null) { + ExternalStorageRunner.throwIfContainsReference(built); + return task; + } + return new NexusTask( + externalStorageRunner.retrieve(built, storageCancellation.token()), + task.getPermit(), + task.getCompletionCallback()); + } + + private void storeOutbound(Message.Builder builder) { + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + if (externalStorageRunner == null) { + return; + } + try { + externalStorageRunner.store(builder, null, null, storageCancellation.token()); + } catch (CancellationException e) { + // A shutdown cancellation is not a task failure. Let it reach handle(), which abandons the + // task rather than telling the server the handler failed. + throw e; + } catch (Exception e) { + throw new ExternalStorageTaskFailure("External storage store failed", e); + } + } } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java index 3eb2475475..27f1ee5be8 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/NexusClientOptionsTest.java @@ -3,9 +3,18 @@ import static org.junit.Assert.*; import static org.mockito.Mockito.mock; +import io.temporal.api.common.v1.Payload; import io.temporal.common.converter.DataConverter; import io.temporal.common.interceptors.NexusClientInterceptor; +import io.temporal.internal.payload.storage.ExternalStorageDataConverter; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; import org.junit.Test; public class NexusClientOptionsTest { @@ -33,6 +42,7 @@ public void testNewBuilderFromOptionsCopiesAllFields() { .setNamespace("ns") .setIdentity("id") .setDataConverter(dc) + .setExternalStorage(storage()) .setInterceptors(Collections.singletonList(interceptor)) .build(); @@ -42,5 +52,65 @@ public void testNewBuilderFromOptionsCopiesAllFields() { assertEquals(original.getIdentity(), copy.getIdentity()); assertSame(original.getDataConverter(), copy.getDataConverter()); assertEquals(original.getInterceptors(), copy.getInterceptors()); + assertSame(original.getExternalStorage(), copy.getExternalStorage()); + } + + @Test + public void externalStorageDefaultsToDisabled() { + assertNull(NexusClientOptions.newBuilder().build().getExternalStorage()); + } + + @Test + public void externalStorageSurvivesBuild() { + ExternalStorage storage = storage(); + + NexusClientOptions options = + NexusClientOptions.newBuilder().setExternalStorage(storage).build(); + + assertSame(storage, options.getExternalStorage()); + } + + @Test + public void resolveOptionsWrapsDataConverterWithoutChangingConfiguredOptions() { + DataConverter dataConverter = mock(DataConverter.class); + NexusClientOptions options = + NexusClientOptions.newBuilder() + .setDataConverter(dataConverter) + .setExternalStorage(storage()) + .build(); + + assertSame(dataConverter, options.getDataConverter()); + assertTrue( + options.toResolvedOptions().getDataConverter() instanceof ExternalStorageDataConverter); + } + + private static ExternalStorage storage() { + return ExternalStorage.newBuilder().setDriver(driver()).build(); + } + + private static StorageDriver driver() { + return new StorageDriver() { + @Override + public String getName() { + return "test-driver"; + } + + @Override + public String getType() { + return "test"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + throw new UnsupportedOperationException(); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + }; } } diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageFailureTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageFailureTest.java new file mode 100644 index 0000000000..c44a75f2eb --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageFailureTest.java @@ -0,0 +1,293 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import com.google.common.collect.ImmutableMap; +import com.uber.m3.tally.RootScopeBuilder; +import io.temporal.api.common.v1.Payload; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusServiceClient; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.reporter.TestStatsReporter; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.serviceclient.MetricsTag; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.MetricsType; +import io.temporal.worker.WorkerMetricsTag; +import io.temporal.worker.WorkerOptions; +import io.temporal.worker.tuning.ActivitySlotInfo; +import io.temporal.worker.tuning.CompositeTuner; +import io.temporal.worker.tuning.FixedSizeSlotSupplier; +import io.temporal.worker.tuning.LocalActivitySlotInfo; +import io.temporal.worker.tuning.NexusSlotInfo; +import io.temporal.worker.tuning.SlotMarkUsedContext; +import io.temporal.worker.tuning.SlotPermit; +import io.temporal.worker.tuning.SlotReleaseContext; +import io.temporal.worker.tuning.SlotReserveContext; +import io.temporal.worker.tuning.SlotSupplier; +import io.temporal.worker.tuning.SlotSupplierFuture; +import io.temporal.worker.tuning.WorkflowSlotInfo; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class NexusExternalStorageFailureTest { + + private static final List events = new CopyOnWriteArrayList<>(); + + private static final FlakyDriver driver = new FlakyDriver("nexus-flaky"); + + private static final ExternalStorage storage = + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build(); + + private final TestStatsReporter reporter = new TestStatsReporter(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .setMetricsScope( + new RootScopeBuilder() + .reporter(reporter) + .reportEvery(com.uber.m3.util.Duration.ofMillis(10))) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setExternalStorage(storage).build()) + .setWorkerOptions( + WorkerOptions.newBuilder() + .setWorkerTuner( + new CompositeTuner( + new FixedSizeSlotSupplier(10), + new FixedSizeSlotSupplier(10), + new FixedSizeSlotSupplier(10), + new RecordingNexusSlotSupplier(10))) + .build()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + driver.reset(); + events.clear(); + } + + @Test + public void aFailedRetrievalIsReportedAsARetryableHandlerError() { + String input = "extstore-flaky-" + UUID.randomUUID(); + driver.failNextRetrieves.set(1); + + String result = + buildServiceClient() + .execute(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), input); + + Assert.assertEquals("echo:" + input, result); + Assert.assertTrue( + "expected the failed retrieval to be retried, attempts=" + driver.retrieveAttempts.get(), + driver.retrieveAttempts.get() > 1); + reporter.assertCounter(MetricsType.NEXUS_EXEC_FAILED_COUNTER, execFailedTags(), 1); + } + + @Test + public void aFailedOutboundStoreIsReportedAsARetryableHandlerError() { + String input = "extstore-outbound-" + UUID.randomUUID(); + driver.failStoresContaining.set("echo:" + input); + + String result = + buildServiceClient() + .execute(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), input); + + Assert.assertEquals("echo:" + input, result); + Assert.assertEquals( + "expected exactly one injected store failure", 1, driver.injectedStoreFailures.get()); + reporter.assertCounter(MetricsType.NEXUS_EXEC_FAILED_COUNTER, execFailedTags(), 1); + } + + @Test + public void theSlotIsMarkedUsedBeforeRetrievalStarts() { + buildServiceClient() + .execute( + TestNexusServices.TestNexusService1::operation, + newOptionsWithId(), + "extstore-slot-" + UUID.randomUUID()); + + int markedUsed = events.indexOf("markSlotUsed"); + int retrieved = events.indexOf("retrieve"); + Assert.assertTrue("expected the slot to be marked used, events=" + events, markedUsed >= 0); + Assert.assertTrue("expected a retrieval, events=" + events, retrieved >= 0); + Assert.assertTrue( + "retrieval must happen inside the used-slot lifecycle, events=" + events, + markedUsed < retrieved); + } + + private Map execFailedTags() { + return ImmutableMap.builder() + .putAll( + MetricsTag.defaultTags( + testWorkflowRule.getWorkflowClient().getOptions().getNamespace())) + .put(MetricsTag.WORKER_TYPE, WorkerMetricsTag.WorkerType.NEXUS_WORKER.getValue()) + .put(MetricsTag.TASK_QUEUE, testWorkflowRule.getTaskQueue()) + .put(MetricsTag.NEXUS_SERVICE, "TestNexusService1") + .put(MetricsTag.NEXUS_OPERATION, "operation") + .put(MetricsTag.TASK_FAILURE_TYPE, MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL) + .buildKeepingLast(); + } + + private static StartNexusOperationOptions newOptionsWithId() { + return StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(60)) + .build(); + } + + private NexusServiceClient buildServiceClient() { + NexusClient nexusClient = + NexusClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setExternalStorage(storage) + .build()); + return nexusClient.newNexusServiceClient( + TestNexusServices.TestNexusService1.class, + testWorkflowRule.getNexusEndpoint().getSpec().getName()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + private static final class RecordingNexusSlotSupplier implements SlotSupplier { + private final FixedSizeSlotSupplier delegate; + + RecordingNexusSlotSupplier(int numSlots) { + this.delegate = new FixedSizeSlotSupplier<>(numSlots); + } + + @Override + public SlotSupplierFuture reserveSlot(SlotReserveContext ctx) throws Exception { + return delegate.reserveSlot(ctx); + } + + @Override + public Optional tryReserveSlot(SlotReserveContext ctx) { + return delegate.tryReserveSlot(ctx); + } + + @Override + public void markSlotUsed(SlotMarkUsedContext ctx) { + events.add("markSlotUsed"); + delegate.markSlotUsed(ctx); + } + + @Override + public void releaseSlot(SlotReleaseContext ctx) { + events.add("releaseSlot"); + delegate.releaseSlot(ctx); + } + + @Override + public Optional getMaximumSlots() { + return delegate.getMaximumSlots(); + } + } + + private static final class FlakyDriver implements StorageDriver { + private final String name; + private final Map objects = new HashMap<>(); + final AtomicInteger failNextRetrieves = new AtomicInteger(); + final AtomicInteger retrieveAttempts = new AtomicInteger(); + final AtomicReference failStoresContaining = new AtomicReference<>(); + final AtomicInteger injectedStoreFailures = new AtomicInteger(); + private int counter = 0; + + FlakyDriver(String name) { + this.name = name; + } + + synchronized void reset() { + objects.clear(); + failNextRetrieves.set(0); + retrieveAttempts.set(0); + failStoresContaining.set(null); + injectedStoreFailures.set(0); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.nexus.flaky"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + String marker = failStoresContaining.get(); + if (marker != null) { + for (Payload payload : payloads) { + if (payload.getData().toStringUtf8().contains(marker)) { + failStoresContaining.set(null); + injectedStoreFailures.incrementAndGet(); + CompletableFuture> failed = new CompletableFuture<>(); + failed.completeExceptionally(new IllegalStateException("storage unavailable")); + return failed; + } + } + } + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = name + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + events.add("retrieve"); + retrieveAttempts.incrementAndGet(); + if (failNextRetrieves.getAndDecrement() > 0) { + CompletableFuture> failed = new CompletableFuture<>(); + failed.completeExceptionally(new IllegalStateException("storage unavailable")); + return failed; + } + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageTest.java new file mode 100644 index 0000000000..26a3dd04b1 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/NexusExternalStorageTest.java @@ -0,0 +1,189 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.temporal.api.common.v1.Payload; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusServiceClient; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** End-to-end coverage of external storage on a standalone Nexus operation */ +public class NexusExternalStorageTest { + + private static final RecordingDriver driver = new RecordingDriver("nexus-test"); + + private static final ExternalStorage storage = + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setExternalStorage(storage).build()) + .build(); + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + driver.reset(); + } + + @Test + public void operationInputAndResultRoundTripThroughStorage() { + String input = "extstore-input-" + UUID.randomUUID(); + + String result = + buildServiceClient() + .execute(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), input); + + Assert.assertEquals("echo:" + input, result); + Assert.assertTrue( + "expected the operation input to be offloaded to the driver", driver.stored(input)); + Assert.assertTrue( + "expected the operation result to be offloaded to the driver", + driver.stored("echo:" + input)); + Assert.assertTrue( + "expected the driver to be read back on retrieval", driver.retrieves.get() > 0); + } + + /** + * A handler that receives an unresolved reference cannot deserialize its input, so the handler + * observing the original value is what proves the inbound retrieval ran. + */ + @Test + public void handlerReceivesTheResolvedInput() { + String input = "extstore-inbound-" + UUID.randomUUID(); + + String result = + buildServiceClient() + .execute(TestNexusServices.TestNexusService1::operation, newOptionsWithId(), input); + + Assert.assertEquals("echo:" + input, result); + } + + @Test + public void nexusPayloadsAreStoredWithoutATarget() { + buildServiceClient() + .execute( + TestNexusServices.TestNexusService1::operation, + newOptionsWithId(), + "extstore-target-" + UUID.randomUUID()); + + Assert.assertFalse("expected the driver to have been used", driver.targets.isEmpty()); + Assert.assertTrue( + "Nexus payloads are stored without a StorageDriverTargetInfo", + driver.targets.stream().allMatch(target -> target == null)); + } + + private static StartNexusOperationOptions newOptionsWithId() { + return StartNexusOperationOptions.newBuilder().setId(UUID.randomUUID().toString()).build(); + } + + private NexusServiceClient buildServiceClient() { + NexusClient nexusClient = + NexusClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setExternalStorage(storage) + .build()); + return nexusClient.newNexusServiceClient( + TestNexusServices.TestNexusService1.class, + testWorkflowRule.getNexusEndpoint().getSpec().getName()); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + private static final class RecordingDriver implements StorageDriver { + private final String name; + private final Map objects = new HashMap<>(); + private final List storedData = new CopyOnWriteArrayList<>(); + final List targets = new CopyOnWriteArrayList<>(); + final AtomicInteger retrieves = new AtomicInteger(); + private int counter = 0; + + RecordingDriver(String name) { + this.name = name; + } + + synchronized void reset() { + objects.clear(); + storedData.clear(); + targets.clear(); + retrieves.set(0); + } + + boolean stored(String substring) { + return storedData.stream().anyMatch(data -> data.contains(substring)); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.nexus.inmemory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + targets.add(context.getTarget()); + storedData.add(payload.getData().toStringUtf8()); + String key = name + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + retrieves.incrementAndGet(); + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java index 679609fd29..6f3cea502c 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java @@ -29,7 +29,13 @@ public class RootNexusClientInvokerTest { private final GenericWorkflowClient genericClient = mock(GenericWorkflowClient.class); private final RootNexusClientInvoker invoker = - new RootNexusClientInvoker(genericClient, NexusClientOptions.getDefaultInstance()); + new RootNexusClientInvoker( + genericClient, + new NexusClientResolvedOptions( + NexusClientOptions.getDefaultInstance().getNamespace(), + NexusClientOptions.getDefaultInstance().getInterceptors(), + NexusClientOptions.getDefaultInstance().getDataConverter(), + NexusClientOptions.getDefaultInstance().getIdentity())); private static GetNexusOperationResultInput input() { return new GetNexusOperationResultInput<>( diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/NexusWorkerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/NexusWorkerTest.java new file mode 100644 index 0000000000..f299d4c5d6 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/NexusWorkerTest.java @@ -0,0 +1,461 @@ +package io.temporal.internal.worker; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.common.collect.ImmutableMap; +import com.google.common.util.concurrent.Futures; +import com.google.protobuf.ByteString; +import com.uber.m3.tally.NoopScope; +import com.uber.m3.tally.RootScopeBuilder; +import com.uber.m3.tally.Scope; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.failure.v1.ApplicationFailureInfo; +import io.temporal.api.failure.v1.Failure; +import io.temporal.api.nexus.v1.Request; +import io.temporal.api.nexus.v1.Response; +import io.temporal.api.nexus.v1.StartOperationRequest; +import io.temporal.api.nexus.v1.StartOperationResponse; +import io.temporal.api.sdk.v1.ExternalStorageReference; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.api.workflowservice.v1.PollNexusTaskQueueRequest; +import io.temporal.api.workflowservice.v1.PollNexusTaskQueueResponse; +import io.temporal.api.workflowservice.v1.RespondNexusTaskFailedRequest; +import io.temporal.api.workflowservice.v1.ShutdownWorkerRequest; +import io.temporal.api.workflowservice.v1.ShutdownWorkerResponse; +import io.temporal.api.workflowservice.v1.WorkflowServiceGrpc; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.reporter.TestStatsReporter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.serviceclient.MetricsTag; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.testUtils.Eventually; +import io.temporal.worker.MetricsType; +import io.temporal.worker.WorkerMetricsTag; +import io.temporal.worker.tuning.FixedSizeSlotSupplier; +import io.temporal.worker.tuning.PollerBehaviorSimpleMaximum; +import io.temporal.worker.tuning.SlotSupplier; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nonnull; +import org.junit.Test; +import org.mockito.stubbing.Answer; + +public class NexusWorkerTest { + + private final TestStatsReporter reporter = new TestStatsReporter(); + private final Scope metricsScope = + new RootScopeBuilder().reporter(reporter).reportEvery(com.uber.m3.util.Duration.ofMillis(10)); + + @Test + public void interruptingShutdownCancelsInFlightStorage() throws Exception { + NexusWorker worker = worker(); + + try (ShutdownManager shutdownManager = new ShutdownManager()) { + worker.shutdown(shutdownManager, true).get(); + } + + assertTrue(worker.storageCancellation.token().isCancellationRequested()); + } + + @Test + public void gracefulShutdownLeavesStorageRunning() throws Exception { + NexusWorker worker = worker(); + + try (ShutdownManager shutdownManager = new ShutdownManager()) { + worker.shutdown(shutdownManager, false).get(); + } + + assertFalse(worker.storageCancellation.token().isCancellationRequested()); + } + + /** + * A forced shutdown cancels in-flight external storage. That cancellation means we abandoned the + * task, not that the handler failed, so nothing may be reported to the server. + */ + @Test + public void storageCancelledByAForcedShutdownIsNotReportedAsATaskFailure() throws Exception { + CountDownLatch storeEntered = new CountDownLatch(1); + // Never completes: the store is still in flight when the shutdown cancels it. + BlockingDriver driver = new BlockingDriver(storeEntered, new CompletableFuture<>()); + + Fixture fixture = new Fixture(driver); + assertTrue(fixture.worker.start()); + assertTrue("the store must be reached", storeEntered.await(10, TimeUnit.SECONDS)); + + try (ShutdownManager shutdownManager = new ShutdownManager()) { + fixture.worker.shutdown(shutdownManager, true).get(); + } + + verify(fixture.blockingStub, never()).respondNexusTaskFailed(any()); + } + + /** + * Cancelling storage means we abandoned the work. Storage genuinely breaking at the same moment + * is a different thing and must still reach the server. + */ + @Test + public void storageBreakingDuringAForcedShutdownIsStillReported() throws Exception { + Fixture fixture = new Fixture(brokenDriver()); + // Already shutting down when the task runs, so a real failure has to survive the cancellation. + fixture.worker.storageCancellation.cancel(); + CountDownLatch reported = reportedLatch(fixture); + + try { + assertTrue(fixture.worker.start()); + assertTrue( + "a real storage failure must still be reported", reported.await(10, TimeUnit.SECONDS)); + } finally { + shutdown(fixture); + } + + verify(fixture.blockingStub).respondNexusTaskFailed(any(RespondNexusTaskFailedRequest.class)); + } + + /** + * Storage breaking on the way out still has to reach the server, and that report must not go back + * through storage: storage is the thing that just failed. + */ + @Test + public void anOutboundStorageFailureIsReportedWithoutRetryingStorage() throws Exception { + BlockingDriver driver = brokenDriver(); + Fixture fixture = new Fixture(driver); + CountDownLatch reported = reportedLatch(fixture); + + try { + assertTrue(fixture.worker.start()); + assertTrue("the storage failure must be reported", reported.await(10, TimeUnit.SECONDS)); + } finally { + shutdown(fixture); + } + + verify(fixture.blockingStub, never()).respondNexusTaskCompleted(any()); + assertEquals("the report must not go through storage again", 1, driver.stores.get()); + } + + /** + * A task whose payloads live in external storage cannot be handled by a worker with no storage + * configured. That has to be reported rather than silently dropped. + */ + @Test + public void anExternallyStoredTaskWithNoStorageConfiguredIsReported() throws Exception { + Fixture fixture = new Fixture(null, storageReference()); + CountDownLatch reported = reportedLatch(fixture); + + try { + assertTrue(fixture.worker.start()); + assertTrue("the missing storage must be reported", reported.await(10, TimeUnit.SECONDS)); + } finally { + shutdown(fixture); + } + + verify(fixture.blockingStub, never()).respondNexusTaskCompleted(any()); + } + + /** One task counts one exec failure, however many reply attempts a storage failure costs. */ + @Test + public void anOutboundStorageFailureCountsOneExecFailure() throws Exception { + Fixture fixture = new Fixture(brokenDriver(), metricsScope, syncSuccess()); + CountDownLatch reported = reportedLatch(fixture); + + try { + assertTrue(fixture.worker.start()); + assertTrue("the storage failure must be reported", reported.await(10, TimeUnit.SECONDS)); + } finally { + shutdown(fixture); + } + + Eventually.assertEventually( + Duration.ofSeconds(10), + () -> + reporter.assertCounter( + MetricsType.NEXUS_EXEC_FAILED_COUNTER, + execFailedTags(MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL), + 1)); + } + + /** + * A handler failure is already counted under its own reason. Storage breaking while reporting it + * must not count the same task a second time. + */ + @Test + public void aHandlerFailureFollowedByAStorageFailureCountsOneExecFailure() throws Exception { + Fixture fixture = new Fixture(brokenDriver(), metricsScope, operationFailure()); + CountDownLatch reported = reportedLatch(fixture); + + try { + assertTrue(fixture.worker.start()); + assertTrue("the storage failure must be reported", reported.await(10, TimeUnit.SECONDS)); + } finally { + shutdown(fixture); + } + + Eventually.assertEventually( + Duration.ofSeconds(10), + () -> + reporter.assertCounter( + MetricsType.NEXUS_EXEC_FAILED_COUNTER, + execFailedTags(MetricsTag.TASK_FAILURE_VALUE_OPERATION_FAILED), + 1)); + reporter.assertNoMetric( + MetricsType.NEXUS_EXEC_FAILED_COUNTER, + execFailedTags(MetricsTag.TASK_FAILURE_VALUE_HANDLER_ERROR_INTERNAL)); + } + + /** A worker wired to {@code driver}, polling exactly one nexus task that returns a payload. */ + private static final class Fixture { + final NexusWorker worker; + final WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub; + + Fixture(StorageDriver driver) throws Exception { + this(driver, null, new NoopScope(), syncSuccess()); + } + + Fixture(StorageDriver driver, Payload inboundPayload) throws Exception { + this(driver, inboundPayload, new NoopScope(), syncSuccess()); + } + + Fixture(StorageDriver driver, Scope metricsScope, NexusTaskHandler.Result handlerResult) + throws Exception { + this(driver, null, metricsScope, handlerResult); + } + + private Fixture( + StorageDriver driver, + Payload inboundPayload, + Scope metricsScope, + NexusTaskHandler.Result handlerResult) + throws Exception { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.getDefaultInstance()); + + blockingStub = mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) + .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); + when(service.blockingStub()).thenReturn(blockingStub); + when(service.futureStub()).thenReturn(futureStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + + PollNexusTaskQueueResponse pollResponse = + PollNexusTaskQueueResponse.newBuilder() + .setTaskToken(ByteString.copyFrom("token", UTF_8)) + .setRequest( + Request.newBuilder() + .setCapabilities( + Request.Capabilities.newBuilder().setTemporalFailureResponses(true)) + .setStartOperation(startOperation(inboundPayload))) + .build(); + CountDownLatch blockPolls = new CountDownLatch(1); + when(blockingStub.pollNexusTaskQueue(any(PollNexusTaskQueueRequest.class))) + .thenReturn(pollResponse) + .thenAnswer( + (Answer) + invocation -> { + blockPolls.await(); + return null; + }); + + NexusTaskHandler handler = mock(NexusTaskHandler.class); + when(handler.start()).thenReturn(true); + when(handler.handle(any(), any())).thenReturn(handlerResult); + + worker = + new NexusWorker( + service, + "namespace", + "task_queue", + SingleWorkerOptions.newBuilder() + .setIdentity("test_identity") + .setBuildId(UUID.randomUUID().toString()) + .setWorkerInstanceKey(UUID.randomUUID().toString()) + .setPollerOptions( + PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) + .build()) + .setMetricsScope(metricsScope) + .setExternalStorageRunner( + driver == null + ? null + : ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(0) + .build())) + .build(), + handler, + DefaultDataConverter.newDefaultInstance(), + new FixedSizeSlotSupplier<>(10), + new NamespaceCapabilities()); + } + } + + /** Counts down once the worker reports a task failure to the server. */ + private static CountDownLatch reportedLatch(Fixture fixture) { + CountDownLatch reported = new CountDownLatch(1); + when(fixture.blockingStub.respondNexusTaskFailed(any(RespondNexusTaskFailedRequest.class))) + .thenAnswer( + (Answer) + invocation -> { + reported.countDown(); + return null; + }); + return reported; + } + + private static void shutdown(Fixture fixture) throws Exception { + try (ShutdownManager shutdownManager = new ShutdownManager()) { + fixture.worker.shutdown(shutdownManager, true).get(); + } + } + + /** A driver whose every store fails outright. */ + private static BlockingDriver brokenDriver() { + CompletableFuture> broken = new CompletableFuture<>(); + broken.completeExceptionally(new IllegalStateException("storage unavailable")); + return new BlockingDriver(new CountDownLatch(1), broken); + } + + /** The tags the worker puts on {@code NEXUS_EXEC_FAILED_COUNTER}. */ + private static Map execFailedTags(String failureReason) { + return ImmutableMap.of( + MetricsTag.WORKER_TYPE, + WorkerMetricsTag.WorkerType.NEXUS_WORKER.getValue(), + MetricsTag.NEXUS_SERVICE, + "service", + MetricsTag.NEXUS_OPERATION, + "operation", + MetricsTag.TASK_FAILURE_TYPE, + failureReason); + } + + private static NexusTaskHandler.Result syncSuccess() { + Payload result = Payload.newBuilder().setData(ByteString.copyFrom("a result", UTF_8)).build(); + return new NexusTaskHandler.Result( + Response.newBuilder() + .setStartOperation( + StartOperationResponse.newBuilder() + .setSyncSuccess(StartOperationResponse.Sync.newBuilder().setPayload(result))) + .build()); + } + + /** An operation failure carrying a payload, so that reporting it has something to store. */ + private static NexusTaskHandler.Result operationFailure() { + Payload detail = Payload.newBuilder().setData(ByteString.copyFrom("a detail", UTF_8)).build(); + return new NexusTaskHandler.Result( + Response.newBuilder() + .setStartOperation( + StartOperationResponse.newBuilder() + .setFailure( + Failure.newBuilder() + .setMessage("operation failed") + .setApplicationFailureInfo( + ApplicationFailureInfo.newBuilder() + .setDetails(Payloads.newBuilder().addPayloads(detail))))) + .build()); + } + + private static StartOperationRequest.Builder startOperation(Payload inboundPayload) { + StartOperationRequest.Builder request = + StartOperationRequest.newBuilder().setService("service").setOperation("operation"); + if (inboundPayload != null) { + request.setPayload(inboundPayload); + } + return request; + } + + /** + * A payload that looks like an external storage reference to {@code throwIfContainsReference}. + */ + private static Payload storageReference() { + return Payload.newBuilder() + .putMetadata("encoding", ByteString.copyFrom("json/protobuf", UTF_8)) + .putMetadata( + "messageType", + ByteString.copyFrom(ExternalStorageReference.getDescriptor().getFullName(), UTF_8)) + .setData(ByteString.copyFrom("{}", UTF_8)) + .build(); + } + + /** Signals when a store is reached and answers every store with {@code answer}. */ + private static final class BlockingDriver implements StorageDriver { + private final CountDownLatch storeEntered; + private final CompletableFuture> answer; + final AtomicInteger stores = new AtomicInteger(); + + BlockingDriver( + CountDownLatch storeEntered, CompletableFuture> answer) { + this.storeEntered = storeEntered; + this.answer = answer; + } + + @Override + @Nonnull + public String getName() { + return "blocking"; + } + + @Override + @Nonnull + public String getType() { + return "test.nexus.blocking"; + } + + @Override + @Nonnull + public CompletableFuture> store( + @Nonnull StorageDriverStoreContext context, @Nonnull List payloads) { + stores.incrementAndGet(); + storeEntered.countDown(); + return answer; + } + + @Override + @Nonnull + public CompletableFuture> retrieve( + @Nonnull StorageDriverRetrieveContext context, @Nonnull List claims) { + List payloads = + new ArrayList<>(Collections.nCopies(claims.size(), Payload.getDefaultInstance())); + return CompletableFuture.completedFuture(payloads); + } + } + + @SuppressWarnings("unchecked") + private static NexusWorker worker() { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.getDefaultInstance()); + return new NexusWorker( + service, + "ns", + "tq", + SingleWorkerOptions.newBuilder().build(), + mock(NexusTaskHandler.class), + DefaultDataConverter.newDefaultInstance(), + mock(SlotSupplier.class), + mock(NamespaceCapabilities.class)); + } +} From b319d85a2e9ab1e0b0859f63c4dd1f9abbe6da7b Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 4 Sep 2026 15:57:01 -0700 Subject: [PATCH 086/107] Adding SDK Ergonomics Query link (#2988) * Query commit * PR review comments * Updating server versuin --- .../client/RootWorkflowClientInvoker.java | 8 + .../internal/common/LinkConverter.java | 121 ++++-- ...kflowClientInvokerLinkPropagationTest.java | 144 +++++++ .../internal/common/LinkConverterTest.java | 311 ++++++++++++++ .../workflow/nexus/QueryOperationTest.java | 395 ++++++++++++++++++ 5 files changed, 954 insertions(+), 25 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/QueryOperationTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java index 502c12e8ee..121c62fc19 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java @@ -434,6 +434,14 @@ public QueryOutput query(QueryInput input) { QueryWorkflowResponse result; result = genericClient.query(request); + // A query writes nothing to history, so the server returns a link to the workflow execution + // that processed it rather than to an event. When the query is issued from inside a Nexus + // operation handler, propagate that link so the caller's Nexus operation event points at the + // queried workflow. Older servers leave it unset. + if (CurrentNexusOperationContext.isNexusContext() && result.hasLink()) { + CurrentNexusOperationContext.get().addResponseLink(result.getLink()); + } + boolean queryRejected = result.hasQueryRejected(); WorkflowExecutionStatus rejectStatus = queryRejected ? result.getQueryRejected().getStatus() : null; 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 54b3cdc728..ae56af47d1 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 @@ -25,10 +25,12 @@ public class LinkConverter { "temporal:///namespaces/%s/nexus-operations/%s/%s/details"; private static final String activityLinkPathFormat = "temporal:///namespaces/%s/activities/%s/%s/details"; + private static final String workflowLinkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s"; private static final String linkReferenceTypeKey = "referenceType"; private static final String linkEventIDKey = "eventID"; private static final String linkEventTypeKey = "eventType"; private static final String linkRequestIDKey = "requestID"; + private static final String linkReasonKey = "reason"; private static final String eventReferenceType = Link.WorkflowEvent.EventReference.getDescriptor().getName(); @@ -98,14 +100,28 @@ public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.Workfl return null; } + /** + * Converts a {@link Link.Workflow} to a Nexus link. A workflow link addresses a workflow + * execution as a whole rather than one event within it, so the URL uses the workflow path and + * carries no event path suffix and no reference query params. It is used when there is no history + * event to point at, for example a Query or a rejected Update. The optional {@code reason} + * explaining why the link exists is carried as a query param. + */ public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) { try { - String namespace = URLEncoder.encode(w.getNamespace(), StandardCharsets.UTF_8.toString()); - String workflowId = - URLEncoder.encode(w.getWorkflowId(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"); // handle workflowIds supporting spaces - String runId = URLEncoder.encode(w.getRunId(), StandardCharsets.UTF_8.toString()); - String url = String.format(linkPathFormat, namespace, workflowId, runId); + String url = + String.format( + workflowLinkPathFormat, + encodePathSegment(w.getNamespace()), + encodePathSegment(w.getWorkflowId()), + encodePathSegment(w.getRunId())); + if (!w.getReason().isEmpty()) { + url += + "?" + + linkReasonKey + + "=" + + URLEncoder.encode(w.getReason(), StandardCharsets.UTF_8.toString()); + } return io.temporal.api.nexus.v1.Link.newBuilder() .setUrl(url) .setType(workflowLinkType) @@ -131,13 +147,13 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String namespace = decodePathSegment(st.nextToken()); if (!st.nextToken().equals("workflows")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); - String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String workflowID = decodePathSegment(st.nextToken()); + String runID = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens() || !st.nextToken().equals("history")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; @@ -190,38 +206,57 @@ public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusL } public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) { + if (!workflowLinkType.equals(nexusLink.getType())) { + log.error( + "Failed to parse Nexus link URL: cannot parse link type {} to {}", + nexusLink.getType(), + workflowLinkType); + return null; + } Link.Builder link = Link.newBuilder(); try { URI uri = new URI(nexusLink.getUrl()); - log.debug("Parsing nexus link URL: {}", uri.getRawPath()); - if (!uri.getScheme().equals(temporalUrlScheme)) { + + // Compared in this order so a URL with no scheme at all reports the invalid scheme rather + // than throwing. + if (!temporalUrlScheme.equals(uri.getScheme())) { log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); return null; } + StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - // maybe add constants for "namespaces", "workflows" too if (!st.nextToken().equals("namespaces")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String namespace = decodePathSegment(st.nextToken()); if (!st.nextToken().equals("workflows")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String workflowID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); - if (!st.hasMoreTokens()) { + String workflowID = decodePathSegment(st.nextToken()); + String runID = decodePathSegment(st.nextToken()); + // The run ID ends a workflow link, so anything trailing means this is a different link + // shape. In particular this rejects the workflow-event form, which ends in "/history". + if (st.hasMoreTokens()) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String runID = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); - link.setWorkflow( + + Link.Workflow.Builder w = Link.Workflow.newBuilder() .setNamespace(namespace) .setWorkflowId(workflowID) - .setRunId(runID)); + .setRunId(runID); + String reason = rawQueryParam(uri, linkReasonKey); + if (reason != null) { + w.setReason(reason); + } + + link.setWorkflow(w); } catch (Exception e) { - log.error("Failed to convert NexusLink {} to WorkflowLink", nexusLink, e); + // Swallow un-parsable links since they are not critical to processing. + log.error("Failed to parse Nexus link URL", e); return null; } return link.build(); @@ -308,17 +343,17 @@ public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String namespace = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens() || !st.nextToken().equals("activities")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String activityId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String activityId = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens()) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String runId = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; @@ -378,17 +413,17 @@ public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexus log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String namespace = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String namespace = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens() || !st.nextToken().equals("nexus-operations")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String operationId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String operationId = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens()) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; } - String runId = URLDecoder.decode(st.nextToken(), StandardCharsets.UTF_8.toString()); + String runId = decodePathSegment(st.nextToken()); if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); return null; @@ -406,6 +441,42 @@ public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexus return link.build(); } + /** + * Percent-encodes a single URL path segment. {@link URLEncoder} targets form encoding, where a + * space becomes '+', so rewrite it to "%20" as required for a path. + */ + private static String encodePathSegment(String value) throws UnsupportedEncodingException { + return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replace("+", "%20"); + } + + /** + * Percent-decodes a single URL path segment. {@link URLDecoder} targets form decoding, where '+' + * means a space, but in a path a '+' is a literal character. Pre-escaping '+' as "%2B" keeps it + * literal while leaving genuine percent escapes such as "%20" for the decoder to handle. + */ + private static String decodePathSegment(String value) throws UnsupportedEncodingException { + return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.toString()); + } + + /** + * Reads a single param out of the raw, still-encoded query string, or returns null when the param + * is absent. Unlike {@link #parseQueryParams} the value is decoded exactly once, so values that + * themselves contain '=' or '&' survive the round trip. + */ + private static String rawQueryParam(URI uri, String key) throws UnsupportedEncodingException { + final String rawQuery = uri.getRawQuery(); + if (rawQuery == null || rawQuery.isEmpty()) { + return null; + } + for (String pair : rawQuery.split("&")) { + final String[] kv = pair.split("=", 2); + if (kv[0].equals(key)) { + return kv.length == 2 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString()) : ""; + } + } + return null; + } + private static Map parseQueryParams(URI uri) throws UnsupportedEncodingException { final String query = uri.getQuery(); if (query == null || query.isEmpty()) { diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java index a597ae96d2..e9cc667128 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerLinkPropagationTest.java @@ -7,10 +7,15 @@ import com.uber.m3.tally.RootScopeBuilder; import com.uber.m3.tally.Scope; import io.temporal.api.common.v1.Link; +import io.temporal.api.common.v1.Payloads; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.EventType; import io.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage; +import io.temporal.api.enums.v1.WorkflowExecutionStatus; +import io.temporal.api.query.v1.QueryRejected; import io.temporal.api.update.v1.UpdateRef; +import io.temporal.api.workflowservice.v1.QueryWorkflowRequest; +import io.temporal.api.workflowservice.v1.QueryWorkflowResponse; import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest; import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse; import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; @@ -23,7 +28,9 @@ import io.temporal.client.WorkflowClientOptions; import io.temporal.client.WorkflowOptions; import io.temporal.client.WorkflowUpdateStage; +import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.StartUpdateInput; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalInput; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalWithStartInput; @@ -348,6 +355,116 @@ private static StartUpdateInput newStartUpdateInput() { .build()); } + /** + * A query never writes to history, so the server answers with a {@code Link.Workflow} naming the + * execution that processed it instead of a {@code Link.WorkflowEvent}. That link has to reach the + * operation context so the caller's Nexus operation event points back at the queried workflow. + */ + @Test + public void queryCapturesWorkflowResponseLink() { + Link responseLink = workflowLink(WORKFLOW_ID, "target-run", "Query processed"); + when(genericClient.query(any(QueryWorkflowRequest.class))) + .thenReturn( + QueryWorkflowResponse.newBuilder() + .setLink(responseLink) + .setQueryResult(queryResult("answer")) + .build()); + + WorkflowClientCallsInterceptor.QueryOutput output = invoker.query(newQueryInput()); + + List captured = nexusCtx.getResponseLinks(); + Assert.assertEquals("expected one captured response link", 1, captured.size()); + Assert.assertEquals(responseLink, captured.get(0)); + + // Capturing the link must not disturb the query's own result. + Assert.assertFalse(output.isQueryRejected()); + Assert.assertEquals("answer", output.getResult()); + } + + /** + * Two queries in a row each contribute a response link; both must accumulate in call order on the + * shared list, exactly as the signal path does. + */ + @Test + public void multipleQueriesAccumulateAllResponseLinks() { + Link firstResponseLink = workflowLink("callee-a", "run-a", "Query processed"); + Link secondResponseLink = workflowLink("callee-b", "run-b", "Query processed"); + when(genericClient.query(any(QueryWorkflowRequest.class))) + .thenReturn(QueryWorkflowResponse.newBuilder().setLink(firstResponseLink).build()) + .thenReturn(QueryWorkflowResponse.newBuilder().setLink(secondResponseLink).build()); + + invoker.query(newQueryInput()); + invoker.query(newQueryInput()); + + Assert.assertEquals( + "expected one response link per query call, in call order", + Arrays.asList(firstResponseLink, secondResponseLink), + nexusCtx.getResponseLinks()); + } + + /** + * A rejected query still carries a link to the workflow that rejected it, and the link is + * captured before the rejection is surfaced. This matches sdk-go, where the link is recorded + * ahead of the QueryRejected branch. Pins the ordering so it is not "fixed" into the wrong + * behavior later. + */ + @Test + public void rejectedQueryStillCapturesResponseLink() { + Link responseLink = workflowLink(WORKFLOW_ID, "target-run", "Query processed"); + when(genericClient.query(any(QueryWorkflowRequest.class))) + .thenReturn( + QueryWorkflowResponse.newBuilder() + .setLink(responseLink) + .setQueryRejected( + QueryRejected.newBuilder() + .setStatus(WorkflowExecutionStatus.WORKFLOW_EXECUTION_STATUS_COMPLETED)) + .build()); + + WorkflowClientCallsInterceptor.QueryOutput output = invoker.query(newQueryInput()); + + Assert.assertTrue("expected the query to be reported as rejected", output.isQueryRejected()); + Assert.assertEquals( + "expected the response link to be captured even for a rejected query", + Collections.singletonList(responseLink), + nexusCtx.getResponseLinks()); + } + + /** + * Older-server compatibility: {@code QueryWorkflowResponse.link} is unset, so nothing is captured + * and the query itself still succeeds. + */ + @Test + public void queryAgainstOlderServerCapturesNoResponseLink() { + when(genericClient.query(any(QueryWorkflowRequest.class))) + .thenReturn(QueryWorkflowResponse.getDefaultInstance()); + + invoker.query(newQueryInput()); + + Assert.assertTrue( + "expected no captured response link when server returned no link", + nexusCtx.getResponseLinks().isEmpty()); + } + + /** + * A query issued outside a Nexus operation handler must not touch the operation context at all. + * Guards against the propagation being reached without a context, which would throw. + */ + @Test + public void queryOutsideNexusContextIgnoresResponseLink() { + CurrentNexusOperationContext.unset(); + when(genericClient.query(any(QueryWorkflowRequest.class))) + .thenReturn( + QueryWorkflowResponse.newBuilder() + .setLink(workflowLink(WORKFLOW_ID, "target-run", "Query processed")) + .build()); + + invoker.query(newQueryInput()); + + Assert.assertTrue( + "a query outside a Nexus context must not record response links", + nexusCtx.getResponseLinks().isEmpty()); + } + // ── helpers ────────────────────────────────────────────────────────────────────────────── private static WorkflowSignalInput newSignalInput() { @@ -374,6 +491,33 @@ private static WorkflowSignalWithStartInput newSignalWithStartInput() { startInput, "test-signal", new Object[] {"signal-payload"}); } + private static WorkflowClientCallsInterceptor.QueryInput newQueryInput() { + return new WorkflowClientCallsInterceptor.QueryInput<>( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).build(), + "test-query", + Header.empty(), + new Object[] {}, + String.class, + String.class); + } + + private static Payloads queryResult(String value) { + return DefaultDataConverter.STANDARD_INSTANCE + .toPayloads(value) + .orElseThrow(() -> new IllegalStateException("expected payloads")); + } + + private static Link workflowLink(String workflowId, String runId, String reason) { + return Link.newBuilder() + .setWorkflow( + Link.Workflow.newBuilder() + .setNamespace(NAMESPACE) + .setWorkflowId(workflowId) + .setRunId(runId) + .setReason(reason)) + .build(); + } + private static Link workflowEventLink(String workflowId, String runId, EventType eventType) { return Link.newBuilder() .setWorkflowEvent( 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 34868a2471..2434f12db2 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,8 +6,10 @@ import static io.temporal.internal.common.LinkConverter.nexusLinkToLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToNexusOperation; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowEvent; +import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowLink; import static io.temporal.internal.common.LinkConverter.nexusOperationToNexusLink; import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; +import static io.temporal.internal.common.LinkConverter.workflowLinkToNexusLink; import static org.junit.Assert.*; import io.temporal.api.common.v1.Link; @@ -634,4 +636,313 @@ public void testLinkToNexusLink_Activity() { public void testLinkToNexusLink_Empty() { assertNull(linkToNexusLink(Link.newBuilder().build())); } + + @Test + public void testConvertWorkflowToNexus_Valid() { + Link.Workflow input = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals(expected, workflowLinkToNexusLink(input)); + } + + @Test + public void testConvertWorkflowToNexus_ValidReason() { + Link.Workflow input = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .setReason("rejected update") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals(expected, workflowLinkToNexusLink(input)); + } + + @Test + public void testConvertWorkflowToNexus_ValidSlash() { + Link.Workflow input = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf/id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf%2Fid/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals(expected, workflowLinkToNexusLink(input)); + } + + @Test + public void testConvertWorkflowToNexus_ValidSpace() throws UnsupportedEncodingException { + Link.Workflow input = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf id") + .setRunId("run-id") + .build(); + + io.temporal.api.nexus.v1.Link expected = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf%20id/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + io.temporal.api.nexus.v1.Link actual = workflowLinkToNexusLink(input); + assertEquals(expected, actual); + // A space in the path has to survive as %20 rather than the '+' that form encoding would + // produce, otherwise the link resolves to a different workflow ID. + assertEquals( + "temporal:///namespaces/ns/workflows/wf id/run-id", + URLDecoder.decode(actual.getUrl(), StandardCharsets.UTF_8.toString())); + } + + @Test + public void testConvertNexusToWorkflow_Valid() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + Link expected = + Link.newBuilder() + .setWorkflow( + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id")) + .build(); + + assertEquals(expected, nexusLinkToWorkflowLink(input)); + } + + @Test + public void testConvertNexusToWorkflow_ValidReason() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + Link expected = + Link.newBuilder() + .setWorkflow( + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .setReason("rejected update")) + .build(); + + assertEquals(expected, nexusLinkToWorkflowLink(input)); + } + + @Test + public void testConvertNexusToWorkflow_WrongType() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") + .setType("temporal.api.common.v1.Link.WorkflowEvent") + .build(); + + assertNull(nexusLinkToWorkflowLink(input)); + } + + @Test + public void testConvertNexusToWorkflow_InvalidScheme() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("random:///namespaces/ns/workflows/wf-id/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertNull(nexusLinkToWorkflowLink(input)); + } + + @Test + public void testConvertNexusToWorkflow_InvalidPathTrailingSegment() { + // The workflow-event form addresses an event inside the workflow, so it must not be accepted + // as a workflow link even when the type says otherwise. + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id/history") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertNull(nexusLinkToWorkflowLink(input)); + } + + @Test + public void testConvertNexusToWorkflow_ReasonNotFirstQueryParam() { + // The reason is located by key, not by position, so unrelated params ahead of it are skipped. + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl( + "temporal:///namespaces/ns/workflows/wf-id/run-id?foo=bar&reason=Query+processed") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals("Query processed", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); + } + + @Test + public void testConvertNexusToWorkflow_EmptyReasonValue() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); + } + + @Test + public void testConvertNexusToWorkflow_BareReasonKey() { + // A key with no '=' must not blow up on the missing value. + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); + } + + @Test + public void testConvertNexusToWorkflow_ReasonPrefixKeyIgnored() { + // "reasonx" must not be treated as "reason". + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reasonx=nope") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); + } + + @Test + public void testConvertNexusToWorkflow_EmptyUrl() { + // A URL with no scheme must be reported as an invalid scheme rather than throwing. + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertNull(nexusLinkToWorkflowLink(input)); + } + + /** + * A '+' in a path segment is a literal '+', not a space. Form decoding would turn it into a space + * and point at a different execution. + */ + @Test + public void testConvertNexusToWorkflow_LiteralPlusInPathIsPreserved() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/a+b/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals("a+b", nexusLinkToWorkflowLink(input).getWorkflow().getWorkflowId()); + + // A percent-escaped space still decodes to a space. + io.temporal.api.nexus.v1.Link spaceInput = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/a%20b/run-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertEquals("a b", nexusLinkToWorkflowLink(spaceInput).getWorkflow().getWorkflowId()); + + // A '+' this SDK encoded itself does survive, because URLEncoder emits %2B. + Link.Workflow w = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("a+b") + .setRunId("run-id") + .build(); + assertEquals( + Link.newBuilder().setWorkflow(w).build(), + nexusLinkToWorkflowLink(workflowLinkToNexusLink(w))); + } + + @Test + public void testConvertNexusToWorkflow_InvalidPathMissingRunID() { + io.temporal.api.nexus.v1.Link input = + io.temporal.api.nexus.v1.Link.newBuilder() + .setUrl("temporal:///namespaces/ns/workflows/wf-id") + .setType("temporal.api.common.v1.Link.Workflow") + .build(); + + assertNull(nexusLinkToWorkflowLink(input)); + } + + @Test + public void testWorkflowLinkRoundTrip() { + // Reserved characters in every field at once: the path segments are percent-escaped and the + // reason is form-encoded, so a reason containing '=' and '&' must not be split as query syntax. + Link.Workflow w = + Link.Workflow.newBuilder() + .setNamespace("ns/with/slash") + .setWorkflowId("wf id with space") + .setRunId("run-id") + .setReason("reason with = and &") + .build(); + + io.temporal.api.nexus.v1.Link nexusLink = workflowLinkToNexusLink(w); + assertEquals("temporal.api.common.v1.Link.Workflow", nexusLink.getType()); + assertEquals(Link.newBuilder().setWorkflow(w).build(), nexusLinkToWorkflowLink(nexusLink)); + } + + @Test + public void testLinkToNexusLink_Workflow() { + Link.Workflow w = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .setReason("Query processed") + .build(); + + io.temporal.api.nexus.v1.Link actual = + linkToNexusLink(Link.newBuilder().setWorkflow(w).build()); + assertEquals(workflowLinkToNexusLink(w), actual); + } + + @Test + public void testNexusLinkToLink_WorkflowRoundTrip() { + Link.Workflow w = + Link.Workflow.newBuilder() + .setNamespace("ns") + .setWorkflowId("wf-id") + .setRunId("run-id") + .setReason("Query processed") + .build(); + + io.temporal.api.nexus.v1.Link nexusLink = workflowLinkToNexusLink(w); + Link converted = nexusLinkToLink(nexusLink); + assertNotNull(converted); + assertEquals(Link.newBuilder().setWorkflow(w).build(), converted); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/QueryOperationTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/QueryOperationTest.java new file mode 100644 index 0000000000..5ee2a0b218 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/QueryOperationTest.java @@ -0,0 +1,395 @@ +package io.temporal.workflow.nexus; + +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.Operation; +import io.nexusrpc.Service; +import io.nexusrpc.handler.HandlerException; +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.api.common.v1.Link; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.enums.v1.QueryRejectCondition; +import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowTargetOptions; +import io.temporal.failure.NexusOperationFailure; +import io.temporal.nexus.Nexus; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.QueryMethod; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; + +/** + * A Nexus operation backed by a workflow Query. A Query is always synchronous and writes nothing to + * history, so the handler simply queries and returns the result; there is no operation token and no + * completion callback. + * + *

Covers the value round trip plus the failure modes a caller can observe: an unknown workflow, + * a query handler that throws, and a Query rejected by the client's reject condition. All of these + * must fail the caller's Nexus operation rather than hanging or returning a default. + */ +public class QueryOperationTest { + + private static final int BUMPS = 2; + + @BeforeClass + public static void requireExternalService() { + assumeTrue( + "query response links require a real server that populates QueryWorkflowResponse.link", + SDKTestWorkflowRule.useExternalService); + } + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(QueryCallerWorkflowImpl.class, CounterWorkflowImpl.class) + .setNexusServiceImplementation(new QueryingNexusServiceImpl()) + // The workflow being queried parks on a signal, so time skipping would fast-forward it + // into its execution timeout and it would be gone before the Query lands. + .setUseTimeskipping(false) + // Matches the reject condition the Go test passes per request; in Java the condition is a + // client-level option. + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setQueryRejectCondition(QueryRejectCondition.QUERY_REJECT_CONDITION_NOT_OPEN) + .build()) + .build(); + + @Test + public void queryOperationReturnsResult() { + String targetWorkflowId = startCounterWorkflow(); + bumpCounter(targetWorkflowId, BUMPS); + + QueryCallerWorkflow caller = + testWorkflowRule.newWorkflowStubTimeoutOptions(QueryCallerWorkflow.class, "query-caller"); + Assert.assertEquals( + "the operation should return what the query handler computed from workflow state", + BUMPS, + caller.execute(new QueryRequest(targetWorkflowId, "", false))); + + completeCounterWorkflow(targetWorkflowId); + } + + /** + * End-to-end response link check: the server attaches a link to {@code QueryWorkflowResponse}, + * {@code RootWorkflowClientInvoker.query} hands it to the Nexus operation context, and the SDK + * puts it on the caller's {@code NexusOperationCompleted} event. + * + *

Only the response direction is asserted. A Query writes nothing to the queried workflow's + * history, so there is no event on the callee side to carry a forward link — unlike the signal + * case in {@link SignalOperationLinkingTest}. + */ + @Test + public void queryOperationCapturesResponseLink() { + String targetWorkflowId = startCounterWorkflow(); + bumpCounter(targetWorkflowId, BUMPS); + + QueryCallerWorkflow caller = + testWorkflowRule.newWorkflowStubTimeoutOptions(QueryCallerWorkflow.class, "link-caller"); + Assert.assertEquals(BUMPS, caller.execute(new QueryRequest(targetWorkflowId, "", false))); + + String callerWorkflowId = WorkflowStub.fromTyped(caller).getExecution().getWorkflowId(); + History callerHistory = + testWorkflowRule.getWorkflowClient().fetchHistory(callerWorkflowId).getHistory(); + + List completedEvents = + getAllEventsOfType(callerHistory, EventType.EVENT_TYPE_NEXUS_OPERATION_COMPLETED); + Assert.assertEquals( + "expected exactly one NexusOperationCompleted event", 1, completedEvents.size()); + assertQueryResponseLink(completedEvents.get(0), targetWorkflowId); + + completeCounterWorkflow(targetWorkflowId); + } + + @Test + public void queryOnUnknownWorkflowFailsOperation() { + QueryCallerWorkflow caller = + testWorkflowRule.newWorkflowStubTimeoutOptions( + QueryCallerWorkflow.class, "unknown-wid-caller"); + + assertOperationFailedWith( + HandlerException.ErrorType.NOT_FOUND, + () -> caller.execute(new QueryRequest("unknown-wid-" + UUID.randomUUID(), "", false))); + } + + @Test + public void queryOnUnknownRunFailsOperation() { + String targetWorkflowId = startCounterWorkflow(); + QueryCallerWorkflow caller = + testWorkflowRule.newWorkflowStubTimeoutOptions( + QueryCallerWorkflow.class, "unknown-rid-caller"); + + assertOperationFailedWith( + HandlerException.ErrorType.NOT_FOUND, + () -> + caller.execute( + new QueryRequest(targetWorkflowId, UUID.randomUUID().toString(), false))); + + completeCounterWorkflow(targetWorkflowId); + } + + @Test + public void failedQueryFailsOperation() { + String targetWorkflowId = startCounterWorkflow(); + QueryCallerWorkflow caller = + testWorkflowRule.newWorkflowStubTimeoutOptions( + QueryCallerWorkflow.class, "failed-query-caller"); + + assertOperationFailedWith( + HandlerException.ErrorType.BAD_REQUEST, + () -> caller.execute(new QueryRequest(targetWorkflowId, "", true))); + + completeCounterWorkflow(targetWorkflowId); + } + + @Test + public void rejectedQueryFailsOperation() { + // The reject condition is NOT_OPEN, so querying a workflow that has already closed is rejected + // and must surface as an operation failure. + String targetWorkflowId = startCounterWorkflow(); + completeCounterWorkflow(targetWorkflowId); + + QueryCallerWorkflow caller = + testWorkflowRule.newWorkflowStubTimeoutOptions( + QueryCallerWorkflow.class, "rejected-query-caller"); + + assertOperationFailedWith( + HandlerException.ErrorType.BAD_REQUEST, + () -> caller.execute(new QueryRequest(targetWorkflowId, "", false))); + } + + // ── helpers ────────────────────────────────────────────────────────────────────────────── + + /** + * Asserts the caller's operation failed, and that it failed with the specific handler error type + * the SDK is supposed to derive from what the handler threw. Asserting only {@code + * NexusOperationFailure} would still pass if every failure collapsed into one retryable type, so + * the mapping in {@code NexusTaskHandlerImpl.convertKnownFailures} is pinned here. + */ + private static void assertOperationFailedWith( + HandlerException.ErrorType expectedErrorType, ThrowingRunnable callerInvocation) { + WorkflowFailedException e = + Assert.assertThrows(WorkflowFailedException.class, callerInvocation); + Assert.assertTrue( + "expected the caller to fail with a NexusOperationFailure but got: " + e.getCause(), + e.getCause() instanceof NexusOperationFailure); + + Throwable handlerFailure = e.getCause().getCause(); + Assert.assertTrue( + "expected a HandlerException under the NexusOperationFailure but got: " + handlerFailure, + handlerFailure instanceof HandlerException); + Assert.assertEquals(expectedErrorType, ((HandlerException) handlerFailure).getErrorType()); + } + + /** + * Assert that a caller-side event carries a response link naming the queried workflow. A Query + * produces no history event, so the server answers with a {@code Link.Workflow} identifying the + * execution that processed the Query rather than the {@code Link.WorkflowEvent} the signal and + * update paths use. + */ + private static void assertQueryResponseLink(HistoryEvent event, String queriedWorkflowId) { + Assert.assertTrue( + "expected a query response link on " + event.getEventType().name(), + event.getLinksCount() >= 1); + Link link = event.getLinks(0); + Assert.assertTrue( + "a Query link must use the Workflow variant, not WorkflowEvent, because a Query writes" + + " nothing to history; got: " + + link, + link.hasWorkflow()); + Assert.assertEquals( + "the response link should name the queried workflow", + queriedWorkflowId, + link.getWorkflow().getWorkflowId()); + Assert.assertFalse( + "the response link should name the run that processed the Query", + link.getWorkflow().getRunId().isEmpty()); + } + + /** Find all history events of a given type, in order. */ + private static List getAllEventsOfType(History history, EventType type) { + List out = new ArrayList<>(); + for (HistoryEvent e : history.getEventsList()) { + if (e.getEventType() == type) { + out.add(e); + } + } + return out; + } + + private String startCounterWorkflow() { + String workflowId = "counter-" + UUID.randomUUID(); + WorkflowStub stub = + testWorkflowRule + .getWorkflowClient() + .newUntypedWorkflowStub( + "CounterWorkflow", + WorkflowOptions.newBuilder() + .setWorkflowId(workflowId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .build()); + stub.start(); + return workflowId; + } + + private void bumpCounter(String workflowId, int times) { + CounterWorkflow stub = + testWorkflowRule.getWorkflowClient().newWorkflowStub(CounterWorkflow.class, workflowId); + for (int i = 0; i < times; i++) { + stub.bump(); + } + } + + private void completeCounterWorkflow(String workflowId) { + WorkflowStub stub = testWorkflowRule.getWorkflowClient().newUntypedWorkflowStub(workflowId); + stub.signal("done"); + stub.getResult(Integer.class); + } + + // ── workflows ──────────────────────────────────────────────────────────────────────────── + + /** Target of the Query: holds a counter that signals advance and a query reads. */ + @WorkflowInterface + public interface CounterWorkflow { + @WorkflowMethod + int execute(); + + @QueryMethod + int getCount(boolean fail); + + @SignalMethod + void bump(); + + @SignalMethod + void done(); + } + + public static class CounterWorkflowImpl implements CounterWorkflow { + private int counter; + private boolean completed; + + @Override + public int execute() { + Workflow.await(() -> completed); + return counter; + } + + @Override + public int getCount(boolean fail) { + if (fail) { + // A query handler that throws makes the server answer with a query failure, which the + // handler surfaces to the caller as a failed operation. + throw new IllegalStateException("query failed (for testing)"); + } + return counter; + } + + @Override + public void bump() { + counter++; + } + + @Override + public void done() { + completed = true; + } + } + + @WorkflowInterface + public interface QueryCallerWorkflow { + @WorkflowMethod + int execute(QueryRequest request); + } + + public static class QueryCallerWorkflowImpl implements QueryCallerWorkflow { + @Override + public int execute(QueryRequest request) { + TestNexusQueryService service = + Workflow.newNexusServiceStub( + TestNexusQueryService.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(20)) + .build()) + .build()); + return service.query(request); + } + } + + // ── nexus service ──────────────────────────────────────────────────────────────────────── + + @Service + public interface TestNexusQueryService { + @Operation + Integer query(QueryRequest input); + } + + @ServiceImpl(service = TestNexusQueryService.class) + public static class QueryingNexusServiceImpl { + @OperationImpl + public OperationHandler query() { + // A Query resolves immediately, so this is a plain synchronous operation: no operation token, + // no completion callback, nothing to cancel. + return OperationHandler.sync( + (context, details, input) -> { + WorkflowClient client = Nexus.getOperationContext().getWorkflowClient(); + WorkflowTargetOptions.Builder target = + WorkflowTargetOptions.newBuilder().setWorkflowId(input.getWorkflowId()); + if (!input.getRunId().isEmpty()) { + target.setRunId(input.getRunId()); + } + return client + .newWorkflowStub(CounterWorkflow.class, target.build()) + .getCount(input.isFail()); + }); + } + } + + /** Input describing which workflow to query and how the query should behave. */ + public static final class QueryRequest { + private String workflowId; + private String runId; + private boolean fail; + + public QueryRequest() {} + + QueryRequest(String workflowId, String runId, boolean fail) { + this.workflowId = workflowId; + this.runId = runId; + this.fail = fail; + } + + public String getWorkflowId() { + return workflowId; + } + + public String getRunId() { + return runId; + } + + public boolean isFail() { + return fail; + } + } +} From 479ea76c74345e99786057f5fd08534055dd7bcc Mon Sep 17 00:00:00 2001 From: Edward Amsden Date: Fri, 4 Sep 2026 18:09:01 -0500 Subject: [PATCH 087/107] Fix WorkerFactory command-worker cleanup race (#2952) * Add regression test for command worker cleanup race * Prevent race by not setting workerCommandWorker to null when shutting down --------- Co-authored-by: Dan Plyukhin --- .../io/temporal/worker/WorkerFactory.java | 1 - .../temporal/worker/WorkerShutdownTest.java | 40 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java index b6fa12650f..b13c5407d0 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java @@ -487,7 +487,6 @@ private void doShutdown(boolean interruptUserTasks) { } cache.invalidateAll(); workflowThreadPool.shutdownNow(); - workerCommandWorker = null; return null; }) .whenComplete( diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java index 390efe1e7d..415b1b3926 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java @@ -25,21 +25,61 @@ import io.temporal.internal.sync.WorkflowThreadExecutor; import io.temporal.internal.worker.NamespaceCapabilities; import io.temporal.internal.worker.ShutdownManager; +import io.temporal.internal.worker.SuspendableWorker; import io.temporal.internal.worker.WorkflowExecutorCache; import io.temporal.internal.worker.WorkflowRunLockManager; import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.testing.TestEnvironmentOptions; +import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.workflow.WorkflowInterface; import io.temporal.workflow.WorkflowMethod; import io.temporal.workflow.shared.TestNexusServices; +import java.lang.reflect.Field; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.function.Supplier; import org.junit.Test; import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; public class WorkerShutdownTest { + @Test + public void awaitTerminationDoesNotRaceWithWorkerCommandWorkerCleanup() throws Exception { + try (TestWorkflowEnvironment env = + TestWorkflowEnvironment.newInstance(TestEnvironmentOptions.newBuilder().build())) { + WorkerFactory factory = env.getWorkerFactory(); + SuspendableWorker workerCommandWorker = mock(SuspendableWorker.class); + CompletableFuture shutdownFuture = new CompletableFuture<>(); + when(workerCommandWorker.shutdown(any(ShutdownManager.class), eq(true))) + .thenReturn(shutdownFuture); + + Field field = WorkerFactory.class.getDeclaredField("workerCommandWorker"); + field.setAccessible(true); + field.set(factory, workerCommandWorker); + + factory.shutdown(); + + try (MockedStatic shutdownManager = mockStatic(ShutdownManager.class)) { + shutdownManager + .when(() -> ShutdownManager.runAndGetRemainingTimeoutMs(anyLong(), any(Runnable.class))) + .thenAnswer( + invocation -> { + // Complete shutdown after awaitTermination passes its null check. + shutdownFuture.complete(null); + invocation.getArgument(1, Runnable.class).run(); + return 0L; + }); + + factory.awaitTermination(1, TimeUnit.SECONDS); + } + + verify(workerCommandWorker).awaitTermination(1_000, TimeUnit.MILLISECONDS); + } + } + @WorkflowInterface public interface TestWorkflow { @WorkflowMethod From 015fdc1202c757aac904bd38e2ad58bb4919c40f Mon Sep 17 00:00:00 2001 From: Justin Anderson <44687433+jmaeagle99@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:19:29 -0700 Subject: [PATCH 088/107] Pass a dedicated context to the driver selector (#3047) --- .../ExternalStoragePayloadTransformer.java | 9 ++-- .../StorageDriverSelectContextImpl.java | 33 +++++++++++++++ .../storage/StorageDriverSelectContext.java | 33 +++++++++++++++ .../storage/StorageDriverSelector.java | 2 +- ...ExternalStoragePayloadTransformerTest.java | 41 +++++++++++++++++++ .../payload/storage/ExternalStorageTest.java | 6 +-- 6 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverSelectContextImpl.java create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelectContext.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java index ef13075f9e..f88789fe42 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformer.java @@ -8,6 +8,7 @@ import io.temporal.payload.storage.StorageDriver; import io.temporal.payload.storage.StorageDriverClaim; import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverSelectContext; import io.temporal.payload.storage.StorageDriverSelector; import io.temporal.payload.storage.StorageDriverStoreContext; import io.temporal.payload.storage.StorageDriverTargetInfo; @@ -52,11 +53,11 @@ CompletableFuture> store( List payloads, @Nullable StorageDriverTargetInfo target, CancellationToken cancellationToken) { - StorageDriverStoreContext context = - new StorageDriverStoreContextImpl(target, cancellationToken); + StorageDriverSelectContext selectContext = + new StorageDriverSelectContextImpl(target, cancellationToken); Map> batches; try { - batches = buildStoreBatches(payloads, context); + batches = buildStoreBatches(payloads, selectContext); } catch (RuntimeException e) { return failedFuture(e); } @@ -68,7 +69,7 @@ CompletableFuture> store( } private Map> buildStoreBatches( - List payloads, StorageDriverStoreContext context) { + List payloads, StorageDriverSelectContext context) { Map> batches = new LinkedHashMap<>(); for (int i = 0; i < payloads.size(); i++) { Payload payload = payloads.get(i); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverSelectContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverSelectContextImpl.java new file mode 100644 index 0000000000..5c0fa2f54a --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/StorageDriverSelectContextImpl.java @@ -0,0 +1,33 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.common.CancellationToken; +import io.temporal.payload.storage.StorageDriverSelectContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.Objects; +import java.util.concurrent.CancellationException; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +final class StorageDriverSelectContextImpl implements StorageDriverSelectContext { + private final @Nullable StorageDriverTargetInfo target; + private final CancellationToken cancellationToken; + + StorageDriverSelectContextImpl( + @Nullable StorageDriverTargetInfo target, + CancellationToken cancellationToken) { + this.target = target; + this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken"); + } + + @Nullable + @Override + public StorageDriverTargetInfo getTarget() { + return target; + } + + @Nonnull + @Override + public CancellationToken getCancellationToken() { + return cancellationToken; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelectContext.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelectContext.java new file mode 100644 index 0000000000..159924c15b --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelectContext.java @@ -0,0 +1,33 @@ +package io.temporal.payload.storage; + +import io.temporal.common.CancellationToken; +import io.temporal.common.Experimental; +import java.util.concurrent.CancellationException; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * Context passed to {@link StorageDriverSelector}. + * + *

The SDK supplies the instance a selector receives. Members added here in later releases will + * carry a default, so an existing selector-side implementation keeps compiling and behaves as + * though the new member were absent. + */ +@Experimental +public interface StorageDriverSelectContext { + /** + * Identity of the workflow or activity the payload is being stored for, or {@code null} when it + * is not available. + */ + @Nullable + StorageDriverTargetInfo getTarget(); + + /** + * Token cancelled when the SDK abandons the operation this selection is part of. Defaults to a + * token that is never cancelled. + */ + @Nonnull + default CancellationToken getCancellationToken() { + return CancellationToken.none(); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java index 966e52e68d..e72d99f1d9 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/storage/StorageDriverSelector.java @@ -14,5 +14,5 @@ public interface StorageDriverSelector { * {@link ExternalStorage}, or {@code null} to leave the payload stored inline. */ @Nullable - StorageDriver selectDriver(@Nonnull StorageDriverStoreContext context, @Nonnull Payload payload); + StorageDriver selectDriver(@Nonnull StorageDriverSelectContext context, @Nonnull Payload payload); } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java index 2dcb58f384..110bd6ff2e 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java @@ -16,8 +16,11 @@ import io.temporal.payload.storage.StorageDriver; import io.temporal.payload.storage.StorageDriverClaim; import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverSelectContext; import io.temporal.payload.storage.StorageDriverSelector; import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -115,6 +118,44 @@ public void multipleDriversBatchPerDriverAndPreserveOrder() throws Exception { assertEquals(input, transformer.retrieve(stored, CancellationToken.none()).get()); } + @Test + public void selectorReceivesSelectContextCarryingTheTarget() throws Exception { + AtomicReference seen = new AtomicReference<>(); + AtomicReference storeSeen = new AtomicReference<>(); + InMemoryDriver driver = + new InMemoryDriver("d1") { + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + storeSeen.set(context); + return super.store(context, payloads); + } + }; + StorageDriverSelector selector = + (context, payload) -> { + seen.set(context); + return driver; + }; + ExternalStoragePayloadTransformer transformer = + ExternalStoragePayloadTransformer.fromOptions( + ExternalStorage.newBuilder() + .setDriver(driver) + .setDriverSelector(selector) + .setPayloadSizeThreshold(0) + .build()); + StorageDriverTargetInfo target = + new StorageDriverWorkflowInfo("ns", "wf-id", "run-id", "MyWorkflow"); + + transformer + .store(Collections.singletonList(payload("a")), target, CancellationToken.none()) + .get(); + + assertNotNull(seen.get()); + assertSame(target, seen.get().getTarget()); + assertNotNull(storeSeen.get()); + assertSame(target, storeSeen.get().getTarget()); + } + @Test public void arityMismatchFails() { StorageDriver driver = diff --git a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java index e68b13b3a0..c7ccb8c067 100644 --- a/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java +++ b/temporal-sdk/src/test/java/io/temporal/payload/storage/ExternalStorageTest.java @@ -14,8 +14,8 @@ /** Tests external storage option validation and defaults. */ public class ExternalStorageTest { - private static StorageDriverStoreContext storeContext(StorageDriverTargetInfo target) { - return new StorageDriverStoreContext() { + private static StorageDriverSelectContext selectContext(StorageDriverTargetInfo target) { + return new StorageDriverSelectContext() { @Override public StorageDriverTargetInfo getTarget() { return target; @@ -56,7 +56,7 @@ public void singleDriverNoSelectorSynthesizesSelector() { assertEquals(1, storage.getDrivers().size()); StorageDriverSelector selector = storage.getDriverSelector(); assertNotNull(selector); - assertSame(a, selector.selectDriver(storeContext(null), Payload.getDefaultInstance())); + assertSame(a, selector.selectDriver(selectContext(null), Payload.getDefaultInstance())); } @Test From c73480ffafd0680321e1ed3d3562bcc0f4da181b Mon Sep 17 00:00:00 2001 From: Spencer Judge Date: Tue, 8 Sep 2026 14:40:01 -0700 Subject: [PATCH 089/107] Report runtime environment information in worker heartbeats (#3052) --- .../client/WorkflowClientInternalImpl.java | 12 + .../client/WorkflowClientOptions.java | 31 ++ .../client/WorkflowClientInternal.java | 8 + .../internal/worker/HeartbeatManager.java | 43 ++- .../worker/WorkerEnvironmentInfo.java | 312 ++++++++++++++++++ .../main/java/io/temporal/worker/Worker.java | 18 +- .../io/temporal/worker/WorkerFactory.java | 10 +- .../internal/worker/HeartbeatManagerTest.java | 108 +++++- .../worker/WorkerEnvironmentInfoTest.java | 113 +++++++ .../temporal/worker/WorkerShutdownTest.java | 126 ++++--- 10 files changed, 714 insertions(+), 67 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java index c92d1d8390..0b892425e0 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java @@ -10,6 +10,7 @@ import io.temporal.api.enums.v1.TaskReachability; import io.temporal.api.history.v1.History; import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.worker.v1.EnvironmentInfo; import io.temporal.api.workflowservice.v1.*; import io.temporal.client.WorkflowInvocationHandler.InvocationType; import io.temporal.common.WorkflowExecutionHistory; @@ -25,6 +26,7 @@ import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.sync.StubMarker; import io.temporal.internal.worker.HeartbeatManager; +import io.temporal.internal.worker.WorkerEnvironmentInfo; import io.temporal.payload.storage.ExternalStorage; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.WorkflowServiceStubs; @@ -58,6 +60,7 @@ final class WorkflowClientInternalImpl implements WorkflowClient, WorkflowClient private final WorkerFactoryRegistry workerFactoryRegistry = new WorkerFactoryRegistry(); private final String workerGroupingKey = java.util.UUID.randomUUID().toString(); private final @Nullable HeartbeatManager heartbeatManager; + private final @Nullable EnvironmentInfo workerEnvironmentInfo; private final @Nullable ExternalStorageRunner externalStorageRunner; /** @@ -126,8 +129,11 @@ public static WorkflowClient newInstance( if (!heartbeatInterval.isNegative()) { this.heartbeatManager = new HeartbeatManager(workflowServiceStubs, options.getIdentity(), heartbeatInterval); + this.workerEnvironmentInfo = + options.isWorkerEnvironmentInfoDisabled() ? null : WorkerEnvironmentInfo.detect(); } else { this.heartbeatManager = null; + this.workerEnvironmentInfo = null; } } @@ -821,6 +827,12 @@ public HeartbeatManager getHeartbeatManager() { return heartbeatManager; } + @Override + @Nullable + public EnvironmentInfo getWorkerEnvironmentInfo() { + return workerEnvironmentInfo; + } + @Override @Nullable public ExternalStorageRunner getExternalStorageRunner() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java index e0e6a5a7b6..3015d20233 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientOptions.java @@ -54,6 +54,7 @@ public static final class Builder { private QueryRejectCondition queryRejectCondition; private WorkflowClientPlugin[] plugins; private Duration workerHeartbeatInterval; + private boolean disableWorkerEnvironmentInfo; private ExternalStorage externalStorage; private Builder() {} @@ -71,6 +72,7 @@ private Builder(WorkflowClientOptions options) { queryRejectCondition = options.queryRejectCondition; plugins = options.plugins; workerHeartbeatInterval = options.workerHeartbeatInterval; + disableWorkerEnvironmentInfo = options.disableWorkerEnvironmentInfo; externalStorage = options.externalStorage; } @@ -187,6 +189,19 @@ public Builder setWorkerHeartbeatInterval(Duration workerHeartbeatInterval) { return this; } + /** + * Disables reporting the JVM version, detected hosting environments (Docker, Kubernetes, cloud + * platforms), and OS platform in worker heartbeats. This information is sent once per worker, + * with the first heartbeat accepted by the server. + * + * @param disableWorkerEnvironmentInfo true to omit environment information from heartbeats + */ + @Experimental + public Builder setDisableWorkerEnvironmentInfo(boolean disableWorkerEnvironmentInfo) { + this.disableWorkerEnvironmentInfo = disableWorkerEnvironmentInfo; + return this; + } + public WorkflowClientOptions build() { return new WorkflowClientOptions( namespace, @@ -198,6 +213,7 @@ public WorkflowClientOptions build() { queryRejectCondition, plugins == null ? EMPTY_PLUGINS : plugins, resolveHeartbeatInterval(workerHeartbeatInterval), + disableWorkerEnvironmentInfo, externalStorage); } @@ -226,6 +242,7 @@ public WorkflowClientOptions validateAndBuildWithDefaults() { : queryRejectCondition, plugins == null ? EMPTY_PLUGINS : plugins, resolveHeartbeatInterval(workerHeartbeatInterval), + disableWorkerEnvironmentInfo, externalStorage); } @@ -269,6 +286,8 @@ private static Duration resolveHeartbeatInterval(Duration raw) { private final Duration workerHeartbeatInterval; + private final boolean disableWorkerEnvironmentInfo; + private final @Nullable ExternalStorage externalStorage; private WorkflowClientOptions( @@ -281,6 +300,7 @@ private WorkflowClientOptions( QueryRejectCondition queryRejectCondition, WorkflowClientPlugin[] plugins, Duration workerHeartbeatInterval, + boolean disableWorkerEnvironmentInfo, @Nullable ExternalStorage externalStorage) { this.namespace = namespace; this.dataConverter = dataConverter; @@ -291,6 +311,7 @@ private WorkflowClientOptions( this.queryRejectCondition = queryRejectCondition; this.plugins = plugins; this.workerHeartbeatInterval = workerHeartbeatInterval; + this.disableWorkerEnvironmentInfo = disableWorkerEnvironmentInfo; this.externalStorage = externalStorage; } @@ -365,6 +386,12 @@ public Duration getWorkerHeartbeatInterval() { return workerHeartbeatInterval; } + /** Returns true when runtime, hosting, and platform information is omitted from heartbeats. */ + @Experimental + public boolean isWorkerEnvironmentInfoDisabled() { + return disableWorkerEnvironmentInfo; + } + @Override public String toString() { return "WorkflowClientOptions{" @@ -389,6 +416,8 @@ public String toString() { + Arrays.toString(plugins) + ", workerHeartbeatInterval=" + workerHeartbeatInterval + + ", disableWorkerEnvironmentInfo=" + + disableWorkerEnvironmentInfo + ", externalStorage=" + externalStorage + '}'; @@ -409,6 +438,7 @@ public boolean equals(Object o) { && Arrays.equals(plugins, that.plugins) && com.google.common.base.Objects.equal( workerHeartbeatInterval, that.workerHeartbeatInterval) + && disableWorkerEnvironmentInfo == that.disableWorkerEnvironmentInfo && com.google.common.base.Objects.equal(externalStorage, that.externalStorage); } @@ -424,6 +454,7 @@ public int hashCode() { queryRejectCondition, Arrays.hashCode(plugins), workerHeartbeatInterval, + disableWorkerEnvironmentInfo, externalStorage); } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java index 982ae56724..90017cd575 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientInternal.java @@ -1,5 +1,6 @@ package io.temporal.internal.client; +import io.temporal.api.worker.v1.EnvironmentInfo; import io.temporal.client.WorkflowClient; import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.worker.HeartbeatManager; @@ -27,6 +28,13 @@ public interface WorkflowClientInternal { @Nullable HeartbeatManager getHeartbeatManager(); + /** + * Environment information workers report in their heartbeats until the server accepts one, or + * null if disabled. + */ + @Nullable + EnvironmentInfo getWorkerEnvironmentInfo(); + @Nullable ExternalStorageRunner getExternalStorageRunner(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/HeartbeatManager.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/HeartbeatManager.java index 09edd173e3..2015e7389d 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/HeartbeatManager.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/HeartbeatManager.java @@ -36,7 +36,11 @@ public HeartbeatManager(WorkflowServiceStubs service, String identity, Duration * is the first worker for the given namespace. */ public void registerWorker( - String namespace, String workerInstanceKey, Supplier callback) { + String namespace, + String workerInstanceKey, + Supplier callback, + Runnable onHeartbeatAccepted) { + WorkerCallbacks callbacks = new WorkerCallbacks(callback, onHeartbeatAccepted); synchronized (lock) { if (unimplementedNamespaces.contains(namespace)) { return; @@ -45,12 +49,12 @@ public void registerWorker( namespace, (ns, existing) -> { if (existing != null && !existing.isShutdown()) { - existing.registerWorker(workerInstanceKey, callback); + existing.registerWorker(workerInstanceKey, callbacks); return existing; } SharedNamespaceWorker nsWorker = new SharedNamespaceWorker(this, service, ns, identity, interval); - nsWorker.registerWorker(workerInstanceKey, callback); + nsWorker.registerWorker(workerInstanceKey, callbacks); return nsWorker; }); } @@ -96,6 +100,16 @@ void markNamespaceUnimplemented(String namespace) { } } + private static final class WorkerCallbacks { + final Supplier heartbeat; + final Runnable heartbeatAccepted; + + WorkerCallbacks(Supplier heartbeat, Runnable heartbeatAccepted) { + this.heartbeat = heartbeat; + this.heartbeatAccepted = heartbeatAccepted; + } + } + /** * Handles heartbeating for all workers in a specific namespace. Each instance owns its own * scheduler thread and callback map. @@ -105,8 +119,7 @@ static class SharedNamespaceWorker { private final WorkflowServiceStubs service; private final String namespace; private final String identity; - private final ConcurrentHashMap> callbacks = - new ConcurrentHashMap<>(); + private final ConcurrentHashMap callbacks = new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler; SharedNamespaceWorker( @@ -130,8 +143,8 @@ static class SharedNamespaceWorker { this::heartbeatTick, 0, interval.toMillis(), TimeUnit.MILLISECONDS); } - void registerWorker(String workerInstanceKey, Supplier callback) { - callbacks.put(workerInstanceKey, callback); + void registerWorker(String workerInstanceKey, WorkerCallbacks workerCallbacks) { + callbacks.put(workerInstanceKey, workerCallbacks); } void unregisterWorker(String workerInstanceKey) { @@ -165,9 +178,11 @@ private void heartbeatTick() { if (callbacks.isEmpty()) return; List heartbeats = new ArrayList<>(); - for (Map.Entry> entry : callbacks.entrySet()) { + List acceptedCallbacks = new ArrayList<>(); + for (Map.Entry entry : callbacks.entrySet()) { try { - heartbeats.add(entry.getValue().get()); + heartbeats.add(entry.getValue().heartbeat.get()); + acceptedCallbacks.add(entry.getValue().heartbeatAccepted); } catch (Exception e) { log.warn( "Failed to build heartbeat for worker {} in namespace {}", @@ -196,8 +211,18 @@ private void heartbeatTick() { return; } log.warn("Failed to send worker heartbeat for namespace {}", namespace, e); + return; } catch (Exception e) { log.warn("Failed to send worker heartbeat for namespace {}", namespace, e); + return; + } + + for (Runnable accepted : acceptedCallbacks) { + try { + accepted.run(); + } catch (Exception e) { + log.warn("Heartbeat accepted callback failed in namespace {}", namespace, e); + } } } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java new file mode 100644 index 0000000000..cd33aad2fa --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkerEnvironmentInfo.java @@ -0,0 +1,312 @@ +package io.temporal.internal.worker; + +import io.temporal.api.worker.v1.EnvironmentInfo; +import io.temporal.api.worker.v1.EnvironmentInfo.Architecture; +import io.temporal.api.worker.v1.EnvironmentInfo.HostingEnvironment; +import io.temporal.api.worker.v1.EnvironmentInfo.HostingEnvironment.HostingEnvironmentType; +import io.temporal.api.worker.v1.EnvironmentInfo.LinuxPlatform; +import io.temporal.api.worker.v1.EnvironmentInfo.MacOSPlatform; +import io.temporal.api.worker.v1.EnvironmentInfo.Platform; +import io.temporal.api.worker.v1.EnvironmentInfo.Runtime; +import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType; +import io.temporal.api.worker.v1.EnvironmentInfo.WindowsPlatform; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; +import javax.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Detects the runtime, hosting environment, and platform information reported in the first accepted + * worker heartbeat. + */ +public final class WorkerEnvironmentInfo { + private static final Logger log = LoggerFactory.getLogger(WorkerEnvironmentInfo.class); + + private WorkerEnvironmentInfo() {} + + /** + * Never throws: this runs during client creation, and telemetry must not break it. System + * property, environment, and filesystem access can all fail under a security manager, in which + * case whatever was collected before the failure is returned. + */ + public static EnvironmentInfo detect() { + EnvironmentInfo.Builder builder = EnvironmentInfo.newBuilder(); + try { + builder.addRuntimes( + Runtime.newBuilder() + .setType(RuntimeType.RUNTIME_TYPE_JVM) + .setVersion(nullToEmpty(System.getProperty("java.version")))); + builder.addAllHostingEnvironments(detectHostingEnvironments(System::getenv)); + Platform platform = detectPlatform(); + if (platform != null) { + builder.setPlatform(platform); + } + } catch (RuntimeException e) { + log.info("Failed to detect worker environment information, reporting partial results", e); + } + return builder.build(); + } + + /** + * Several environments may be detected at once, e.g. Docker inside Kubernetes or Azure Functions + * inside Azure App Service. + */ + static List detectHostingEnvironments(Function env) { + List environments = new ArrayList<>(); + if (isDocker()) { + environments.add( + hostingEnvironment(HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_DOCKER, "")); + } + if (hasAnyEnv(env, "KUBERNETES_SERVICE_HOST")) { + environments.add(hostingEnvironment(HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_K8S, "")); + } + if (hasAnyEnv(env, "AWS_LAMBDA_FUNCTION_NAME")) { + environments.add( + hostingEnvironment(HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AWS_LAMBDA, "")); + } + if (hasAnyEnv(env, "ECS_CONTAINER_METADATA_URI_V4", "ECS_CONTAINER_METADATA_URI")) { + environments.add( + hostingEnvironment(HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AWS_ECS, "")); + } + if (hasAnyEnv(env, "K_SERVICE", "CLOUD_RUN_JOB", "CLOUD_RUN_WORKER_POOL")) { + environments.add( + hostingEnvironment(HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_GOOGLE_CLOUD_RUN, "")); + } + if (hasAnyEnv(env, "GAE_SERVICE")) { + environments.add( + hostingEnvironment( + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_GOOGLE_APP_ENGINE, "")); + } + if (hasAnyEnv(env, "WEBSITE_SITE_NAME")) { + environments.add( + hostingEnvironment( + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AZURE_APP_SERVICE, + envValue(env, "WEBSITE_PLATFORM_VERSION"))); + } + String functionsVersion = envValue(env, "FUNCTIONS_EXTENSION_VERSION"); + if (!functionsVersion.isEmpty()) { + environments.add( + hostingEnvironment( + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AZURE_FUNCTIONS, functionsVersion)); + } + if (hasAnyEnv(env, "CONTAINER_APP_NAME", "CONTAINER_APP_JOB_NAME")) { + environments.add( + hostingEnvironment( + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AZURE_CONTAINER_APPS, "")); + } + return environments; + } + + private static HostingEnvironment hostingEnvironment( + HostingEnvironmentType type, String version) { + return HostingEnvironment.newBuilder().setType(type).setVersion(version).build(); + } + + private static String envValue(Function env, String name) { + return nullToEmpty(env.apply(name)).trim(); + } + + private static boolean hasAnyEnv(Function env, String... names) { + for (String name : names) { + if (!envValue(env, name).isEmpty()) { + return true; + } + } + return false; + } + + private static boolean isDocker() { + if (isWindows(System.getProperty("os.name"))) { + return false; + } + if (Files.exists(Paths.get("/.dockerenv"))) { + return true; + } + Path cgroup = Paths.get("/proc/self/cgroup"); + if (!Files.isReadable(cgroup)) { + return false; + } + try { + return cgroupsIndicateDocker(Files.readAllLines(cgroup, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException e) { + return false; + } + } + + /** + * Reports whether any cgroup path has a {@code docker} or {@code docker-.scope} component. + */ + static boolean cgroupsIndicateDocker(List cgroupLines) { + for (String line : cgroupLines) { + int idx = line.lastIndexOf(':'); + String path = idx >= 0 ? line.substring(idx + 1) : line; + for (String component : path.split("/")) { + if (component.equals("docker") + || (component.startsWith("docker-") && component.endsWith(".scope"))) { + return true; + } + } + } + return false; + } + + @Nullable + private static Platform detectPlatform() { + String osName = nullToEmpty(System.getProperty("os.name")); + String name = osName.toLowerCase(Locale.ROOT); + String osVersion = nullToEmpty(System.getProperty("os.version")); + Architecture architecture = detectArchitecture(); + if (name.contains("linux")) { + return Platform.newBuilder() + .setLinux( + LinuxPlatform.newBuilder() + .setVersion(linuxVersion(osVersion)) + .setArchitecture(architecture)) + .build(); + } + if (name.contains("mac") || name.contains("darwin")) { + return Platform.newBuilder() + .setMacos(MacOSPlatform.newBuilder().setVersion(osVersion).setArchitecture(architecture)) + .build(); + } + if (isWindows(name)) { + return Platform.newBuilder() + .setWindows( + WindowsPlatform.newBuilder() + .setVersion(windowsVersion(osName, osVersion)) + .setArchitecture(architecture) + .setCrt( + windowsCrt( + javaMajorVersion(System.getProperty("java.specification.version"))))) + .build(); + } + return null; + } + + static Architecture detectArchitecture() { + switch (nullToEmpty(System.getProperty("os.arch")).toLowerCase(Locale.ROOT)) { + case "amd64": + case "x86_64": + return Architecture.ARCHITECTURE_AMD64; + case "aarch64": + case "arm64": + return Architecture.ARCHITECTURE_ARM64; + default: + return Architecture.ARCHITECTURE_UNSPECIFIED; + } + } + + /** + * Windows JDKs before 11 were built with Visual Studio toolchains that ship their own MSVC + * runtime; JDK 11 onward links against the Universal CRT. + */ + private static WindowsPlatform.Crt windowsCrt(int javaMajor) { + if (javaMajor <= 0) { + return WindowsPlatform.Crt.CRT_UNSPECIFIED; + } + return javaMajor >= 11 ? WindowsPlatform.Crt.CRT_UCRT : WindowsPlatform.Crt.CRT_MSVCRT; + } + + static int javaMajorVersion(@Nullable String specificationVersion) { + String version = nullToEmpty(specificationVersion); + if (version.startsWith("1.")) { + version = version.substring(2); + } + int end = version.indexOf('.'); + if (end >= 0) { + version = version.substring(0, end); + } + try { + return Integer.parseInt(version); + } catch (NumberFormatException e) { + return 0; + } + } + + /** + * Windows 11 still reports {@code os.version} as {@code 10.0}; since JDK 17.0.1 {@code os.name} + * carries the marketing version ("Windows 11"), so prefer it when it is a plain number that + * {@code os.version} contradicts. Server editions ("Windows Server 2022") are left as-is because + * their year is not a version. + */ + static String windowsVersion(String osName, String osVersion) { + String prefix = "windows "; + String lower = osName.toLowerCase(Locale.ROOT); + if (!lower.startsWith(prefix) || lower.startsWith("windows server")) { + return osVersion; + } + String marketing = osName.substring(prefix.length()).trim(); + int marketingMajor = leadingInt(marketing); + if (marketingMajor <= 0 + || !marketing.matches("[0-9]+(\\.[0-9]+)*") + || marketingMajor <= leadingInt(osVersion)) { + return osVersion; + } + return marketing; + } + + private static int leadingInt(String version) { + int end = 0; + while (end < version.length() && Character.isDigit(version.charAt(end))) { + end++; + } + try { + return Integer.parseInt(version.substring(0, end)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static boolean isWindows(@Nullable String osName) { + return nullToEmpty(osName).toLowerCase(Locale.ROOT).contains("windows"); + } + + /** + * The JVM only exposes the kernel release as {@code os.version}; prefer the distribution version + * from os-release to match what Core reports. + */ + private static String linuxVersion(String kernelVersion) { + for (String path : new String[] {"/etc/os-release", "/usr/lib/os-release"}) { + String version = osReleaseValue(Paths.get(path), "VERSION_ID"); + if (!version.isEmpty()) { + return version; + } + } + return kernelVersion; + } + + private static String osReleaseValue(Path path, String key) { + if (!Files.isReadable(path)) { + return ""; + } + try { + for (String line : Files.readAllLines(path, StandardCharsets.UTF_8)) { + int idx = line.indexOf('='); + if (idx > 0 && line.substring(0, idx).equals(key)) { + String value = line.substring(idx + 1).trim(); + if (value.length() >= 2 + && (value.startsWith("\"") && value.endsWith("\"") + || value.startsWith("'") && value.endsWith("'"))) { + value = value.substring(1, value.length() - 1); + } + return value; + } + } + } catch (IOException | RuntimeException e) { + // Fall through to the caller's fallback. + } + return ""; + } + + private static String nullToEmpty(@Nullable String value) { + return value == null ? "" : value; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java index 818308aace..6627e3fd99 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/Worker.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/Worker.java @@ -8,6 +8,7 @@ import io.temporal.api.deployment.v1.WorkerDeploymentVersion; import io.temporal.api.enums.v1.TaskQueueType; import io.temporal.api.enums.v1.WorkerStatus; +import io.temporal.api.worker.v1.EnvironmentInfo; import io.temporal.api.worker.v1.PluginInfo; import io.temporal.api.worker.v1.WorkerHeartbeat; import io.temporal.api.worker.v1.WorkerHostInfo; @@ -79,6 +80,9 @@ public final class Worker { private final @Nonnull WorkflowExecutorCache cache; private final Map previousHeartbeatSnapshots = new ConcurrentHashMap<>(); private volatile Supplier heartbeatSupplier; + // Reported in every heartbeat (including the one embedded in ShutdownWorkerRequest) until the + // server accepts one, then cleared so it is sent only once per worker. + private final AtomicReference pendingEnvironmentInfo = new AtomicReference<>(); private static final class TaskSnapshot { final int processed; @@ -595,7 +599,14 @@ List getActiveTaskQueueTypes() { return types; } - Supplier buildHeartbeatCallback(String workerGroupingKey) { + /** Called by the heartbeat manager once a heartbeat produced by this worker was accepted. */ + void onHeartbeatAccepted() { + pendingEnvironmentInfo.set(null); + } + + Supplier buildHeartbeatCallback( + String workerGroupingKey, @Nullable EnvironmentInfo environmentInfo) { + pendingEnvironmentInfo.set(environmentInfo); // The callback can be invoked concurrently from the heartbeat scheduler and the shutdown path final Object callbackLock = new Object(); final AtomicReference lastHeartbeatTime = new AtomicReference<>(null); @@ -630,6 +641,11 @@ Supplier buildHeartbeatCallback(String workerGroupingKey) { } lastHeartbeatTime.set(now); + EnvironmentInfo pendingEnvironment = pendingEnvironmentInfo.get(); + if (pendingEnvironment != null) { + hb.setEnvironment(pendingEnvironment); + } + // Deployment version if (options.getDeploymentOptions() != null && options.getDeploymentOptions().getVersion() != null) { diff --git a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java index b13c5407d0..dfb9b51326 100644 --- a/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/worker/WorkerFactory.java @@ -4,6 +4,7 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.uber.m3.tally.Scope; +import io.temporal.api.worker.v1.EnvironmentInfo; import io.temporal.api.worker.v1.WorkerHeartbeat; import io.temporal.api.workflowservice.v1.DescribeNamespaceRequest; import io.temporal.api.workflowservice.v1.DescribeNamespaceResponse; @@ -328,10 +329,15 @@ private void doStart() { // Register heartbeat callbacks after workers are started. if (hbManager != null && namespaceCapabilities.isWorkerHeartbeats()) { + EnvironmentInfo environmentInfo = clientInternal.getWorkerEnvironmentInfo(); for (Worker worker : workers.values()) { Supplier heartbeatSupplier = - worker.buildHeartbeatCallback(workerGroupingKey); - hbManager.registerWorker(namespace, worker.getWorkerInstanceKey(), heartbeatSupplier); + worker.buildHeartbeatCallback(workerGroupingKey, environmentInfo); + hbManager.registerWorker( + namespace, + worker.getWorkerInstanceKey(), + heartbeatSupplier, + worker::onHeartbeatAccepted); worker.setHeartbeatSupplier(heartbeatSupplier); } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/HeartbeatManagerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/HeartbeatManagerTest.java index 96192e4c0c..1e831270c1 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/HeartbeatManagerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/HeartbeatManagerTest.java @@ -4,10 +4,13 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import io.temporal.api.worker.v1.EnvironmentInfo; import io.temporal.api.worker.v1.WorkerHeartbeat; import io.temporal.api.workflowservice.v1.*; import io.temporal.serviceclient.WorkflowServiceStubs; import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -47,7 +50,7 @@ public void testHeartbeatRpcSentAtInterval() throws Exception { .setWorkerInstanceKey("worker-1") .setTaskQueue("test-queue") .build(); - manager.registerWorker("default", "worker-1", () -> hb); + manager.registerWorker("default", "worker-1", () -> hb, () -> {}); verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeastOnce()).recordWorkerHeartbeat(any()); @@ -76,8 +79,8 @@ public void testMultipleWorkersInSingleRpc() throws Exception { .setWorkerInstanceKey("worker-2") .setTaskQueue("queue-2") .build(); - manager.registerWorker("default", "worker-1", () -> hb1); - manager.registerWorker("default", "worker-2", () -> hb2); + manager.registerWorker("default", "worker-1", () -> hb1, () -> {}); + manager.registerWorker("default", "worker-2", () -> hb2, () -> {}); verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeast(2)).recordWorkerHeartbeat(any()); @@ -95,7 +98,7 @@ public void testUnregisterStopsRpcWhenEmpty() throws Exception { manager = new HeartbeatManager(service, "test-identity", FAST_INTERVAL); WorkerHeartbeat hb = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1").build(); - manager.registerWorker("default", "worker-1", () -> hb); + manager.registerWorker("default", "worker-1", () -> hb, () -> {}); verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeastOnce()).recordWorkerHeartbeat(any()); @@ -111,8 +114,8 @@ public void testDifferentNamespacesGetSeparateRpcs() throws Exception { WorkerHeartbeat hb1 = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-ns1").build(); WorkerHeartbeat hb2 = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-ns2").build(); - manager.registerWorker("namespace-1", "worker-ns1", () -> hb1); - manager.registerWorker("namespace-2", "worker-ns2", () -> hb2); + manager.registerWorker("namespace-1", "worker-ns1", () -> hb1, () -> {}); + manager.registerWorker("namespace-2", "worker-ns2", () -> hb2, () -> {}); verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeast(2)).recordWorkerHeartbeat(any()); @@ -140,7 +143,7 @@ public void testExceptionsCaughtAndLogged() throws Exception { manager = new HeartbeatManager(service, "test-identity", FAST_INTERVAL); WorkerHeartbeat hb = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1").build(); - manager.registerWorker("default", "worker-1", () -> hb); + manager.registerWorker("default", "worker-1", () -> hb, () -> {}); // Wait for at least 2 ticks — proves the scheduler survived the exception verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeast(2)).recordWorkerHeartbeat(any()); @@ -161,7 +164,7 @@ public void testUnimplementedStopsScheduler() throws Exception { manager = new HeartbeatManager(service, "test-identity", FAST_INTERVAL); WorkerHeartbeat hb = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1").build(); - manager.registerWorker("default", "worker-1", () -> hb); + manager.registerWorker("default", "worker-1", () -> hb, () -> {}); // Wait for the first tick to hit UNIMPLEMENTED verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeastOnce()).recordWorkerHeartbeat(any()); @@ -177,8 +180,8 @@ public void testUnregisterFromOneNamespaceDoesNotAffectAnother() throws Exceptio WorkerHeartbeat hb1 = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-ns1").build(); WorkerHeartbeat hb2 = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-ns2").build(); - manager.registerWorker("namespace-1", "worker-ns1", () -> hb1); - manager.registerWorker("namespace-2", "worker-ns2", () -> hb2); + manager.registerWorker("namespace-1", "worker-ns1", () -> hb1, () -> {}); + manager.registerWorker("namespace-2", "worker-ns2", () -> hb2, () -> {}); // Both namespaces heartbeating verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeast(2)).recordWorkerHeartbeat(any()); @@ -206,8 +209,8 @@ public void testNamespaceSchedulerStopsWhenLastWorkerUnregisters() throws Except WorkerHeartbeat hb1 = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1").build(); WorkerHeartbeat hb2 = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-2").build(); - manager.registerWorker("default", "worker-1", () -> hb1); - manager.registerWorker("default", "worker-2", () -> hb2); + manager.registerWorker("default", "worker-1", () -> hb1, () -> {}); + manager.registerWorker("default", "worker-2", () -> hb2, () -> {}); verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeastOnce()).recordWorkerHeartbeat(any()); @@ -222,6 +225,87 @@ public void testNamespaceSchedulerStopsWhenLastWorkerUnregisters() throws Except verify(blockingStub, after(VERIFY_TIMEOUT_MS).never()).recordWorkerHeartbeat(any()); } + @Test + public void testEnvironmentInfoSentUntilAccepted() throws Exception { + EnvironmentInfo environment = + EnvironmentInfo.newBuilder() + .addRuntimes( + EnvironmentInfo.Runtime.newBuilder() + .setType(EnvironmentInfo.Runtime.RuntimeType.RUNTIME_TYPE_JVM) + .setVersion("17")) + .build(); + // Fail the first delivery so the environment must be retried. + when(blockingStub.recordWorkerHeartbeat(any())) + .thenThrow(new io.grpc.StatusRuntimeException(io.grpc.Status.UNAVAILABLE)) + .thenReturn(RecordWorkerHeartbeatResponse.getDefaultInstance()); + + manager = new HeartbeatManager(service, "test-identity", FAST_INTERVAL); + + // Mirrors Worker.buildHeartbeatCallback: the environment is embedded by the supplier until the + // accepted callback clears it. + AtomicReference pending = new AtomicReference<>(environment); + manager.registerWorker( + "default", + "worker-1", + () -> { + WorkerHeartbeat.Builder hb = + WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1"); + EnvironmentInfo env = pending.get(); + if (env != null) { + hb.setEnvironment(env); + } + return hb.build(); + }, + () -> pending.set(null)); + + verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeast(3)).recordWorkerHeartbeat(any()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RecordWorkerHeartbeatRequest.class); + verify(blockingStub, atLeast(3)).recordWorkerHeartbeat(captor.capture()); + + List requests = captor.getAllValues(); + assertEquals(environment, requests.get(0).getWorkerHeartbeat(0).getEnvironment()); + assertEquals(environment, requests.get(1).getWorkerHeartbeat(0).getEnvironment()); + for (RecordWorkerHeartbeatRequest request : requests.subList(2, requests.size())) { + assertFalse(request.getWorkerHeartbeat(0).hasEnvironment()); + } + } + + @Test + public void testAcceptedCallbackNotInvokedOnFailure() throws Exception { + when(blockingStub.recordWorkerHeartbeat(any())) + .thenThrow(new io.grpc.StatusRuntimeException(io.grpc.Status.UNAVAILABLE)) + .thenThrow(new RuntimeException("boom")) + .thenReturn(RecordWorkerHeartbeatResponse.getDefaultInstance()); + manager = new HeartbeatManager(service, "test-identity", FAST_INTERVAL); + + WorkerHeartbeat hb = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1").build(); + Runnable accepted = mock(Runnable.class); + manager.registerWorker("default", "worker-1", () -> hb, accepted); + + verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeast(3)).recordWorkerHeartbeat(any()); + verify(accepted, timeout(VERIFY_TIMEOUT_MS).atLeastOnce()).run(); + // Two failed RPCs preceded the first success, so there must be fewer acceptances than RPCs. + assertTrue( + mockingDetails(accepted).getInvocations().size() + <= mockingDetails(blockingStub).getInvocations().size() - 2); + } + + @Test + public void testEnvironmentInfoOmittedWhenNull() throws Exception { + manager = new HeartbeatManager(service, "test-identity", FAST_INTERVAL); + + WorkerHeartbeat hb = WorkerHeartbeat.newBuilder().setWorkerInstanceKey("worker-1").build(); + manager.registerWorker("default", "worker-1", () -> hb, () -> {}); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(RecordWorkerHeartbeatRequest.class); + verify(blockingStub, timeout(VERIFY_TIMEOUT_MS).atLeastOnce()) + .recordWorkerHeartbeat(captor.capture()); + assertFalse(captor.getValue().getWorkerHeartbeat(0).hasEnvironment()); + } + @Test public void testIntervalValidation() { HeartbeatManager hm = new HeartbeatManager(service, "test-identity", Duration.ofSeconds(30)); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java new file mode 100644 index 0000000000..1c84aa37b0 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkerEnvironmentInfoTest.java @@ -0,0 +1,113 @@ +package io.temporal.internal.worker; + +import static org.junit.Assert.*; + +import io.temporal.api.worker.v1.EnvironmentInfo; +import io.temporal.api.worker.v1.EnvironmentInfo.Architecture; +import io.temporal.api.worker.v1.EnvironmentInfo.HostingEnvironment; +import io.temporal.api.worker.v1.EnvironmentInfo.HostingEnvironment.HostingEnvironmentType; +import io.temporal.api.worker.v1.EnvironmentInfo.Runtime.RuntimeType; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.Test; + +public class WorkerEnvironmentInfoTest { + + @Test + public void detectReportsJvmRuntimeAndPlatform() { + EnvironmentInfo info = WorkerEnvironmentInfo.detect(); + + assertEquals(1, info.getRuntimesCount()); + assertEquals(RuntimeType.RUNTIME_TYPE_JVM, info.getRuntimes(0).getType()); + assertEquals(System.getProperty("java.version"), info.getRuntimes(0).getVersion()); + + Architecture expectedArchitecture = WorkerEnvironmentInfo.detectArchitecture(); + assertTrue(info.hasPlatform()); + switch (info.getPlatform().getVariantCase()) { + case LINUX: + assertEquals(expectedArchitecture, info.getPlatform().getLinux().getArchitecture()); + assertFalse(info.getPlatform().getLinux().getVersion().isEmpty()); + break; + case MACOS: + assertEquals(expectedArchitecture, info.getPlatform().getMacos().getArchitecture()); + break; + case WINDOWS: + assertEquals(expectedArchitecture, info.getPlatform().getWindows().getArchitecture()); + break; + default: + fail("unexpected platform variant " + info.getPlatform().getVariantCase()); + } + } + + @Test + public void detectHostingEnvironments() { + Map env = new HashMap<>(); + env.put("KUBERNETES_SERVICE_HOST", "10.0.0.1"); + env.put("ECS_CONTAINER_METADATA_URI", "http://169.254.170.2/v3"); + env.put("WEBSITE_SITE_NAME", "my-site"); + env.put("WEBSITE_PLATFORM_VERSION", " 1.2.3 "); + env.put("FUNCTIONS_EXTENSION_VERSION", "~4"); + env.put("GAE_SERVICE", " "); + + // Docker is detected from the host filesystem, so exclude it to keep the test host-independent. + List environments = + WorkerEnvironmentInfo.detectHostingEnvironments(env::get).stream() + .filter(e -> e.getType() != HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_DOCKER) + .collect(Collectors.toList()); + + assertEquals( + Arrays.asList( + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_K8S, + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AWS_ECS, + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AZURE_APP_SERVICE, + HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_AZURE_FUNCTIONS), + environments.stream().map(HostingEnvironment::getType).collect(Collectors.toList())); + assertEquals("1.2.3", environments.get(2).getVersion()); + assertEquals("~4", environments.get(3).getVersion()); + + assertTrue( + WorkerEnvironmentInfo.detectHostingEnvironments(name -> null).stream() + .noneMatch(e -> e.getType() != HostingEnvironmentType.HOSTING_ENVIRONMENT_TYPE_DOCKER)); + } + + @Test + public void cgroupsIndicateDocker() { + assertFalse(WorkerEnvironmentInfo.cgroupsIndicateDocker(Collections.singletonList("0::/"))); + assertTrue( + WorkerEnvironmentInfo.cgroupsIndicateDocker( + Arrays.asList("12:pids:/docker/abc123", "0::/"))); + assertTrue( + WorkerEnvironmentInfo.cgroupsIndicateDocker( + Collections.singletonList("0::/system.slice/docker-abc123.scope"))); + assertFalse( + WorkerEnvironmentInfo.cgroupsIndicateDocker( + Collections.singletonList("0::/system.slice/docker-abc123.service"))); + assertFalse( + WorkerEnvironmentInfo.cgroupsIndicateDocker( + Collections.singletonList("0::/kubepods/besteffort/pod123/dockerish"))); + } + + @Test + public void windowsVersion() { + assertEquals("11", WorkerEnvironmentInfo.windowsVersion("Windows 11", "10.0")); + assertEquals("10.0", WorkerEnvironmentInfo.windowsVersion("Windows 10", "10.0")); + assertEquals("8.1", WorkerEnvironmentInfo.windowsVersion("Windows 8.1", "6.3")); + assertEquals("10.0", WorkerEnvironmentInfo.windowsVersion("Windows Server 2022", "10.0")); + assertEquals("5.1", WorkerEnvironmentInfo.windowsVersion("Windows XP", "5.1")); + assertEquals("10.0", WorkerEnvironmentInfo.windowsVersion("Windows NT (unknown)", "10.0")); + assertEquals("6.2", WorkerEnvironmentInfo.windowsVersion("Windows", "6.2")); + } + + @Test + public void javaMajorVersion() { + assertEquals(8, WorkerEnvironmentInfo.javaMajorVersion("1.8")); + assertEquals(11, WorkerEnvironmentInfo.javaMajorVersion("11")); + assertEquals(21, WorkerEnvironmentInfo.javaMajorVersion("21.0.1")); + assertEquals(0, WorkerEnvironmentInfo.javaMajorVersion(null)); + assertEquals(0, WorkerEnvironmentInfo.javaMajorVersion("unknown")); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java index 415b1b3926..07af0f5bec 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/WorkerShutdownTest.java @@ -14,6 +14,7 @@ import io.temporal.activity.ActivityMethod; import io.temporal.api.enums.v1.TaskQueueType; import io.temporal.api.enums.v1.WorkerStatus; +import io.temporal.api.worker.v1.EnvironmentInfo; import io.temporal.api.worker.v1.WorkerHeartbeat; import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; import io.temporal.api.workflowservice.v1.ShutdownWorkerRequest; @@ -117,51 +118,9 @@ public OperationHandler operation() { */ @Test public void activeTaskQueueTypesEvaluatedAtShutdownTime() throws Exception { - WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); - when(service.getServerCapabilities()) - .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); - WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); - when(service.futureStub()).thenReturn(futureStub); - when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) - .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); - - WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = - mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); - when(service.blockingStub()).thenReturn(blockingStub); - when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); - - WorkflowClient client = mock(WorkflowClient.class); - when(client.getInternal()).thenReturn(mock(WorkflowClientInternal.class)); - when(client.getWorkflowServiceStubs()).thenReturn(service); - when(client.getOptions()) - .thenReturn( - WorkflowClientOptions.newBuilder() - .setNamespace("test-ns") - .setIdentity("test-worker") - .validateAndBuildWithDefaults()); - - Scope metricsScope = new NoopScope(); - WorkflowRunLockManager runLocks = new WorkflowRunLockManager(); - WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLocks, metricsScope); - WorkflowThreadExecutor wfThreadExecutor = mock(WorkflowThreadExecutor.class); - - Worker worker = - new Worker( - client, - "test-task-queue", - WorkerFactoryOptions.newBuilder().build(), - WorkerOptions.newBuilder().build(), - metricsScope, - runLocks, - cache, - true, - wfThreadExecutor, - Collections.emptyList(), - Collections.emptyList(), - "test-worker-group", - new NamespaceCapabilities()); + Worker worker = newWorker(futureStub); // Register types AFTER worker construction. The request built by shutdown should reflect // these registrations, proving that getActiveTaskQueueTypes() is evaluated lazily. @@ -195,4 +154,85 @@ public void activeTaskQueueTypesEvaluatedAtShutdownTime() throws Exception { "ShutdownWorkerRequest sticky task queue should be derived from worker identity", captor.getValue().getStickyTaskQueue().startsWith("test-worker:")); } + + /** + * The environment is reported in every heartbeat, including the one embedded in the shutdown + * request, until the heartbeat manager reports one as accepted by the server. + */ + @Test + public void environmentInfoReportedUntilAccepted() throws Exception { + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + Worker worker = newWorker(futureStub); + worker.registerWorkflowImplementationTypes(TestWorkflowImpl.class); + EnvironmentInfo environment = + EnvironmentInfo.newBuilder() + .addRuntimes( + EnvironmentInfo.Runtime.newBuilder() + .setType(EnvironmentInfo.Runtime.RuntimeType.RUNTIME_TYPE_JVM) + .setVersion("17")) + .build(); + + Supplier heartbeatSupplier = + worker.buildHeartbeatCallback("test-worker-group", environment); + worker.setHeartbeatSupplier(heartbeatSupplier); + worker.start(); + + assertEquals(environment, heartbeatSupplier.get().getEnvironment()); + assertEquals(environment, heartbeatSupplier.get().getEnvironment()); + + worker.shutdown(new ShutdownManager(), true).get(5, TimeUnit.SECONDS); + ArgumentCaptor captor = + ArgumentCaptor.forClass(ShutdownWorkerRequest.class); + verify(futureStub).shutdownWorker(captor.capture()); + assertEquals(environment, captor.getValue().getWorkerHeartbeat().getEnvironment()); + + worker.onHeartbeatAccepted(); + assertFalse(heartbeatSupplier.get().hasEnvironment()); + } + + private static Worker newWorker(WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub) { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + + when(service.futureStub()).thenReturn(futureStub); + when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) + .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); + + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(service.blockingStub()).thenReturn(blockingStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + + WorkflowClient client = mock(WorkflowClient.class); + when(client.getInternal()).thenReturn(mock(WorkflowClientInternal.class)); + when(client.getWorkflowServiceStubs()).thenReturn(service); + when(client.getOptions()) + .thenReturn( + WorkflowClientOptions.newBuilder() + .setNamespace("test-ns") + .setIdentity("test-worker") + .validateAndBuildWithDefaults()); + + Scope metricsScope = new NoopScope(); + WorkflowRunLockManager runLocks = new WorkflowRunLockManager(); + WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLocks, metricsScope); + WorkflowThreadExecutor wfThreadExecutor = mock(WorkflowThreadExecutor.class); + + return new Worker( + client, + "test-task-queue", + WorkerFactoryOptions.newBuilder().build(), + WorkerOptions.newBuilder().build(), + metricsScope, + runLocks, + cache, + true, + wfThreadExecutor, + Collections.emptyList(), + Collections.emptyList(), + "test-worker-group", + new NamespaceCapabilities()); + } } From a49045eb826a76d781789157369f338aa76fda86 Mon Sep 17 00:00:00 2001 From: Gregory Michael Travis Date: Wed, 9 Sep 2026 11:09:01 -0400 Subject: [PATCH 090/107] Implement operator commands for Standalone Activities (#3013) Adds pause, unpause, reset, and update-options to standalone activities, plus the describe surface needed to observe their effects. --- .../StandaloneActivityClientTracingTest.java | 4 +- .../client/ActivityExecutionDescription.java | 253 ++++-- .../client/ActivityExecutionOptions.java | 148 ++++ .../temporal/client/ActivityHandleImpl.java | 35 + .../client/ActivityOptionsUpdate.java | 169 ++++ .../client/DescribeActivityOptions.java | 145 ++++ .../temporal/client/PauseActivityOptions.java | 86 ++ .../client/UnpauseActivityOptions.java | 102 +++ .../client/UntypedActivityHandle.java | 55 +- .../ActivityClientCallsInterceptor.java | 153 +++- .../ActivityClientCallsInterceptorBase.java | 17 + .../internal/client/ActivityHandleImpl.java | 67 +- .../client/RootActivityClientInvoker.java | 84 +- .../external/GenericWorkflowClient.java | 10 + .../external/GenericWorkflowClientImpl.java | 34 + .../ActivityExecutionDescriptionTest.java | 137 +++- ...tandaloneActivityOperatorCommandsTest.java | 747 ++++++++++++++++++ .../functional/StandaloneActivityTest.java | 24 +- ...ctivityClientCallsInterceptorBaseTest.java | 4 +- .../ActivityHandleOperatorCommandsTest.java | 205 +++++ 20 files changed, 2354 insertions(+), 125 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsUpdate.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java diff --git a/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java index c852175196..74919024d5 100644 --- a/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java +++ b/contrib/temporal-opentracing/src/test/java/io/temporal/opentracing/StandaloneActivityClientTracingTest.java @@ -7,6 +7,7 @@ import io.opentracing.util.ThreadLocalScopeManager; import io.temporal.api.workflowservice.v1.CountActivityExecutionsResponse; import io.temporal.client.ActivityExecutionCount; +import io.temporal.client.DescribeActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; @@ -108,7 +109,8 @@ public void testManagementCallsDoNotCreateSpans() throws TimeoutException { new ActivityClientCallsInterceptor.GetActivityResultInput<>( "act-result-async", null, String.class)); interceptor.describeActivity( - new ActivityClientCallsInterceptor.DescribeActivityInput("act-desc", null)); + new ActivityClientCallsInterceptor.DescribeActivityInput( + "act-desc", null, DescribeActivityOptions.getDefaultInstance())); interceptor.cancelActivity( new ActivityClientCallsInterceptor.CancelActivityInput("act-cancel", null, "reason")); interceptor.terminateActivity( diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java index 13df137a3c..485f6ce7b2 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -3,11 +3,13 @@ import io.temporal.api.activity.v1.ActivityExecutionInfo; import io.temporal.api.enums.v1.ActivityExecutionStatus; import io.temporal.api.enums.v1.PendingActivityState; +import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.common.Experimental; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; import io.temporal.common.WorkerDeploymentVersion; import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.EncodedValues; import io.temporal.internal.common.ProtoConverters; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.RetryOptionsUtils; @@ -27,45 +29,61 @@ @Experimental public final class ActivityExecutionDescription extends ActivityExecutionMetadata { - private final ActivityExecutionInfo info; + private final DescribeActivityExecutionResponse response; private final DataConverter dataConverter; - private final String namespace; public ActivityExecutionDescription( - ActivityExecutionInfo info, DataConverter dataConverter, String namespace) { + DescribeActivityExecutionResponse response, DataConverter dataConverter, String namespace) { super( null, - info.getActivityId(), - nullIfEmpty(info.getRunId()), - info.getActivityType().getName(), - info.hasCloseTime() ? ProtobufTimeUtils.toJavaInstant(info.getCloseTime()) : null, - info.hasExecutionDuration() - ? ProtobufTimeUtils.toJavaDuration(info.getExecutionDuration()) + response.getInfo().getActivityId(), + nullIfEmpty(response.getInfo().getRunId()), + response.getInfo().getActivityType().getName(), + response.getInfo().hasCloseTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getCloseTime()) : null, - info.hasScheduleTime() - ? ProtobufTimeUtils.toJavaInstant(info.getScheduleTime()) + response.getInfo().hasExecutionDuration() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getExecutionDuration()) + : null, + response.getInfo().hasScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getScheduleTime()) : Instant.EPOCH, - info.getStatus(), - info.getTaskQueue(), - SearchAttributesUtil.decodeTyped(info.getSearchAttributes())); - this.info = info; - this.dataConverter = dataConverter; - this.namespace = namespace; + response.getInfo().getStatus(), + response.getInfo().getTaskQueue(), + SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes())); + this.response = response; + this.dataConverter = + dataConverter.withContext( + new ActivitySerializationContext( + namespace, null, null, getActivityType(), getTaskQueue(), false)); } private static @Nullable String nullIfEmpty(String s) { return s == null || s.isEmpty() ? null : s; } + /** Underlying proto response. Exposed while the standalone activity surface is experimental. */ + @Nonnull + public DescribeActivityExecutionResponse getRawResponse() { + return response; + } + /** The raw protobuf info returned by the server for this activity execution. */ @Nonnull public ActivityExecutionInfo getRawInfo() { - return info; + return response.getInfo(); } /** Current attempt number (starts at 1). */ public int getAttempt() { - return info.getAttempt(); + return response.getInfo().getAttempt(); + } + + /** + * @return total number of heartbeats recorded across all attempts. + */ + public long getTotalHeartbeatCount() { + return response.getInfo().getTotalHeartbeatCount(); } /** @@ -74,83 +92,118 @@ public int getAttempt() { */ @Nullable public String getCanceledReason() { - String r = info.getCanceledReason(); + String r = response.getInfo().getCanceledReason(); return r.isEmpty() ? null : r; } /** Current or next retry interval. {@code null} if no retries are configured or allowed. */ @Nullable public Duration getCurrentRetryInterval() { - return info.hasCurrentRetryInterval() - ? ProtobufTimeUtils.toJavaDuration(info.getCurrentRetryInterval()) + return response.getInfo().hasCurrentRetryInterval() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getCurrentRetryInterval()) : null; } /** When the activity will time out (scheduled time + scheduleToCloseTimeout). */ @Nullable public Instant getExpirationTime() { - return info.hasExpirationTime() - ? ProtobufTimeUtils.toJavaInstant(info.getExpirationTime()) + return response.getInfo().hasExpirationTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getExpirationTime()) : null; } /** Maximum allowed time between heartbeats. */ @Nullable public Duration getHeartbeatTimeout() { - return info.hasHeartbeatTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getHeartbeatTimeout()) + return response.getInfo().hasHeartbeatTimeout() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getHeartbeatTimeout()) : null; } /** Time the last attempt completed (succeeded or failed). */ @Nullable public Instant getLastAttemptCompleteTime() { - return info.hasLastAttemptCompleteTime() - ? ProtobufTimeUtils.toJavaInstant(info.getLastAttemptCompleteTime()) + return response.getInfo().hasLastAttemptCompleteTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getLastAttemptCompleteTime()) : null; } /** Time the last heartbeat was recorded. */ @Nullable public Instant getLastHeartbeatTime() { - return info.hasLastHeartbeatTime() - ? ProtobufTimeUtils.toJavaInstant(info.getLastHeartbeatTime()) + return response.getInfo().hasLastHeartbeatTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getLastHeartbeatTime()) : null; } /** Time the last attempt was started. */ @Nullable public Instant getLastStartedTime() { - return info.hasLastStartedTime() - ? ProtobufTimeUtils.toJavaInstant(info.getLastStartedTime()) + return response.getInfo().hasLastStartedTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getLastStartedTime()) + : null; + } + + /** + * Time the first activity task was made available for dispatch. Computed as {@code schedule_time + * + start_delay}; equals {@code schedule_time} when no start delay is set. + */ + @Nullable + public Instant getExecutionTime() { + return response.getInfo().hasExecutionTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getExecutionTime()) + : null; + } + + /** + * Delay before the first activity task is made available for dispatch. Not applied to retry + * attempts. {@code null} if no start delay is set. + */ + @Nullable + public Duration getStartDelay() { + return response.getInfo().hasStartDelay() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getStartDelay()) : null; } + /** + * Whether a failure from a failed attempt is present. {@code false} when the activity has no + * failed attempt, and also when the description was requested without {@link + * DescribeActivityOptions.Builder#setIncludeLastFailure(boolean)}. + */ + public boolean hasLastFailure() { + return response.getInfo().hasLastFailure(); + } + /** Failure details from the last failed attempt. {@code null} if no failure has occurred. */ @Nullable - public Exception getLastFailure() { - return info.hasLastFailure() ? dataConverter.failureToException(info.getLastFailure()) : null; + public RuntimeException getLastFailure() { + return response.getInfo().hasLastFailure() + ? dataConverter.failureToException(response.getInfo().getLastFailure()) + : null; } /** Identity of the worker that last processed this activity. */ @Nullable public String getLastWorkerIdentity() { - String w = info.getLastWorkerIdentity(); + String w = response.getInfo().getLastWorkerIdentity(); return w.isEmpty() ? null : w; } /** Time when the next retry attempt will be scheduled. */ @Nullable public Instant getNextAttemptScheduleTime() { - return info.hasNextAttemptScheduleTime() - ? ProtobufTimeUtils.toJavaInstant(info.getNextAttemptScheduleTime()) + return response.getInfo().hasNextAttemptScheduleTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getNextAttemptScheduleTime()) : null; } /** Retry policy for this activity. */ @Nullable public RetryOptions getRetryOptions() { - return info.hasRetryPolicy() ? RetryOptionsUtils.toRetryOptions(info.getRetryPolicy()) : null; + return response.getInfo().hasRetryPolicy() + ? RetryOptionsUtils.toRetryOptions(response.getInfo().getRetryPolicy()) + : null; } /** @@ -159,62 +212,119 @@ public RetryOptions getRetryOptions() { */ @Nonnull public PendingActivityState getRunState() { - return info.getRunState(); + return response.getInfo().getRunState(); } /** Total time the caller is willing to wait for the activity to complete, including retries. */ @Nullable public Duration getScheduleToCloseTimeout() { - return info.hasScheduleToCloseTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToCloseTimeout()) + return response.getInfo().hasScheduleToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getScheduleToCloseTimeout()) : null; } /** Maximum time the task may wait in the task queue. */ @Nullable public Duration getScheduleToStartTimeout() { - return info.hasScheduleToStartTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getScheduleToStartTimeout()) + return response.getInfo().hasScheduleToStartTimeout() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getScheduleToStartTimeout()) : null; } /** Maximum time for a single attempt. */ @Nullable public Duration getStartToCloseTimeout() { - return info.hasStartToCloseTimeout() - ? ProtobufTimeUtils.toJavaDuration(info.getStartToCloseTimeout()) + return response.getInfo().hasStartToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getStartToCloseTimeout()) : null; } - /** Whether heartbeat details were recorded for the last attempt. */ + /** + * Whether heartbeat details were recorded for the last attempt. {@code false} when the activity + * recorded none, and also when the description was requested without {@link + * DescribeActivityOptions.Builder#setIncludeHeartbeatDetails(boolean)}. + */ public boolean hasHeartbeatDetails() { - return info.hasHeartbeatDetails(); + return response.getInfo().hasHeartbeatDetails(); + } + + /** + * The details recorded by the last heartbeat, as lazily-decoded values. Empty (size 0) when no + * heartbeat details are present, either because none were recorded or because the description was + * requested without {@link DescribeActivityOptions.Builder#setIncludeHeartbeatDetails(boolean)}. + */ + public EncodedValues getHeartbeatDetails() { + return new EncodedValues(Optional.of(response.getInfo().getHeartbeatDetails()), dataConverter); + } + + /** + * Whether the activity's input is present. {@code false} unless the description was requested + * with {@link DescribeActivityOptions.Builder#setIncludeInput(boolean)}. + */ + public boolean hasInput() { + return response.hasInput(); } /** - * Deserializes the last heartbeat details into the given type. Returns {@link Optional#empty()} - * if no heartbeat details are present. + * The activity's input arguments, as lazily-decoded values, one per argument. Empty (size 0) when + * no input is present, either because the activity took no arguments or because the description + * was requested without {@link DescribeActivityOptions.Builder#setIncludeInput(boolean)}. + */ + public EncodedValues getInput() { + return new EncodedValues(Optional.of(response.getInput()), dataConverter); + } + + /** + * Whether the activity closed with a successful result. {@code false} while the activity is still + * running, when it closed with a failure, or when the description was requested without {@link + * DescribeActivityOptions.Builder#setIncludeOutcome(boolean)}. + */ + public boolean hasResult() { + return response.getOutcome().hasResult(); + } + + /** + * Deserializes the activity's success result. Returns {@link Optional#empty()} if no result is + * present (activity still running, closed with a failure, or {@code includeOutcome} was false). * - * @param valueType the class to deserialize the heartbeat details into + * @param valueType the class to deserialize the result into */ - public Optional getHeartbeatDetails(Class valueType) { - return getHeartbeatDetails(valueType, valueType); + public Optional getResult(Class valueType) { + return getResult(valueType, null); } /** - * Deserializes the last heartbeat details into the given generic type. Returns {@link - * Optional#empty()} if no heartbeat details are present. + * Deserializes the activity's success result into the given generic type. Returns {@link + * Optional#empty()} if no result is present. * - * @param valueType the class to deserialize the heartbeat details into + * @param valueType the class to deserialize the result into * @param genericType the generic type for deserialization; may equal {@code valueType} */ - public Optional getHeartbeatDetails(Class valueType, Type genericType) { - if (!info.hasHeartbeatDetails()) { + public Optional getResult(Class valueType, @Nullable Type genericType) { + if (!hasResult()) { return Optional.empty(); } return Optional.ofNullable( dataConverter.fromPayloads( - 0, Optional.of(info.getHeartbeatDetails()), valueType, genericType)); + 0, + Optional.of(response.getOutcome().getResult()), + valueType, + genericType != null ? genericType : valueType)); + } + + /** + * The failure the activity closed with, as an exception. {@code null} if the activity did not + * close with a failure or if {@code includeOutcome} was false on the describe call. + * + *

This is the terminal outcome; {@link #getLastFailure()} is the failure of the most recent + * attempt, which may be set while the activity is still retrying. + */ + @Nullable + public RuntimeException getOutcomeFailure() { + if (!response.getOutcome().hasFailure()) { + return null; + } + return dataConverter.failureToException(response.getOutcome().getFailure()); } /** @@ -223,20 +333,21 @@ public Optional getHeartbeatDetails(Class valueType, Type genericType) */ @Nullable public WorkerDeploymentVersion getWorkerDeploymentVersion() { - if (!info.hasLastDeploymentVersion()) { + if (!response.getInfo().hasLastDeploymentVersion()) { return null; } - io.temporal.api.deployment.v1.WorkerDeploymentVersion proto = info.getLastDeploymentVersion(); + io.temporal.api.deployment.v1.WorkerDeploymentVersion proto = + response.getInfo().getLastDeploymentVersion(); return new WorkerDeploymentVersion(proto.getDeploymentName(), proto.getBuildId()); } /** Priority hint for this activity. {@code null} if not set. */ @Nullable public Priority getPriority() { - if (!info.hasPriority()) { + if (!response.getInfo().hasPriority()) { return null; } - return ProtoConverters.fromProto(info.getPriority()); + return ProtoConverters.fromProto(response.getInfo().getPriority()); } /** @@ -245,14 +356,11 @@ public Priority getPriority() { */ @Nullable public String getStaticSummary() { - if (!info.hasUserMetadata() || !info.getUserMetadata().hasSummary()) { + if (!response.getInfo().getUserMetadata().hasSummary()) { return null; } - return dataConverter - .withContext( - new ActivitySerializationContext( - namespace, null, null, getActivityType(), getTaskQueue(), false)) - .fromPayload(info.getUserMetadata().getSummary(), String.class, String.class); + return dataConverter.fromPayload( + response.getInfo().getUserMetadata().getSummary(), String.class, String.class); } /** @@ -261,13 +369,10 @@ namespace, null, null, getActivityType(), getTaskQueue(), false)) */ @Nullable public String getStaticDetails() { - if (!info.hasUserMetadata() || !info.getUserMetadata().hasDetails()) { + if (!response.getInfo().getUserMetadata().hasDetails()) { return null; } - return dataConverter - .withContext( - new ActivitySerializationContext( - namespace, null, null, getActivityType(), getTaskQueue(), false)) - .fromPayload(info.getUserMetadata().getDetails(), String.class, String.class); + return dataConverter.fromPayload( + response.getInfo().getUserMetadata().getDetails(), String.class, String.class); } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java new file mode 100644 index 0000000000..4c08800d9a --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java @@ -0,0 +1,148 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import io.temporal.internal.common.ProtoConverters; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.RetryOptionsUtils; +import java.time.Duration; +import java.util.Objects; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * The options an activity is running with, as resolved by the server. Returned by {@link + * UntypedActivityHandle#updateOptions} and {@link UntypedActivityHandle#restoreOriginalOptions}. + */ +@Experimental +public final class ActivityExecutionOptions { + + private final @Nullable String taskQueue; + private final @Nullable Duration scheduleToCloseTimeout; + private final @Nullable Duration scheduleToStartTimeout; + private final @Nullable Duration startToCloseTimeout; + private final @Nullable Duration heartbeatTimeout; + private final @Nullable RetryOptions retryOptions; + private final @Nullable Priority priority; + private final @Nullable Duration startDelay; + + /** + * Converts the server's resolved activity options into this type. An option the server did not + * report is left null. + */ + public ActivityExecutionOptions(@Nonnull io.temporal.api.activity.v1.ActivityOptions proto) { + this.taskQueue = proto.hasTaskQueue() ? proto.getTaskQueue().getName() : null; + this.scheduleToCloseTimeout = + proto.hasScheduleToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getScheduleToCloseTimeout()) + : null; + this.scheduleToStartTimeout = + proto.hasScheduleToStartTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getScheduleToStartTimeout()) + : null; + this.startToCloseTimeout = + proto.hasStartToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getStartToCloseTimeout()) + : null; + this.heartbeatTimeout = + proto.hasHeartbeatTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getHeartbeatTimeout()) + : null; + this.retryOptions = + proto.hasRetryPolicy() ? RetryOptionsUtils.toRetryOptions(proto.getRetryPolicy()) : null; + this.priority = proto.hasPriority() ? ProtoConverters.fromProto(proto.getPriority()) : null; + this.startDelay = + proto.hasStartDelay() ? ProtobufTimeUtils.toJavaDuration(proto.getStartDelay()) : null; + } + + @Nullable + public String getTaskQueue() { + return taskQueue; + } + + @Nullable + public Duration getScheduleToCloseTimeout() { + return scheduleToCloseTimeout; + } + + @Nullable + public Duration getScheduleToStartTimeout() { + return scheduleToStartTimeout; + } + + @Nullable + public Duration getStartToCloseTimeout() { + return startToCloseTimeout; + } + + @Nullable + public Duration getHeartbeatTimeout() { + return heartbeatTimeout; + } + + @Nullable + public RetryOptions getRetryOptions() { + return retryOptions; + } + + @Nullable + public Priority getPriority() { + return priority; + } + + @Nullable + public Duration getStartDelay() { + return startDelay; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ActivityExecutionOptions that = (ActivityExecutionOptions) o; + return Objects.equals(taskQueue, that.taskQueue) + && Objects.equals(scheduleToCloseTimeout, that.scheduleToCloseTimeout) + && Objects.equals(scheduleToStartTimeout, that.scheduleToStartTimeout) + && Objects.equals(startToCloseTimeout, that.startToCloseTimeout) + && Objects.equals(heartbeatTimeout, that.heartbeatTimeout) + && Objects.equals(retryOptions, that.retryOptions) + && Objects.equals(priority, that.priority) + && Objects.equals(startDelay, that.startDelay); + } + + @Override + public int hashCode() { + return Objects.hash( + taskQueue, + scheduleToCloseTimeout, + scheduleToStartTimeout, + startToCloseTimeout, + heartbeatTimeout, + retryOptions, + priority, + startDelay); + } + + @Override + public String toString() { + return "ActivityExecutionOptions{" + + "taskQueue='" + + taskQueue + + "', scheduleToCloseTimeout=" + + scheduleToCloseTimeout + + ", scheduleToStartTimeout=" + + scheduleToStartTimeout + + ", startToCloseTimeout=" + + startToCloseTimeout + + ", heartbeatTimeout=" + + heartbeatTimeout + + ", retryOptions=" + + retryOptions + + ", priority=" + + priority + + ", startDelay=" + + startDelay + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java index 3144195d11..5bc7ef7b8f 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java @@ -102,6 +102,11 @@ public ActivityExecutionDescription describe() { return delegate.describe(); } + @Override + public ActivityExecutionDescription describe(DescribeActivityOptions options) { + return delegate.describe(options); + } + @Override public void cancel() { delegate.cancel(); @@ -121,4 +126,34 @@ public void terminate() { public void terminate(@Nullable String reason) { delegate.terminate(reason); } + + @Override + public void pause() { + delegate.pause(); + } + + @Override + public void pause(PauseActivityOptions options) { + delegate.pause(options); + } + + @Override + public void unpause() { + delegate.unpause(); + } + + @Override + public void unpause(UnpauseActivityOptions options) { + delegate.unpause(options); + } + + @Override + public ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates) { + return delegate.updateOptions(updates); + } + + @Override + public ActivityExecutionOptions restoreOriginalOptions() { + return delegate.restoreOriginalOptions(); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsUpdate.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsUpdate.java new file mode 100644 index 0000000000..f27f97988e --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsUpdate.java @@ -0,0 +1,169 @@ +package io.temporal.client; + +import static io.temporal.internal.common.RetryOptionsUtils.toRetryPolicy; + +import io.temporal.api.activity.v1.ActivityOptions; +import io.temporal.api.taskqueue.v1.TaskQueue; +import io.temporal.common.Experimental; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import io.temporal.internal.common.ProtoConverters; +import io.temporal.internal.common.ProtobufTimeUtils; +import java.time.Duration; +import java.util.Optional; +import java.util.function.BiConsumer; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A single change to an activity's options, created from one of the keys on this class via {@link + * ActivityOptionsKey#set} or {@link ActivityOptionsKey#unset}. + * + *

An option with no update in the call is left untouched. + * + * @param type of the option's value + */ +@Experimental +public final class ActivityOptionsUpdate { + + public static final ActivityOptionsKey TASK_QUEUE = + new ActivityOptionsKey<>( + "task_queue.name", + String.class, + (options, value) -> options.setTaskQueue(TaskQueue.newBuilder().setName(value).build())); + + public static final ActivityOptionsKey SCHEDULE_TO_CLOSE_TIMEOUT = + new ActivityOptionsKey<>( + "schedule_to_close_timeout", + Duration.class, + (options, value) -> + options.setScheduleToCloseTimeout(ProtobufTimeUtils.toProtoDuration(value))); + + public static final ActivityOptionsKey SCHEDULE_TO_START_TIMEOUT = + new ActivityOptionsKey<>( + "schedule_to_start_timeout", + Duration.class, + (options, value) -> + options.setScheduleToStartTimeout(ProtobufTimeUtils.toProtoDuration(value))); + + public static final ActivityOptionsKey START_TO_CLOSE_TIMEOUT = + new ActivityOptionsKey<>( + "start_to_close_timeout", + Duration.class, + (options, value) -> + options.setStartToCloseTimeout(ProtobufTimeUtils.toProtoDuration(value))); + + public static final ActivityOptionsKey HEARTBEAT_TIMEOUT = + new ActivityOptionsKey<>( + "heartbeat_timeout", + Duration.class, + (options, value) -> + options.setHeartbeatTimeout(ProtobufTimeUtils.toProtoDuration(value))); + + public static final ActivityOptionsKey START_DELAY = + new ActivityOptionsKey<>( + "start_delay", + Duration.class, + (options, value) -> options.setStartDelay(ProtobufTimeUtils.toProtoDuration(value))); + + public static final ActivityOptionsKey RETRY_OPTIONS = + new ActivityOptionsKey<>( + "retry_policy", + RetryOptions.class, + (options, value) -> options.setRetryPolicy(toRetryPolicy(value))); + + public static final ActivityOptionsKey PRIORITY = + new ActivityOptionsKey<>( + "priority", + Priority.class, + (options, value) -> options.setPriority(ProtoConverters.toProto(value))); + + /** + * Typed key for one updatable activity option. Each key knows both its field-mask path and how to + * write its value onto the request. + * + *

Use the keys on {@link ActivityOptionsUpdate} rather than constructing these directly. + * + * @param type of the option's value + */ + @Experimental + public static final class ActivityOptionsKey { + + private final String path; + private final Class valueType; + private final BiConsumer setter; + + ActivityOptionsKey( + String path, Class valueType, BiConsumer setter) { + this.path = path; + this.valueType = valueType; + this.setter = setter; + } + + /** Field-mask path this key updates. */ + public String getPath() { + return path; + } + + /** Type of this key's value. */ + public Class getValueType() { + return valueType; + } + + /** Create an update that sets this option to the given value. */ + public ActivityOptionsUpdate set(@Nonnull T value) { + if (value == null) { + throw new IllegalArgumentException("Value cannot be null, use unset"); + } + return new ActivityOptionsUpdate<>(this, value); + } + + /** Create an update that clears this option server-side. */ + public ActivityOptionsUpdate unset() { + return new ActivityOptionsUpdate<>(this, null); + } + + /** Writes this option's value onto the request. */ + void apply(ActivityOptions.Builder options, T value) { + setter.accept(options, value); + } + + @Override + public String toString() { + return "ActivityOptionsKey{path='" + path + "', valueType=" + valueType.getSimpleName() + '}'; + } + } + + private final ActivityOptionsKey key; + private final @Nullable T value; + + private ActivityOptionsUpdate(ActivityOptionsKey key, @Nullable T value) { + this.key = key; + this.value = value; + } + + /** Get the key to set/unset. */ + public ActivityOptionsKey getKey() { + return key; + } + + /** Get the value to set, or empty for unset. */ + public Optional getValue() { + return Optional.ofNullable(value); + } + + /** + * Writes this update onto the request. An unset update writes nothing: it names its path in the + * field mask but leaves the field absent, which is how the server is told to clear the option. + */ + public void applyTo(ActivityOptions.Builder options) { + if (value != null) { + key.apply(options, value); + } + } + + @Override + public String toString() { + return "ActivityOptionsUpdate{key=" + key.getPath() + ", value=" + value + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java new file mode 100644 index 0000000000..13e3093361 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java @@ -0,0 +1,145 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.util.Objects; + +/** + * Options for {@link UntypedActivityHandle#describe(DescribeActivityOptions)}. + * + *

Each flag opts in to a field on the description that carries a payload. Payloads can be + * arbitrarily large, so none are returned unless explicitly requested. An instance with no fields + * set describes the activity without any of them. + */ +@Experimental +public final class DescribeActivityOptions { + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(DescribeActivityOptions options) { + return new Builder(options); + } + + public static DescribeActivityOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final DescribeActivityOptions DEFAULT_INSTANCE = + DescribeActivityOptions.newBuilder().build(); + + public static final class Builder { + private boolean includeInput; + private boolean includeOutcome; + private boolean includeHeartbeatDetails; + private boolean includeLastFailure; + + private Builder() {} + + private Builder(DescribeActivityOptions options) { + if (options == null) { + return; + } + this.includeInput = options.includeInput; + this.includeOutcome = options.includeOutcome; + this.includeHeartbeatDetails = options.includeHeartbeatDetails; + this.includeLastFailure = options.includeLastFailure; + } + + /** If set and the activity received input, the description includes the input. */ + public Builder setIncludeInput(boolean includeInput) { + this.includeInput = includeInput; + return this; + } + + /** If set and the activity is closed, the description includes the outcome. */ + public Builder setIncludeOutcome(boolean includeOutcome) { + this.includeOutcome = includeOutcome; + return this; + } + + /** + * If set and the activity recorded heartbeat details, the description includes the details of + * the last heartbeat. + */ + public Builder setIncludeHeartbeatDetails(boolean includeHeartbeatDetails) { + this.includeHeartbeatDetails = includeHeartbeatDetails; + return this; + } + + /** + * If set and the activity has a failed attempt, the description includes the failure of the + * last failed attempt. + */ + public Builder setIncludeLastFailure(boolean includeLastFailure) { + this.includeLastFailure = includeLastFailure; + return this; + } + + public DescribeActivityOptions build() { + return new DescribeActivityOptions(this); + } + } + + private final boolean includeInput; + private final boolean includeOutcome; + private final boolean includeHeartbeatDetails; + private final boolean includeLastFailure; + + private DescribeActivityOptions(Builder builder) { + this.includeInput = builder.includeInput; + this.includeOutcome = builder.includeOutcome; + this.includeHeartbeatDetails = builder.includeHeartbeatDetails; + this.includeLastFailure = builder.includeLastFailure; + } + + public Builder toBuilder() { + return new Builder(this); + } + + public boolean isIncludeInput() { + return includeInput; + } + + public boolean isIncludeOutcome() { + return includeOutcome; + } + + public boolean isIncludeHeartbeatDetails() { + return includeHeartbeatDetails; + } + + public boolean isIncludeLastFailure() { + return includeLastFailure; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DescribeActivityOptions that = (DescribeActivityOptions) o; + return includeInput == that.includeInput + && includeOutcome == that.includeOutcome + && includeHeartbeatDetails == that.includeHeartbeatDetails + && includeLastFailure == that.includeLastFailure; + } + + @Override + public int hashCode() { + return Objects.hash(includeInput, includeOutcome, includeHeartbeatDetails, includeLastFailure); + } + + @Override + public String toString() { + return "DescribeActivityOptions{" + + "includeInput=" + + includeInput + + ", includeOutcome=" + + includeOutcome + + ", includeHeartbeatDetails=" + + includeHeartbeatDetails + + ", includeLastFailure=" + + includeLastFailure + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java new file mode 100644 index 0000000000..e90c856236 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java @@ -0,0 +1,86 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Options for {@link UntypedActivityHandle#pause(PauseActivityOptions)}. + * + *

All fields are optional. An instance with no fields set pauses the activity with default + * behavior. + */ +@Experimental +public final class PauseActivityOptions { + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(PauseActivityOptions options) { + return new Builder(options); + } + + public static PauseActivityOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final PauseActivityOptions DEFAULT_INSTANCE = + PauseActivityOptions.newBuilder().build(); + + public static final class Builder { + private @Nullable String reason; + + private Builder() {} + + private Builder(PauseActivityOptions options) { + if (options == null) { + return; + } + this.reason = options.reason; + } + + /** Human-readable reason for pausing, recorded on the server. */ + public Builder setReason(@Nullable String reason) { + this.reason = reason; + return this; + } + + public PauseActivityOptions build() { + return new PauseActivityOptions(this); + } + } + + private final @Nullable String reason; + + private PauseActivityOptions(Builder builder) { + this.reason = builder.reason; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @Nullable + public String getReason() { + return reason; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PauseActivityOptions that = (PauseActivityOptions) o; + return Objects.equals(reason, that.reason); + } + + @Override + public int hashCode() { + return Objects.hash(reason); + } + + @Override + public String toString() { + return "PauseActivityOptions{" + "reason='" + reason + "'}"; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java new file mode 100644 index 0000000000..c60da6a698 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java @@ -0,0 +1,102 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.time.Duration; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Options for {@link UntypedActivityHandle#unpause(UnpauseActivityOptions)}. + * + *

All fields are optional. An instance with no fields set unpauses the activity with default + * behavior. + */ +@Experimental +public final class UnpauseActivityOptions { + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(UnpauseActivityOptions options) { + return new Builder(options); + } + + public static UnpauseActivityOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final UnpauseActivityOptions DEFAULT_INSTANCE = + UnpauseActivityOptions.newBuilder().build(); + + public static final class Builder { + private @Nullable String reason; + private @Nullable Duration jitter; + + private Builder() {} + + private Builder(UnpauseActivityOptions options) { + if (options == null) { + return; + } + this.reason = options.reason; + this.jitter = options.jitter; + } + + /** Human-readable reason for unpausing. */ + public Builder setReason(@Nullable String reason) { + this.reason = reason; + return this; + } + + /** If set, the activity will resume at a random time within the given jitter window. */ + public Builder setJitter(@Nullable Duration jitter) { + this.jitter = jitter; + return this; + } + + public UnpauseActivityOptions build() { + return new UnpauseActivityOptions(this); + } + } + + private final @Nullable String reason; + private final @Nullable Duration jitter; + + private UnpauseActivityOptions(Builder builder) { + this.reason = builder.reason; + this.jitter = builder.jitter; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @Nullable + public String getReason() { + return reason; + } + + @Nullable + public Duration getJitter() { + return jitter; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UnpauseActivityOptions that = (UnpauseActivityOptions) o; + return Objects.equals(reason, that.reason) && Objects.equals(jitter, that.jitter); + } + + @Override + public int hashCode() { + return Objects.hash(reason, jitter); + } + + @Override + public String toString() { + return "UnpauseActivityOptions{" + "reason='" + reason + "', jitter=" + jitter + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java index 5e6bb12864..c3ab2488b6 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -118,12 +118,22 @@ CompletableFuture getResultAsync( long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType); /** - * Describes the current state of the activity execution. + * Describes the current state of the activity execution, without any of the payload-bearing + * fields. Equivalent to {@code describe(DescribeActivityOptions.getDefaultInstance())}. * * @return detailed information about the activity */ ActivityExecutionDescription describe(); + /** + * Describes the current state of the activity execution. + * + * @param options which payload-bearing fields to include in the description. These are opt-in + * because they can be arbitrarily large. + * @return detailed information about the activity + */ + ActivityExecutionDescription describe(DescribeActivityOptions options); + /** * Requests cancellation of the activity. The activity will receive a cancellation via {@link * io.temporal.activity.ActivityExecutionContext#heartbeat(Object)}. @@ -146,4 +156,47 @@ CompletableFuture getResultAsync( * @param reason human-readable reason for termination, may be {@code null} */ void terminate(@Nullable String reason); + + /** + * Pauses the activity. A paused activity stops being dispatched to workers until it is unpaused. + */ + void pause(); + + /** + * Pauses the activity with the given options. + * + * @param options pause options (reason) + */ + void pause(PauseActivityOptions options); + + /** Unpauses the activity with default options, allowing it to be dispatched again. */ + void unpause(); + + /** + * Unpauses the activity with the given options. + * + * @param options unpause options (reason, jitter) + */ + void unpause(UnpauseActivityOptions options); + + /** + * Updates the activity's options. Only the options named by {@code updates} are changed; a + * derived field mask leaves the rest untouched. To revert to the options the activity was created + * with, use {@link #restoreOriginalOptions()}. + * + *

Updates are created from the keys on {@link ActivityOptionsUpdate}, via {@link + * ActivityOptionsUpdate.ActivityOptionsKey#set} to set an option or {@link + * ActivityOptionsUpdate.ActivityOptionsKey#unset} to clear it. + * + * @param updates the option updates to apply; at least one is required + * @return the activity options as resolved by the server after the update + */ + ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates); + + /** + * Restores the activity's options to the ones it was created with. + * + * @return the activity options as resolved by the server after the restore + */ + ActivityExecutionOptions restoreOriginalOptions(); } diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java index 16a34dc285..3dbd25fa6e 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java @@ -4,8 +4,13 @@ import io.temporal.client.ActivityExecutionCount; import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.ActivityExecutionMetadata; +import io.temporal.client.ActivityExecutionOptions; import io.temporal.client.ActivityFailedException; +import io.temporal.client.ActivityOptionsUpdate; +import io.temporal.client.DescribeActivityOptions; +import io.temporal.client.PauseActivityOptions; import io.temporal.client.StartActivityOptions; +import io.temporal.client.UnpauseActivityOptions; import io.temporal.common.Experimental; import java.lang.reflect.Type; import java.util.List; @@ -80,6 +85,33 @@ GetActivityResultOutput getActivityResult(GetActivityResultInput input */ TerminateActivityOutput terminateActivity(TerminateActivityInput input); + /** + * Pauses a running standalone activity. A paused activity stops being dispatched to workers until + * it is unpaused. + * + * @param input activity ID, optional run ID, and optional human-readable reason + * @return an empty output object (reserved for future use) + */ + PauseActivityOutput pauseActivity(PauseActivityInput input); + + /** + * Unpauses a previously paused standalone activity, allowing it to be dispatched again. + * + * @param input activity ID, optional run ID, and unpause options (reason, jitter) + * @return an empty output object (reserved for future use) + */ + UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input); + + /** + * Updates the options of a standalone activity. The {@code updateMask} controls which fields of + * {@code activityOptions} are applied; alternatively {@code restoreOriginal} reverts the options + * to the values the activity was created with. + * + * @param input activity ID, optional run ID, options, update mask, and restore flag + * @return output carrying the activity options as resolved by the server after the update + */ + UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input); + /** * Returns a lazy {@link java.util.stream.Stream} of activity execution metadata matching the * Visibility query in {@code input}. Pages are fetched from the server on demand as the stream is @@ -250,10 +282,13 @@ public R getResult() { final class DescribeActivityInput { private final String id; private final @Nullable String runId; + private final DescribeActivityOptions options; - public DescribeActivityInput(String id, @Nullable String runId) { + public DescribeActivityInput( + String id, @Nullable String runId, DescribeActivityOptions options) { this.id = id; this.runId = runId; + this.options = options; } public String getId() { @@ -264,6 +299,10 @@ public String getId() { public String getRunId() { return runId; } + + public DescribeActivityOptions getOptions() { + return options; + } } @Experimental @@ -339,6 +378,118 @@ public String getReason() { @Experimental final class TerminateActivityOutput {} + @Experimental + final class PauseActivityInput { + private final String id; + private final @Nullable String runId; + private final PauseActivityOptions options; + + public PauseActivityInput(String id, @Nullable String runId, PauseActivityOptions options) { + this.id = id; + this.runId = runId; + this.options = options; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + public PauseActivityOptions getOptions() { + return options; + } + } + + @Experimental + final class PauseActivityOutput {} + + @Experimental + final class UnpauseActivityInput { + private final String id; + private final @Nullable String runId; + private final UnpauseActivityOptions options; + + public UnpauseActivityInput(String id, @Nullable String runId, UnpauseActivityOptions options) { + this.id = id; + this.runId = runId; + this.options = options; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + public UnpauseActivityOptions getOptions() { + return options; + } + } + + @Experimental + final class UnpauseActivityOutput {} + + @Experimental + final class UpdateActivityOptionsInput { + private final String id; + private final @Nullable String runId; + private final List> updates; + private final boolean restoreOriginal; + + public UpdateActivityOptionsInput( + String id, + @Nullable String runId, + List> updates, + boolean restoreOriginal) { + this.id = id; + this.runId = runId; + this.updates = updates; + this.restoreOriginal = restoreOriginal; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + /** + * The option updates to apply, in the order the caller supplied them. Empty when {@link + * #isRestoreOriginal()} is true. For a repeated key, the later update wins. + */ + public List> getUpdates() { + return updates; + } + + public boolean isRestoreOriginal() { + return restoreOriginal; + } + } + + @Experimental + final class UpdateActivityOptionsOutput { + private final ActivityExecutionOptions options; + + public UpdateActivityOptionsOutput(ActivityExecutionOptions options) { + this.options = options; + } + + /** The activity options as resolved by the server after the update. */ + public ActivityExecutionOptions getOptions() { + return options; + } + } + @Experimental final class ListActivitiesInput { private final String query; diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java index e8b99f5b9f..e1604fb87a 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java @@ -1,9 +1,11 @@ package io.temporal.common.interceptors; +import io.temporal.common.Experimental; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; /** Convenience base class for {@link ActivityClientCallsInterceptor} implementations. */ +@Experimental public class ActivityClientCallsInterceptorBase implements ActivityClientCallsInterceptor { private final ActivityClientCallsInterceptor next; @@ -44,6 +46,21 @@ public TerminateActivityOutput terminateActivity(TerminateActivityInput input) { return next.terminateActivity(input); } + @Override + public PauseActivityOutput pauseActivity(PauseActivityInput input) { + return next.pauseActivity(input); + } + + @Override + public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { + return next.unpauseActivity(input); + } + + @Override + public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input) { + return next.updateActivityOptions(input); + } + @Override public ListActivitiesOutput listActivities(ListActivitiesInput input) { return next.listActivities(input); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java index 77ecddcb4f..0736547d7a 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java @@ -1,9 +1,17 @@ package io.temporal.internal.client; import io.temporal.client.ActivityExecutionDescription; +import io.temporal.client.ActivityExecutionOptions; +import io.temporal.client.ActivityOptionsUpdate; +import io.temporal.client.DescribeActivityOptions; +import io.temporal.client.PauseActivityOptions; +import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -102,9 +110,15 @@ public CompletableFuture getResultAsync( @Override public ActivityExecutionDescription describe() { + return describe(DescribeActivityOptions.getDefaultInstance()); + } + + @Override + public ActivityExecutionDescription describe(DescribeActivityOptions options) { return clientCallsInterceptor .describeActivity( - new ActivityClientCallsInterceptor.DescribeActivityInput(activityId, activityRunId)) + new ActivityClientCallsInterceptor.DescribeActivityInput( + activityId, activityRunId, options)) .getDescription(); } @@ -130,4 +144,55 @@ public void terminate(@Nullable String reason) { new ActivityClientCallsInterceptor.TerminateActivityInput( activityId, activityRunId, reason)); } + + @Override + public void pause() { + pause(PauseActivityOptions.getDefaultInstance()); + } + + @Override + public void pause(PauseActivityOptions options) { + clientCallsInterceptor.pauseActivity( + new ActivityClientCallsInterceptor.PauseActivityInput(activityId, activityRunId, options)); + } + + @Override + public void unpause() { + unpause(UnpauseActivityOptions.getDefaultInstance()); + } + + @Override + public void unpause(UnpauseActivityOptions options) { + clientCallsInterceptor.unpauseActivity( + new ActivityClientCallsInterceptor.UnpauseActivityInput( + activityId, activityRunId, options)); + } + + @Override + public ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates) { + List> list = Arrays.asList(updates); + + // An update naming nothing would send an empty mask and silently change nothing. Fail here + // rather than making a round trip that looks like it worked. Use restoreOriginalOptions() to + // revert options instead. + if (list.isEmpty()) { + throw new IllegalArgumentException("updateOptions requires at least one option update"); + } + + ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = + clientCallsInterceptor.updateActivityOptions( + new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( + activityId, activityRunId, list, false)); + + return output.getOptions(); + } + + @Override + public ActivityExecutionOptions restoreOriginalOptions() { + ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = + clientCallsInterceptor.updateActivityOptions( + new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( + activityId, activityRunId, Collections.emptyList(), true)); + return output.getOptions(); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index 225228e6a2..077ad34246 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -5,10 +5,12 @@ import com.google.common.base.Strings; import com.google.common.collect.Iterators; +import com.google.protobuf.FieldMask; import io.grpc.Deadline; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.temporal.api.activity.v1.ActivityExecutionOutcome; +import io.temporal.api.activity.v1.ActivityOptions; import io.temporal.api.common.v1.ActivityType; import io.temporal.api.common.v1.Callback; import io.temporal.api.common.v1.Link; @@ -18,6 +20,7 @@ import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.api.workflowservice.v1.*; import io.temporal.client.*; +import io.temporal.client.ActivityOptionsUpdate; import io.temporal.common.converter.DataConverter; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; @@ -32,6 +35,8 @@ import io.temporal.serviceclient.StatusUtils; import java.lang.reflect.Type; import java.util.*; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeoutException; @@ -342,14 +347,18 @@ public DescribeActivityOutput describeActivity(DescribeActivityInput input) { DescribeActivityExecutionRequest.Builder req = DescribeActivityExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) - .setActivityId(input.getId()); + .setActivityId(input.getId()) + .setIncludeInput(input.getOptions().isIncludeInput()) + .setIncludeOutcome(input.getOptions().isIncludeOutcome()) + .setIncludeHeartbeatDetails(input.getOptions().isIncludeHeartbeatDetails()) + .setIncludeLastFailure(input.getOptions().isIncludeLastFailure()); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } DescribeActivityExecutionResponse response = genericClient.describeActivity(req.build()); return new DescribeActivityOutput( new ActivityExecutionDescription( - response.getInfo(), clientOptions.getDataConverter(), clientOptions.getNamespace())); + response, clientOptions.getDataConverter(), clientOptions.getNamespace())); } @Override @@ -388,6 +397,77 @@ public TerminateActivityOutput terminateActivity(TerminateActivityInput input) { return new TerminateActivityOutput(); } + @Override + public PauseActivityOutput pauseActivity(PauseActivityInput input) { + PauseActivityExecutionRequest.Builder req = + PauseActivityExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setRequestId(UUID.randomUUID().toString()) + .setActivityId(input.getId()); + if (input.getRunId() != null) { + req.setRunId(input.getRunId()); + } + if (input.getOptions().getReason() != null) { + req.setReason(input.getOptions().getReason()); + } + genericClient.pauseActivity(req.build()); + return new PauseActivityOutput(); + } + + @Override + public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { + UnpauseActivityExecutionRequest.Builder req = + UnpauseActivityExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setActivityId(input.getId()) + .setRequestId(UUID.randomUUID().toString()); + if (input.getRunId() != null) { + req.setRunId(input.getRunId()); + } + if (input.getOptions().getReason() != null) { + req.setReason(input.getOptions().getReason()); + } + if (input.getOptions().getJitter() != null) { + req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getOptions().getJitter())); + } + genericClient.unpauseActivity(req.build()); + return new UnpauseActivityOutput(); + } + + @Override + public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input) { + UpdateActivityExecutionOptionsRequest.Builder req = + UpdateActivityExecutionOptionsRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setActivityId(input.getId()) + .setRequestId(UUID.randomUUID().toString()); + if (input.getRunId() != null) { + req.setRunId(input.getRunId()); + } + if (input.isRestoreOriginal()) { + req.setRestoreOriginal(true); + } else { + // For repeated keys, later values override previous ones. + Map> byPath = new LinkedHashMap<>(); + for (ActivityOptionsUpdate update : input.getUpdates()) { + byPath.put(update.getKey().getPath(), update); + } + ActivityOptions.Builder activityOptions = ActivityOptions.newBuilder(); + for (ActivityOptionsUpdate update : byPath.values()) { + update.applyTo(activityOptions); + } + req.setActivityOptions(activityOptions.build()) + .setUpdateMask(FieldMask.newBuilder().addAllPaths(byPath.keySet()).build()); + } + UpdateActivityExecutionOptionsResponse response = + genericClient.updateActivityOptions(req.build()); + return new UpdateActivityOptionsOutput( + new ActivityExecutionOptions(response.getActivityOptions())); + } + @Override public ListActivitiesOutput listActivities(ListActivitiesInput input) { ListActivityExecutionIterator iterator = 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 23932104fe..b83ce177a2 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 @@ -122,6 +122,16 @@ CompletableFuture pollActivityAsync( @Experimental void terminateActivity(TerminateActivityExecutionRequest request); + @Experimental + void pauseActivity(PauseActivityExecutionRequest request); + + @Experimental + void unpauseActivity(UnpauseActivityExecutionRequest request); + + @Experimental + UpdateActivityExecutionOptionsResponse updateActivityOptions( + UpdateActivityExecutionOptionsRequest request); + @Experimental ListActivityExecutionsResponse listActivities(ListActivityExecutionsRequest request); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/GenericWorkflowClientImpl.java index cee4ffc893..fb1a42feed 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 @@ -641,6 +641,40 @@ public void terminateActivity(TerminateActivityExecutionRequest request) { grpcRetryerOptions); } + @Override + public void pauseActivity(PauseActivityExecutionRequest request) { + grpcRetryer.retry( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .pauseActivityExecution(request), + grpcRetryerOptions); + } + + @Override + public void unpauseActivity(UnpauseActivityExecutionRequest request) { + grpcRetryer.retry( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .unpauseActivityExecution(request), + grpcRetryerOptions); + } + + @Override + public UpdateActivityExecutionOptionsResponse updateActivityOptions( + UpdateActivityExecutionOptionsRequest request) { + return grpcRetryer.retryWithResult( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .updateActivityExecutionOptions(request), + grpcRetryerOptions); + } + @Override public ListActivityExecutionsResponse listActivities(ListActivityExecutionsRequest request) { return grpcRetryer.retryWithResult( diff --git a/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java b/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java index 024d8b1890..6c965c45bc 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java @@ -4,19 +4,21 @@ import com.google.common.reflect.TypeToken; import io.temporal.api.activity.v1.ActivityExecutionInfo; +import io.temporal.api.activity.v1.ActivityExecutionOutcome; import io.temporal.api.common.v1.ActivityType; import io.temporal.api.common.v1.Payloads; import io.temporal.api.enums.v1.ActivityExecutionStatus; +import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.common.Priority; import io.temporal.common.WorkerDeploymentVersion; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; import io.temporal.internal.common.ProtobufTimeUtils; import java.lang.reflect.Type; import java.time.Instant; import java.util.Arrays; import java.util.List; -import java.util.Optional; import org.junit.Test; public class ActivityExecutionDescriptionTest { @@ -35,26 +37,31 @@ private ActivityExecutionInfo buildInfo(String activityId, String runId) { .build(); } + private ActivityExecutionDescription describe(ActivityExecutionInfo info) { + return describe(DescribeActivityExecutionResponse.newBuilder().setInfo(info).build()); + } + + private ActivityExecutionDescription describe(DescribeActivityExecutionResponse response) { + return new ActivityExecutionDescription(response, CONVERTER, "test-ns"); + } + @Test public void testNullRunIdWhenEmpty() { - ActivityExecutionDescription desc = - new ActivityExecutionDescription(buildInfo("act-id", ""), CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(buildInfo("act-id", "")); assertNull(desc.getActivityRunId()); } @Test public void testScheduledTime() { - ActivityExecutionDescription desc = - new ActivityExecutionDescription(buildInfo("act-id", ""), CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(buildInfo("act-id", "")); assertEquals(Instant.ofEpochMilli(1000), desc.getScheduledTime()); } @Test public void testHasHeartbeatDetailsAbsent() { - ActivityExecutionDescription desc = - new ActivityExecutionDescription(buildInfo("id", "run"), CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); assertFalse(desc.hasHeartbeatDetails()); - assertFalse(desc.getHeartbeatDetails(String.class).isPresent()); + assertEquals(0, desc.getHeartbeatDetails().getSize()); } @Test @@ -62,13 +69,11 @@ public void testGetHeartbeatDetailsPresent() { Payloads encoded = CONVERTER.toPayloads("hello-heartbeat").get(); ActivityExecutionInfo info = buildInfo("id", "run").toBuilder().setHeartbeatDetails(encoded).build(); - ActivityExecutionDescription desc = - new ActivityExecutionDescription(info, CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(info); assertTrue(desc.hasHeartbeatDetails()); - Optional result = desc.getHeartbeatDetails(String.class); - assertTrue(result.isPresent()); - assertEquals("hello-heartbeat", result.get()); + assertEquals(1, desc.getHeartbeatDetails().getSize()); + assertEquals("hello-heartbeat", desc.getHeartbeatDetails().get(0, String.class)); } @Test @@ -78,14 +83,13 @@ public void testGetHeartbeatDetailsWithExplicitGenericType() { Payloads encoded = CONVERTER.toPayloads(original).get(); ActivityExecutionInfo info = buildInfo("id", "run").toBuilder().setHeartbeatDetails(encoded).build(); - ActivityExecutionDescription desc = - new ActivityExecutionDescription(info, CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(info); Type genericType = new TypeToken>() {}.getType(); Class> listClass = (Class>) (Class) List.class; - Optional> result = desc.getHeartbeatDetails(listClass, genericType); - assertTrue(result.isPresent()); - assertEquals(Arrays.asList("one", "two", "three"), result.get()); + assertEquals( + Arrays.asList("one", "two", "three"), + desc.getHeartbeatDetails().get(0, listClass, genericType)); } @Test @@ -97,8 +101,7 @@ public void testGetWorkerDeploymentVersionPresent() { .build(); ActivityExecutionInfo info = buildInfo("id", "run").toBuilder().setLastDeploymentVersion(protoVersion).build(); - ActivityExecutionDescription desc = - new ActivityExecutionDescription(info, CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(info); WorkerDeploymentVersion version = desc.getWorkerDeploymentVersion(); assertNotNull(version); @@ -106,14 +109,104 @@ public void testGetWorkerDeploymentVersionPresent() { assertEquals("build-42", version.getBuildId()); } + @Test + public void testInputAbsentUnlessRequested() { + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); + assertFalse(desc.hasInput()); + assertEquals(0, desc.getInput().getSize()); + } + + @Test + public void testGetInputPresent() { + DescribeActivityExecutionResponse response = + DescribeActivityExecutionResponse.newBuilder() + .setInfo(buildInfo("id", "run")) + .setInput(CONVERTER.toPayloads("hello-input").get()) + .build(); + ActivityExecutionDescription desc = describe(response); + + assertTrue(desc.hasInput()); + assertEquals(1, desc.getInput().getSize()); + assertEquals("hello-input", desc.getInput().get(0, String.class)); + } + + @Test + public void testGetInputDecodesEveryArgument() { + DescribeActivityExecutionResponse response = + DescribeActivityExecutionResponse.newBuilder() + .setInfo(buildInfo("id", "run")) + .setInput(CONVERTER.toPayloads("first", 42).get()) + .build(); + ActivityExecutionDescription desc = describe(response); + + assertEquals(2, desc.getInput().getSize()); + assertEquals("first", desc.getInput().get(0, String.class)); + assertEquals(Integer.valueOf(42), desc.getInput().get(1, Integer.class)); + } + + @Test + public void testInputEmptyWhenInputAbsent() { + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); + assertEquals(0, desc.getInput().getSize()); + } + + @Test + public void testOutcomeAbsentUnlessRequested() { + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); + assertFalse(desc.hasResult()); + assertFalse(desc.getResult(String.class).isPresent()); + assertNull(desc.getOutcomeFailure()); + } + + @Test + public void testGetResultPresentOnSuccessfulOutcome() { + DescribeActivityExecutionResponse response = + DescribeActivityExecutionResponse.newBuilder() + .setInfo(buildInfo("id", "run")) + .setOutcome( + ActivityExecutionOutcome.newBuilder() + .setResult(CONVERTER.toPayloads("hello-result").get()) + .build()) + .build(); + ActivityExecutionDescription desc = describe(response); + + assertTrue(desc.hasResult()); + assertEquals("hello-result", desc.getResult(String.class).orElse(null)); + // A successful outcome has no failure arm. + assertNull(desc.getOutcomeFailure()); + } + + @Test + public void testGetFailurePresentOnFailedOutcome() { + DescribeActivityExecutionResponse response = + DescribeActivityExecutionResponse.newBuilder() + .setInfo(buildInfo("id", "run")) + .setOutcome( + ActivityExecutionOutcome.newBuilder() + .setFailure( + CONVERTER.exceptionToFailure( + ApplicationFailure.newFailure("boom", "test-type"))) + .build()) + .build(); + ActivityExecutionDescription desc = describe(response); + + // The failure arm is populated, so there is no result to read. + assertFalse(desc.hasResult()); + assertFalse(desc.getResult(String.class).isPresent()); + + RuntimeException failure = desc.getOutcomeFailure(); + assertNotNull(failure); + assertTrue(failure instanceof ApplicationFailure); + assertEquals("boom", ((ApplicationFailure) failure).getOriginalMessage()); + } + @Test public void testGetPriorityPresent() { io.temporal.api.common.v1.Priority protoPriority = io.temporal.api.common.v1.Priority.newBuilder().setPriorityKey(3).build(); ActivityExecutionInfo info = buildInfo("id", "run").toBuilder().setPriority(protoPriority).build(); - ActivityExecutionDescription desc = - new ActivityExecutionDescription(info, CONVERTER, "test-ns"); + ActivityExecutionDescription desc = describe(info); Priority priority = desc.getPriority(); assertNotNull(priority); diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java new file mode 100644 index 0000000000..a818219290 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -0,0 +1,747 @@ +package io.temporal.client.functional; + +import static io.temporal.testUtils.Eventually.assertEventually; +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityExecutionContext; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.enums.v1.ActivityExecutionStatus; +import io.temporal.api.enums.v1.PendingActivityState; +import io.temporal.client.ActivityCanceledException; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityExecutionDescription; +import io.temporal.client.ActivityExecutionOptions; +import io.temporal.client.ActivityHandle; +import io.temporal.client.ActivityOptionsUpdate; +import io.temporal.client.DescribeActivityOptions; +import io.temporal.client.PauseActivityOptions; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.UnpauseActivityOptions; +import io.temporal.common.CancellationToken; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor.*; +import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; +import io.temporal.common.interceptors.ActivityClientInterceptorBase; +import io.temporal.failure.ApplicationFailure; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import org.junit.Rule; +import org.junit.Test; + +/** + * Integration tests for the standalone-activity operator commands on {@link ActivityHandle}: pause, + * unpause and updateOptions. Each asserts an observable server state change. + * + *

Gated behind {@link SDKTestWorkflowRule#useExternalService} because the embedded test server + * does not support the standalone activity APIs. + */ +public class StandaloneActivityOperatorCommandsTest { + + /** Heartbeat details are opt-in on describe; these tests assert on them. */ + private static final DescribeActivityOptions WITH_HEARTBEAT_DETAILS = + DescribeActivityOptions.newBuilder().setIncludeHeartbeatDetails(true).build(); + + // --------------------------------------------------------------------------- + // Activities + // --------------------------------------------------------------------------- + + /** Long-running activity that heartbeats and runs until cancellation/interruption. */ + @ActivityInterface + public interface SlowActivity { + @ActivityMethod(name = "Slow") + void run(); + } + + public static class SlowActivityImpl implements SlowActivity { + @Override + public void run() { + Activity.getExecutionContext().heartbeat(null); + while (true) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + Activity.getExecutionContext().heartbeat(null); + } + } + } + + /** Takes two arguments, so a describe can read a multi-argument input back off the server. */ + @ActivityInterface + public interface TwoArgActivity { + @ActivityMethod(name = "TwoArg") + String run(String word, Integer count); + } + + public static class TwoArgActivityImpl implements TwoArgActivity { + @Override + public String run(String word, Integer count) { + return word + "-" + count; + } + } + + /** Returns immediately. Used with a start delay so it can be paused while scheduled. */ + @ActivityInterface + public interface QuickActivity { + @ActivityMethod(name = "Quick") + String run(); + } + + public static class QuickActivityImpl implements QuickActivity { + @Override + public String run() { + return "resumed"; + } + } + + /** Fails until the third attempt, then succeeds. Drives an activity past its first attempt. */ + @ActivityInterface + public interface FailThenSucceedActivity { + @ActivityMethod(name = "FailThenSucceed") + String run(); + } + + public static class FailThenSucceedActivityImpl implements FailThenSucceedActivity { + @Override + public String run() { + if (Activity.getExecutionContext().getInfo().getAttempt() < 3) { + throw ApplicationFailure.newFailure("retryable failure", "retry-type"); + } + return "done"; + } + } + + /** Heartbeats, fails the first attempt, then succeeds. */ + @ActivityInterface + public interface HeartbeatFailIncrementActivity { + @ActivityMethod(name = "HeartbeatFailIncrement") + Integer run(Integer value); + } + + public static class HeartbeatFailIncrementActivityImpl implements HeartbeatFailIncrementActivity { + @Override + public Integer run(Integer value) { + Activity.getExecutionContext().heartbeat("heartbeat details"); + if (Activity.getExecutionContext().getInfo().getAttempt() == 1) { + throw ApplicationFailure.newFailure("deliberate first-attempt failure", "first-attempt"); + } + return value + 1; + } + } + + /** + * Records heartbeat details on attempt 1, then blocks waiting for cancellation. The heartbeat + * runs on its own — not adjacent to any completion RPC — so the details reliably persist and are + * observable via describe. Later attempts (after an unpause that spawns a new attempt) do not + * heartbeat, so any operator-driven clearing of the details stays observable. + */ + @ActivityInterface + public interface HeartbeatOnceActivity { + @ActivityMethod(name = "HeartbeatOnce") + void run(); + } + + public static class HeartbeatOnceActivityImpl implements HeartbeatOnceActivity { + @Override + public void run() { + ActivityExecutionContext ctx = Activity.getExecutionContext(); + if (ctx.getInfo().getAttempt() == 1) { + ctx.heartbeat("hb-details"); + } + CancellationToken token = ctx.getCancellationToken(); + while (!token.isCancellationRequested()) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + + // --------------------------------------------------------------------------- + // Rule + helpers + // --------------------------------------------------------------------------- + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setActivityImplementations( + new SlowActivityImpl(), + new QuickActivityImpl(), + new FailThenSucceedActivityImpl(), + new TwoArgActivityImpl(), + new HeartbeatOnceActivityImpl(), + new HeartbeatFailIncrementActivityImpl()) + .build(); + + /** + * A running activity does not transition straight to PAUSED on pause: the server records + * PAUSE_REQUESTED and only moves to PAUSED once the worker drops the attempt. A long-running + * heartbeating activity that has not yet noticed the pause stays in PAUSE_REQUESTED, so both + * states count as "paused" for an observability assertion. + */ + private static final List PAUSED_STATES = + Arrays.asList( + PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, + PendingActivityState.PENDING_ACTIVITY_STATE_PAUSE_REQUESTED); + + private String uniqueId() { + return "act-" + UUID.randomUUID(); + } + + private ActivityClient newActivityClient() { + return ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder().setNamespace(SDKTestWorkflowRule.NAMESPACE).build()); + } + + private void assertEventuallyPaused(ActivityHandle handle) { + assertEventually( + Duration.ofSeconds(30), + () -> + assertTrue( + "expected paused run state, got " + handle.describe().getRunState(), + PAUSED_STATES.contains(handle.describe().getRunState()))); + } + + /** Start a SlowActivity and wait until it has actually started running on the worker. */ + private ActivityHandle startRunningSlowActivity(StartActivityOptions.Builder optsBuilder) { + ActivityHandle handle = + newActivityClient().start(SlowActivity.class, SlowActivity::run, optsBuilder.build()); + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + PendingActivityState.PENDING_ACTIVITY_STATE_STARTED, + handle.describe().getRunState())); + return handle; + } + + /** + * Start a HeartbeatOnceActivity and wait until its first attempt has recorded heartbeat details. + * The activity keeps running (sleeping until interrupted) once heartbeat has fired, so pause + * transitions the activity through PAUSE_REQUESTED to PAUSED — assertEventuallyPaused tolerates + * both. + */ + private ActivityHandle startHeartbeatReadyActivity() { + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(30)) + .build(); + ActivityHandle handle = + newActivityClient().start(HeartbeatOnceActivity.class, HeartbeatOnceActivity::run, opts); + assertEventually( + Duration.ofSeconds(30), + () -> + assertTrue( + "expected heartbeat details to be recorded", + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails())); + return handle; + } + + private StartActivityOptions.Builder slowOpts() { + return StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(30)); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + // Overrides the rule's default 10s global timeout: the start delay makes this take longer. + @Test(timeout = 60_000) + public void unpauseResumes() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityClient client = newActivityClient(); + // Start with a long delay so the activity sits SCHEDULED and can be paused before it runs. + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setStartDelay(Duration.ofSeconds(30)) + .build(); + ActivityHandle handle = client.start(QuickActivity.class, QuickActivity::run, opts); + + handle.pause(PauseActivityOptions.newBuilder().setReason("pause-before-unpause").build()); + // A not-yet-started (scheduled) activity transitions fully to PAUSED. + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, + handle.describe().getRunState())); + + handle.unpause(); + assertEventually( + Duration.ofSeconds(30), + () -> assertFalse(PAUSED_STATES.contains(handle.describe().getRunState()))); + handle.terminate("cleanup"); + } + + @Test + public void updateOptionsRespectsMask() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = + startRunningSlowActivity( + slowOpts() + .setStartToCloseTimeout(Duration.ofSeconds(45)) + .setScheduleToCloseTimeout(Duration.ofSeconds(120))); + + ActivityExecutionOptions updated = + handle.updateOptions( + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90))); + + // Returned options: only start_to_close changed; schedule_to_close kept its original value. + assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); + assertEquals(Duration.ofSeconds(120), updated.getScheduleToCloseTimeout()); + + // Confirm via describe that the partial update was applied server-side. + assertEventually( + Duration.ofSeconds(30), + () -> { + ActivityExecutionDescription desc = handle.describe(); + assertEquals(Duration.ofSeconds(90), desc.getStartToCloseTimeout()); + assertEquals(Duration.ofSeconds(120), desc.getScheduleToCloseTimeout()); + }); + handle.terminate("cleanup"); + } + + // Overrides the rule's default 10s global timeout: uses a start delay to keep the activity + // scheduled while every option is updated and observed. + @Test(timeout = 60_000) + public void updateOptionsAllFields() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + // Start delayed so the activity stays SCHEDULED (never runs) while we update every option. + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofSeconds(100)) + .setStartToCloseTimeout(Duration.ofSeconds(30)) + .setStartDelay(Duration.ofSeconds(300)) + .build(); + ActivityHandle handle = + newActivityClient().start(QuickActivity.class, QuickActivity::run, opts); + + ActivityExecutionOptions updated = + handle.updateOptions( + ActivityOptionsUpdate.TASK_QUEUE.set("updated-tq"), + ActivityOptionsUpdate.SCHEDULE_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(200)), + ActivityOptionsUpdate.SCHEDULE_TO_START_TIMEOUT.set(Duration.ofSeconds(15)), + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90)), + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.set(Duration.ofSeconds(25)), + ActivityOptionsUpdate.RETRY_OPTIONS.set( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(2.0) + .setMaximumAttempts(7) + .build()), + ActivityOptionsUpdate.PRIORITY.set(Priority.newBuilder().setPriorityKey(3).build()), + ActivityOptionsUpdate.START_DELAY.set(Duration.ofSeconds(500))); + + // Every field is settable and lands: the returned options reflect each new value. + assertEquals("updated-tq", updated.getTaskQueue()); + assertEquals(Duration.ofSeconds(200), updated.getScheduleToCloseTimeout()); + assertEquals(Duration.ofSeconds(15), updated.getScheduleToStartTimeout()); + assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); + assertEquals(Duration.ofSeconds(25), updated.getHeartbeatTimeout()); + assertEquals(7, updated.getRetryOptions().getMaximumAttempts()); + assertEquals(3, updated.getPriority().getPriorityKey()); + assertEquals(Duration.ofSeconds(500), updated.getStartDelay()); + + // And describe reflects them server-side. + ActivityExecutionDescription desc = handle.describe(); + assertEquals("updated-tq", desc.getTaskQueue()); + assertEquals(Duration.ofSeconds(200), desc.getScheduleToCloseTimeout()); + assertEquals(Duration.ofSeconds(15), desc.getScheduleToStartTimeout()); + assertEquals(Duration.ofSeconds(90), desc.getStartToCloseTimeout()); + assertEquals(Duration.ofSeconds(25), desc.getHeartbeatTimeout()); + assertEquals(7, desc.getRetryOptions().getMaximumAttempts()); + assertEquals(3, desc.getPriority().getPriorityKey()); + assertEquals(Duration.ofSeconds(500), desc.getStartDelay()); + assertEquals( + desc.getScheduledTime().plus(Duration.ofSeconds(500)).getEpochSecond(), + desc.getExecutionTime().getEpochSecond()); + + handle.terminate("cleanup"); + } + + @Test + public void updateOptionsRestoreOriginal() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = + startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); + + // Change an option away from the original. + ActivityExecutionOptions changed = + handle.updateOptions( + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90))); + assertEquals(Duration.ofSeconds(90), changed.getStartToCloseTimeout()); + + // restore_original alone reverts to the value the activity was created with. + ActivityExecutionOptions restored = handle.restoreOriginalOptions(); + assertEquals(Duration.ofSeconds(45), restored.getStartToCloseTimeout()); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void updateOptionsOnPausedActivity() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + // Start delayed so the activity sits SCHEDULED and pauses to a true PAUSED state rather than + // the PAUSE_REQUESTED a running activity lands in. + ActivityHandle handle = + newActivityClient() + .start( + QuickActivity.class, + QuickActivity::run, + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(45)) + .setScheduleToCloseTimeout(Duration.ofSeconds(120)) + .setStartDelay(Duration.ofSeconds(60)) + .build()); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, + handle.describe().getRunState())); + + // Updating options is legal while paused, and the new value lands. + ActivityExecutionOptions updated = + handle.updateOptions( + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90))); + assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); + + ActivityExecutionDescription desc = handle.describe(); + assertEquals(Duration.ofSeconds(90), desc.getStartToCloseTimeout()); + // The mask is still honored while paused — an option we didn't touch keeps its original value. + assertEquals(Duration.ofSeconds(120), desc.getScheduleToCloseTimeout()); + // And the update leaves the activity paused; it is not an implicit unpause. + assertEquals(PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, desc.getRunState()); + assertEquals(ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_PAUSED, desc.getStatus()); + + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void describeReportsPausedStatus() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + // Start delayed so the activity sits SCHEDULED; pausing from there reaches a true PAUSED state + // rather than the PAUSE_REQUESTED of a running activity. + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setStartDelay(Duration.ofSeconds(30)) + .build(); + ActivityHandle handle = + newActivityClient().start(QuickActivity.class, QuickActivity::run, opts); + + assertEquals( + ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_RUNNING, handle.describe().getStatus()); + + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); + + assertEventually( + Duration.ofSeconds(30), + () -> { + ActivityExecutionDescription desc = handle.describe(); + assertEquals(ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_PAUSED, desc.getStatus()); + assertEquals(PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, desc.getRunState()); + }); + + handle.terminate("cleanup"); + } + + /** The count tracks heartbeats the server recorded. */ + @Test(timeout = 60_000) + public void describeReportsTotalHeartbeatCount() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = + startRunningSlowActivity(slowOpts().setHeartbeatTimeout(Duration.ofSeconds(3))); + + assertEventually( + Duration.ofSeconds(20), + () -> + assertTrue( + "total heartbeat count should reach 2", + handle.describe().getTotalHeartbeatCount() >= 2)); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void describePayloads() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(5)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(2).build()) + .build(); + ActivityHandle handle = + newActivityClient() + .start( + HeartbeatFailIncrementActivity.class, HeartbeatFailIncrementActivity::run, opts, 1); + assertEquals(Integer.valueOf(2), handle.getResult(Integer.class)); + + // Nothing requested: every payload field is absent. + ActivityExecutionDescription bare = handle.describe(); + assertFalse(bare.hasInput()); + assertFalse(bare.hasResult()); + assertFalse(bare.hasHeartbeatDetails()); + assertFalse(bare.hasLastFailure()); + assertFalse(bare.getResult(Integer.class).isPresent()); + assertNull(bare.getOutcomeFailure()); + assertNull(bare.getLastFailure()); + + // All four requested. The activity succeeded on its second attempt, so it has a result and a + // last failure at the same time, and no terminal failure. + ActivityExecutionDescription full = + handle.describe( + DescribeActivityOptions.newBuilder() + .setIncludeInput(true) + .setIncludeOutcome(true) + .setIncludeHeartbeatDetails(true) + .setIncludeLastFailure(true) + .build()); + assertTrue(full.hasInput()); + assertEquals(Integer.valueOf(1), full.getInput().get(0, Integer.class)); + assertTrue(full.hasResult()); + assertEquals(Integer.valueOf(2), full.getResult(Integer.class).orElse(null)); + assertNull(full.getOutcomeFailure()); + assertTrue(full.hasHeartbeatDetails()); + assertEquals("heartbeat details", full.getHeartbeatDetails().get(0, String.class)); + assertTrue(full.hasLastFailure()); + assertNotNull(full.getLastFailure()); + + StartActivityOptions failOpts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build(); + ActivityHandle failed = + newActivityClient() + .start(FailThenSucceedActivity.class, FailThenSucceedActivity::run, failOpts); + assertThrows(Exception.class, () -> failed.getResult(String.class)); + + ActivityExecutionDescription desc = + failed.describe( + DescribeActivityOptions.newBuilder() + .setIncludeOutcome(true) + .setIncludeLastFailure(true) + .build()); + assertFalse(desc.hasResult()); + assertFalse(desc.getResult(String.class).isPresent()); + assertTrue(desc.getOutcomeFailure() instanceof ApplicationFailure); + } + + @Test(timeout = 60_000) + public void pausePreservesHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); + assertEventuallyPaused(handle); + + // Pause never touches heartbeat details — they persist across the transition. + assertTrue( + "heartbeat details should be preserved across pause", + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void unpausePreservesHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); + assertEventuallyPaused(handle); + + // Unpause preserves heartbeat details. The re-dispatched attempt doesn't heartbeat (only + // attempt 1 does), so the persisted details are stable and observable. + handle.unpause(); + + assertEventually( + Duration.ofSeconds(30), + () -> + assertTrue( + "heartbeat details should be preserved after unpause", + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails())); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void updateOptionsPreservesHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); + assertEventuallyPaused(handle); + + // UpdateOptions changes activity options only; it never touches heartbeat details. + handle.updateOptions(ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90))); + + assertTrue( + "heartbeat details should be preserved after updateOptions", + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); + handle.terminate("cleanup"); + } + + // Overrides the rule's default 10s global timeout: exercises every command against a real server. + @Test(timeout = 60_000) + public void interceptorInvokesEachOperatorCommand() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + List events = Collections.synchronizedList(new ArrayList<>()); + ActivityClient client = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder() + .setNamespace(SDKTestWorkflowRule.NAMESPACE) + .setInterceptors(Collections.singletonList(new RecordingInterceptor(events))) + .build()); + + ActivityHandle handle = + client.start(SlowActivity.class, SlowActivity::run, slowOpts().build()); + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + PendingActivityState.PENDING_ACTIVITY_STATE_STARTED, + handle.describe().getRunState())); + + handle.pause(PauseActivityOptions.newBuilder().setReason("reason").build()); + assertEventuallyPaused(handle); + handle.unpause(); + handle.updateOptions(ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90))); + handle.terminate("cleanup"); + + assertTrue("pause should flow through the interceptor", events.contains("pause")); + assertTrue("unpause should flow through the interceptor", events.contains("unpause")); + assertTrue( + "updateOptions should flow through the interceptor", events.contains("updateOptions")); + } + + /** Records each operator command as it flows through the client interceptor chain. */ + /** + * Asserts the values a caller passes reach the interceptor chain, not merely that the hook fired. + * A dropped argument between the handle and the chain is invisible to a test that only checks + * which events were recorded. + */ + @Test + public void interceptorReceivesCommandArguments() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + List events = Collections.synchronizedList(new ArrayList<>()); + RecordingInterceptor recorder = new RecordingInterceptor(events); + ActivityClient client = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder() + .setNamespace(SDKTestWorkflowRule.NAMESPACE) + .setInterceptors(Collections.singletonList(recorder)) + .build()); + + ActivityHandle handle = + client.start(SlowActivity.class, SlowActivity::run, slowOpts().build()); + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + PendingActivityState.PENDING_ACTIVITY_STATE_STARTED, + handle.describe().getRunState())); + + handle.pause(PauseActivityOptions.newBuilder().setReason("pause-reason").build()); + assertEventuallyPaused(handle); + handle.unpause( + UnpauseActivityOptions.newBuilder() + .setReason("unpause-reason") + .setJitter(Duration.ofSeconds(5)) + .build()); + handle.updateOptions( + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90)), + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.unset()); + handle.terminate("cleanup"); + + assertEquals("pause-reason", recorder.pauseInput.getOptions().getReason()); + assertEquals("unpause-reason", recorder.unpauseInput.getOptions().getReason()); + assertEquals(Duration.ofSeconds(5), recorder.unpauseInput.getOptions().getJitter()); + + List> updates = recorder.updateInput.getUpdates(); + assertEquals(2, updates.size()); + assertFalse(recorder.updateInput.isRestoreOriginal()); + assertEquals( + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.getPath(), updates.get(0).getKey().getPath()); + assertEquals(Duration.ofSeconds(90), updates.get(0).getValue().orElse(null)); + assertEquals( + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.getPath(), updates.get(1).getKey().getPath()); + assertFalse("an unset update carries no value", updates.get(1).getValue().isPresent()); + } + + private static class RecordingInterceptor extends ActivityClientInterceptorBase { + private final List events; + PauseActivityInput pauseInput; + UnpauseActivityInput unpauseInput; + UpdateActivityOptionsInput updateInput; + + RecordingInterceptor(List events) { + this.events = events; + } + + @Override + public ActivityClientCallsInterceptor activityClientCallsInterceptor( + ActivityClientCallsInterceptor next) { + return new ActivityClientCallsInterceptorBase(next) { + @Override + public PauseActivityOutput pauseActivity(PauseActivityInput input) { + events.add("pause"); + pauseInput = input; + return super.pauseActivity(input); + } + + @Override + public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { + events.add("unpause"); + unpauseInput = input; + return super.unpauseActivity(input); + } + + @Override + public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input) { + events.add("updateOptions"); + updateInput = input; + return super.updateActivityOptions(input); + } + }; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java index 3cc94cb26a..2a7c87c694 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java @@ -15,11 +15,8 @@ import io.temporal.api.enums.v1.ActivityExecutionStatus; import io.temporal.api.enums.v1.ActivityIdConflictPolicy; import io.temporal.api.enums.v1.ActivityIdReusePolicy; -import io.temporal.api.workflowservice.v1.DescribeActivityExecutionRequest; -import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.client.*; import io.temporal.common.RetryOptions; -import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.common.interceptors.ActivityClientCallsInterceptor.*; import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; @@ -857,26 +854,9 @@ public void testDescribeLastFailureIsPopulatedDuringRetryBackoff() { assertEventually( Duration.ofSeconds(60), () -> { - // last_failure carries a payload, so the server returns it only when the - // DescribeActivityExecution request opts in via include_last_failure. handle.describe() - // has no way to request it yet (sdk-java PR #3013 adds DescribeActivityOptions), so the - // request is issued directly here and wrapped in the same description type the handle - // would return, keeping the failure-conversion assertions below intact. - DescribeActivityExecutionResponse raw = - testWorkflowRule - .getWorkflowServiceStubs() - .blockingStub() - .describeActivityExecution( - DescribeActivityExecutionRequest.newBuilder() - .setNamespace(SDKTestWorkflowRule.NAMESPACE) - .setActivityId(handle.getActivityId()) - .setIncludeLastFailure(true) - .build()); ActivityExecutionDescription desc = - new ActivityExecutionDescription( - raw.getInfo(), - DefaultDataConverter.STANDARD_INSTANCE, - SDKTestWorkflowRule.NAMESPACE); + handle.describe( + DescribeActivityOptions.newBuilder().setIncludeLastFailure(true).build()); Exception lastFailure = desc.getLastFailure(); assertNotNull("last_failure should be set after a failed attempt", lastFailure); assertThat(lastFailure, instanceOf(ApplicationFailure.class)); diff --git a/temporal-sdk/src/test/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBaseTest.java b/temporal-sdk/src/test/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBaseTest.java index e3cc99b3a1..400bb2ce9a 100644 --- a/temporal-sdk/src/test/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBaseTest.java +++ b/temporal-sdk/src/test/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBaseTest.java @@ -6,6 +6,7 @@ import io.temporal.client.ActivityExecutionCount; import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.ActivityExecutionMetadata; +import io.temporal.client.DescribeActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor.*; import java.time.Duration; @@ -89,7 +90,8 @@ public void testDescribeActivityDelegatesToNext() { DescribeActivityOutput output = new DescribeActivityOutput(desc); when(next.describeActivity(any(DescribeActivityInput.class))).thenReturn(output); - DescribeActivityInput input = new DescribeActivityInput("id", null); + DescribeActivityInput input = + new DescribeActivityInput("id", null, DescribeActivityOptions.getDefaultInstance()); DescribeActivityOutput result = base.describeActivity(input); assertSame(output, result); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java new file mode 100644 index 0000000000..4f6b295bb2 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java @@ -0,0 +1,205 @@ +package io.temporal.internal.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; +import io.temporal.api.workflowservice.v1.UnpauseActivityExecutionRequest; +import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsRequest; +import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsResponse; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityOptionsUpdate; +import io.temporal.client.PauseActivityOptions; +import io.temporal.client.UnpauseActivityOptions; +import io.temporal.client.UntypedActivityHandle; +import io.temporal.internal.client.external.GenericWorkflowClient; +import java.time.Duration; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +/** Unit test for the operator-command request fields that the server does not surface back. */ +public class ActivityHandleOperatorCommandsTest { + + private final GenericWorkflowClient genericClient = mock(GenericWorkflowClient.class); + + private final ActivityClientOptions clientOptions = + ActivityClientOptions.newBuilder() + .setNamespace("test-namespace") + .setIdentity("test-identity") + .build(); + + private UntypedActivityHandle newHandle() { + return new ActivityHandleImpl( + "act-1", "run-1", new RootActivityClientInvoker(genericClient, clientOptions)); + } + + @Test + public void unobservableRequestFields() { + // updateActivityOptions returns a non-void response; stub so handle.updateOptions doesn't NPE. + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + UntypedActivityHandle handle = newHandle(); + + handle.pause(PauseActivityOptions.newBuilder().setReason("because").build()); + handle.unpause( + UnpauseActivityOptions.newBuilder() + .setReason("go") + .setJitter(Duration.ofSeconds(5)) + .build()); + handle.updateOptions(ActivityOptionsUpdate.START_DELAY.set(Duration.ofSeconds(7))); + + // pause carries the reason, which is not returned by describe. + PauseActivityExecutionRequest pauseReq = capturePause(); + assertEquals("because", pauseReq.getReason()); + + // unpause carries the reason and jitter. + UnpauseActivityExecutionRequest unpauseReq = captureUnpause(); + assertEquals("go", unpauseReq.getReason()); + assertEquals(5, unpauseReq.getJitter().getSeconds()); + assertEquals(0, unpauseReq.getJitter().getNanos()); + + // updateOptions carries start_delay in activity_options with a matching update_mask path. + UpdateActivityExecutionOptionsRequest updateReq = captureUpdate(); + assertEquals(7, updateReq.getActivityOptions().getStartDelay().getSeconds()); + assertEquals(0, updateReq.getActivityOptions().getStartDelay().getNanos()); + assertTrue( + "update_mask should include start_delay", + updateReq.getUpdateMask().getPathsList().contains("start_delay")); + } + + private PauseActivityExecutionRequest capturePause() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(PauseActivityExecutionRequest.class); + verify(genericClient).pauseActivity(captor.capture()); + return captor.getValue(); + } + + private UnpauseActivityExecutionRequest captureUnpause() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(UnpauseActivityExecutionRequest.class); + verify(genericClient).unpauseActivity(captor.capture()); + return captor.getValue(); + } + + /** + * An update naming no options would send an empty mask and silently change nothing, so it is + * rejected before the round trip. Reverting options is {@link + * UntypedActivityHandle#restoreOriginalOptions()}, which the server does not allow to be combined + * with individual changes. + */ + @Test + public void updateOptionsRequiresAtLeastOneOption() { + UntypedActivityHandle handle = newHandle(); + + IllegalArgumentException e = + assertThrows(IllegalArgumentException.class, () -> handle.updateOptions()); + assertTrue(e.getMessage().contains("at least one option")); + + verifyNoInteractions(genericClient); + } + + /** + * ValueSet of a zero duration is an explicit zero, not a clear: the path is named in the mask and + * the field is present holding zero. The server normalizes a zero timeout to unset, but that is + * the server's decision, not something the SDK decides on the caller's behalf. + */ + @Test + public void valueSetOfZeroSendsAnExplicitZero() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle().updateOptions(ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.set(Duration.ZERO)); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + assertEquals( + java.util.Collections.singleton("heartbeat_timeout"), + new java.util.HashSet<>(req.getUpdateMask().getPathsList())); + assertTrue( + "a zero value is present, not absent", req.getActivityOptions().hasHeartbeatTimeout()); + assertEquals(0, req.getActivityOptions().getHeartbeatTimeout().getSeconds()); + assertEquals(0, req.getActivityOptions().getHeartbeatTimeout().getNanos()); + } + + /** + * ValueUnset names the path but leaves the field absent, which is how the server is told to clear + * the option rather than set it to a value. + */ + @Test + public void valueUnsetNamesThePathButLeavesTheFieldAbsent() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle().updateOptions(ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.unset()); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + assertEquals( + java.util.Collections.singleton("heartbeat_timeout"), + new java.util.HashSet<>(req.getUpdateMask().getPathsList())); + assertFalse( + "an unset value is absent, not zero", req.getActivityOptions().hasHeartbeatTimeout()); + } + + @Test + public void omittedJitterIsLeftOffTheWire() { + UntypedActivityHandle handle = newHandle(); + + handle.unpause(UnpauseActivityOptions.newBuilder().build()); + + assertFalse("unpause should not send jitter", captureUnpause().hasJitter()); + } + + /** A repeated key resolves to its last update: a later valueUnset overrides an earlier set. */ + @Test + public void aRepeatedKeyResolvesToItsLastUpdate() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle() + .updateOptions( + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.set(Duration.ofSeconds(5)), + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.unset()); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + // The later unset wins, and the path is named once. + assertEquals( + java.util.Collections.singleton("heartbeat_timeout"), + new java.util.HashSet<>(req.getUpdateMask().getPathsList())); + assertFalse(req.getActivityOptions().hasHeartbeatTimeout()); + } + + /** The mask names exactly the options that were updated, and nothing else. */ + @Test + public void maskNamesOnlyTheChangedOptions() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle() + .updateOptions( + ActivityOptionsUpdate.TASK_QUEUE.set("new-tq"), + ActivityOptionsUpdate.START_TO_CLOSE_TIMEOUT.set(Duration.ofSeconds(90))); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + assertEquals( + new java.util.HashSet<>( + java.util.Arrays.asList("task_queue.name", "start_to_close_timeout")), + new java.util.HashSet<>(req.getUpdateMask().getPathsList())); + assertFalse(req.getRestoreOriginal()); + assertEquals("new-tq", req.getActivityOptions().getTaskQueue().getName()); + assertEquals(90, req.getActivityOptions().getStartToCloseTimeout().getSeconds()); + } + + private UpdateActivityExecutionOptionsRequest captureUpdate() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(UpdateActivityExecutionOptionsRequest.class); + verify(genericClient).updateActivityOptions(captor.capture()); + return captor.getValue(); + } +} From 895a65a29438f44ef760f61c483feb2b45813773 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Thu, 10 Sep 2026 13:29:48 -0400 Subject: [PATCH 091/107] External Storage Integration: WorkflowWorker, replay, history (#3017) * feature(extstore): integrate into workflow worker pipeline, including replay handler. * feat(extstore): make sure sticky cache miss path also retrieves external payloads after fetching history. * refactor(extstore): refactor the way we derive storage targets by using command.getAttributesCase() + switch for exhaustiveness checking. * Explicitly pass cancellation tokens for external storage methods. * more cancellation token threading * externalStorage -> externalStorageRunner * fix(extstore): return workflow task failures properly when extstore fails. * fix(extstore): don't fail tasks when extstore fails more than once. * fix(extstore): add error for worker shutdown and handle extstore cancellation gracefully during shutdown * fix(extstore): correctly target SCHEDULE_ACTIVITY_TASK_COMMAND_ATTRIBUTES * fix(extstore): COMPLETE_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES should retarget to parent for storage target. * fix(extstore): run extstore inside the try of replay handler. * fix replay tests * add more tests * consolidate test storage drivers. * if the worker is shutting down and extstore retrieve is canceled, don't fail * refactor(extstore): centralize cancellation token in the options instead of threading it through and cancel extstore AFTER the poller shutdown flag is set (was previously before). * only ignore extstore cancelation errors if a shutdown was requested * resolve conflicts in main and add some tests. * fix(tests): bump 1s timeouts to 5s. * test(extstore): add e2e workflow worker test. * test(exstore): broaden test timeouts again. --- .../replay/ReplayWorkflowRunTaskHandler.java | 4 +- .../replay/ReplayWorkflowTaskHandler.java | 35 +- .../ServiceWorkflowHistoryIterator.java | 26 +- .../internal/replay/WorkflowTaskResult.java | 37 +- .../temporal/internal/worker/BasePoller.java | 3 + .../internal/worker/SingleWorkerOptions.java | 23 +- .../internal/worker/SyncWorkflowWorker.java | 15 +- .../internal/worker/WorkflowTaskHandler.java | 35 + .../internal/worker/WorkflowWorker.java | 310 ++++++-- .../client/functional/StartDelayTest.java | 6 +- .../ExternalStorageDataConverterTest.java | 81 +- ...ExternalStoragePayloadTransformerTest.java | 68 +- .../storage/ExternalStorageRunnerTest.java | 167 +---- .../payload/storage/TestStorageDriver.java | 247 +++++++ ...orkflowRunTaskHandlerTaskHandlerTests.java | 237 ++++++ .../ServiceWorkflowHistoryIteratorTest.java | 98 +++ .../internal/worker/AsyncPollerTest.java | 8 +- ...kflowWorkerExternalStorageFailureTest.java | 120 +++ .../WorkflowWorkerExternalStorageTest.java | 113 +++ .../internal/worker/WorkflowWorkerTest.java | 689 ++++++++++++++++++ ...ocalActivityWorkflowTaskHeartbeatTest.java | 2 +- 21 files changed, 1993 insertions(+), 331 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/payload/storage/TestStorageDriver.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageFailureTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandler.java index 1d0d09a692..3b0894d5d9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandler.java @@ -175,7 +175,9 @@ public WorkflowTaskResult handleWorkflowTask( .setForceWorkflowTask( localActivityTaskCount > 0 && !context.isWorkflowMethodCompleted()) .setNonfirstLocalActivityAttempts(localActivityMeteringHelper.getNonfirstAttempts()) - .setSdkFlags(newSdkFlags); + .setSdkFlags(newSdkFlags) + .setParentWorkflowExecution(context.getParentWorkflowExecution()) + .setContinuedAsNew(context.getContinuedExecutionRunId().isPresent()); if (workflowStateMachines.sdkNameToWrite() != null) { result.setWriteSdkName(workflowStateMachines.sdkNameToWrite()); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowTaskHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowTaskHandler.java index f5b7cb0d29..a4800a7f7f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowTaskHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowTaskHandler.java @@ -23,6 +23,7 @@ import io.temporal.common.converter.DataConverter; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.WorkflowExecutionUtils; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.worker.*; import io.temporal.payload.context.WorkflowSerializationContext; import io.temporal.serviceclient.MetricsTag; @@ -34,6 +35,7 @@ import java.time.Duration; import java.util.List; import java.util.Objects; +import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -89,12 +91,19 @@ private Result handleWorkflowTaskWithQuery( boolean useCache = stickyTaskQueue != null; try { + workflowTask = retrieveStoredPayloads(workflowTask); workflowRunTaskHandler = getOrCreateWorkflowExecutor(useCache, workflowTask, metricsScope, createdNew); logWorkflowTaskToBeProcessed(workflowTask, createdNew); ServiceWorkflowHistoryIterator historyIterator = - new ServiceWorkflowHistoryIterator(service, namespace, workflowTask, metricsScope); + new ServiceWorkflowHistoryIterator( + service, + namespace, + workflowTask, + metricsScope, + options.getExternalStorageRunner(), + options.getStorageCancellation()); boolean finalCommand; Result result; @@ -132,7 +141,7 @@ private Result handleWorkflowTaskWithQuery( } return result; - } catch (InterruptedException e) { + } catch (InterruptedException | CancellationException e) { throw e; } catch (Throwable e) { // Note here that the executor might not be in the cache, even when the caching is on. In that @@ -170,6 +179,18 @@ private Result handleWorkflowTaskWithQuery( } } + private PollWorkflowTaskQueueResponse.Builder retrieveStoredPayloads( + PollWorkflowTaskQueueResponse.Builder workflowTask) { + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + if (externalStorageRunner == null) { + ExternalStorageRunner.throwIfContainsReference(workflowTask.build()); + return workflowTask; + } + return externalStorageRunner + .retrieve(workflowTask.build(), options.getStorageCancellation()) + .toBuilder(); + } + private Result createCompletedWFTRequest( String workflowType, PollWorkflowTaskQueueResponseOrBuilder workflowTask, @@ -253,7 +274,8 @@ private Result createCompletedWFTRequest( null, result.isFinalCommand(), eventIdSetHandle, - result.getApplyPostCompletionMetrics()); + result.getApplyPostCompletionMetrics(), + result.isContinuedAsNew() ? null : result.getParentWorkflowExecution()); } private Result failureToWFTResult( @@ -395,6 +417,13 @@ private WorkflowRunTaskHandler createStatefulHandler( .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) .getWorkflowExecutionHistory(getHistoryRequest); + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + if (externalStorageRunner == null) { + ExternalStorageRunner.throwIfContainsReference(getHistoryResponse); + } else { + getHistoryResponse = + externalStorageRunner.retrieve(getHistoryResponse, options.getStorageCancellation()); + } workflowTask .setHistory(getHistoryResponse.getHistory()) .setNextPageToken(getHistoryResponse.getNextPageToken()); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/ServiceWorkflowHistoryIterator.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/ServiceWorkflowHistoryIterator.java index 229b66186e..8c9974a5ef 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/ServiceWorkflowHistoryIterator.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/ServiceWorkflowHistoryIterator.java @@ -12,12 +12,16 @@ import io.temporal.api.workflowservice.v1.GetWorkflowExecutionHistoryRequest; import io.temporal.api.workflowservice.v1.GetWorkflowExecutionHistoryResponse; import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponseOrBuilder; +import io.temporal.common.CancellationToken; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.retryer.GrpcRetryer; import io.temporal.serviceclient.RpcRetryOptions; import io.temporal.serviceclient.WorkflowServiceStubs; import java.time.Duration; import java.util.Iterator; import java.util.NoSuchElementException; +import java.util.concurrent.CancellationException; +import javax.annotation.Nullable; /** Supports iteration over history while loading new pages through calls to the service. */ class ServiceWorkflowHistoryIterator implements WorkflowHistoryIterator { @@ -29,6 +33,8 @@ class ServiceWorkflowHistoryIterator implements WorkflowHistoryIterator { private final Scope metricsScope; private final PollWorkflowTaskQueueResponseOrBuilder task; private final GrpcRetryer grpcRetryer; + private final @Nullable ExternalStorageRunner externalStorageRunner; + private final CancellationToken storageCancellation; private Deadline deadline; private Iterator current; ByteString nextPageToken; @@ -38,10 +44,22 @@ class ServiceWorkflowHistoryIterator implements WorkflowHistoryIterator { String namespace, PollWorkflowTaskQueueResponseOrBuilder task, Scope metricsScope) { + this(service, namespace, task, metricsScope, null, CancellationToken.none()); + } + + ServiceWorkflowHistoryIterator( + WorkflowServiceStubs service, + String namespace, + PollWorkflowTaskQueueResponseOrBuilder task, + Scope metricsScope, + @Nullable ExternalStorageRunner externalStorageRunner, + CancellationToken storageCancellation) { + this.storageCancellation = storageCancellation; this.service = service; this.namespace = namespace; this.task = task; this.metricsScope = metricsScope; + this.externalStorageRunner = externalStorageRunner; // TODO Refactor WorkflowHistoryIteratorTest or WorkflowHistoryIterator to remove this check. // `service == null` shouldn't be allowed as it's needed for a normal functioning of this // class. @@ -64,7 +82,13 @@ public boolean hasNext() { // true. GetWorkflowExecutionHistoryResponse response = queryWorkflowExecutionHistory(); - current = response.getHistory().getEventsList().iterator(); + History history = response.getHistory(); + if (externalStorageRunner == null) { + ExternalStorageRunner.throwIfContainsReference(history); + } else { + history = externalStorageRunner.retrieve(history, storageCancellation); + } + current = history.getEventsList().iterator(); nextPageToken = response.getNextPageToken(); // Server can return an empty page, but a valid nextPageToken that contains // more events. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/WorkflowTaskResult.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/WorkflowTaskResult.java index 9433b99f93..db740c8d33 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/WorkflowTaskResult.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/WorkflowTaskResult.java @@ -1,12 +1,14 @@ package io.temporal.internal.replay; import io.temporal.api.command.v1.Command; +import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.protocol.v1.Message; import io.temporal.api.query.v1.WorkflowQueryResult; import io.temporal.common.VersioningBehavior; import java.util.Collections; import java.util.List; import java.util.Map; +import javax.annotation.Nullable; public final class WorkflowTaskResult { @@ -26,6 +28,8 @@ public static final class Builder { private String writeSdkVersion; private VersioningBehavior versioningBehavior; private Runnable applyPostCompletionMetrics; + private @Nullable WorkflowExecution parentWorkflowExecution; + private boolean continuedAsNew; public Builder setCommands(List commands) { this.commands = commands; @@ -77,6 +81,16 @@ public Builder setVersioningBehavior(VersioningBehavior versioningBehavior) { return this; } + public Builder setParentWorkflowExecution(@Nullable WorkflowExecution parentWorkflowExecution) { + this.parentWorkflowExecution = parentWorkflowExecution; + return this; + } + + public Builder setContinuedAsNew(boolean continuedAsNew) { + this.continuedAsNew = continuedAsNew; + return this; + } + public Builder setApplyPostCompletionMetrics(Runnable applyPostCompletionMetrics) { this.applyPostCompletionMetrics = applyPostCompletionMetrics; return this; @@ -94,7 +108,9 @@ public WorkflowTaskResult build() { writeSdkName, writeSdkVersion, versioningBehavior == null ? VersioningBehavior.UNSPECIFIED : versioningBehavior, - applyPostCompletionMetrics); + applyPostCompletionMetrics, + parentWorkflowExecution, + continuedAsNew); } } @@ -109,6 +125,8 @@ public WorkflowTaskResult build() { private final String writeSdkVersion; private final VersioningBehavior versioningBehavior; private final Runnable applyPostCompletionMetrics; + private final @Nullable WorkflowExecution parentWorkflowExecution; + private final boolean continuedAsNew; private WorkflowTaskResult( List commands, @@ -121,7 +139,9 @@ private WorkflowTaskResult( String writeSdkName, String writeSdkVersion, VersioningBehavior versioningBehavior, - Runnable applyPostCompletionMetrics) { + Runnable applyPostCompletionMetrics, + @Nullable WorkflowExecution parentWorkflowExecution, + boolean continuedAsNew) { this.commands = commands; this.messages = messages; this.nonfirstLocalActivityAttempts = nonfirstLocalActivityAttempts; @@ -136,6 +156,19 @@ private WorkflowTaskResult( this.writeSdkVersion = writeSdkVersion; this.versioningBehavior = versioningBehavior; this.applyPostCompletionMetrics = applyPostCompletionMetrics; + this.parentWorkflowExecution = parentWorkflowExecution; + this.continuedAsNew = continuedAsNew; + } + + /** The workflow that started this one as a child, or {@code null} if it has no parent. */ + @Nullable + public WorkflowExecution getParentWorkflowExecution() { + return parentWorkflowExecution; + } + + /** Whether this run was created by a continue-as-new rather than started directly. */ + public boolean isContinuedAsNew() { + return continuedAsNew; } public List getCommands() { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java index a8a77d680f..5c91e79619 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/BasePoller.java @@ -9,6 +9,7 @@ import java.time.Duration; import java.util.Objects; import java.util.concurrent.*; +import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -176,6 +177,8 @@ static boolean shouldIgnoreDuringShutdown(Throwable ex) { ex instanceof RejectedExecutionException // if the worker thread gets InterruptedException - it's normal during shutdown || ex instanceof InterruptedException + || ex instanceof CancellationException + || ex.getCause() instanceof CancellationException // if we get wrapped InterruptedException like what PollTask or GRPC clients do with // setting Thread.interrupted() on - it's normal during shutdown too. See PollTask // javadoc. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java index f6692e2144..eaa14ade12 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SingleWorkerOptions.java @@ -3,6 +3,7 @@ import com.uber.m3.tally.NoopScope; import com.uber.m3.tally.Scope; import io.temporal.api.common.v1.WorkerVersionStamp; +import io.temporal.common.CancellationToken; import io.temporal.common.context.ContextPropagator; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.GlobalDataConverter; @@ -12,6 +13,7 @@ import io.temporal.worker.WorkerDeploymentOptions; import java.time.Duration; import java.util.List; +import java.util.concurrent.CancellationException; import javax.annotation.Nullable; public final class SingleWorkerOptions { @@ -48,6 +50,7 @@ public static final class Builder { private String workerControlTaskQueue; private PreferredVersionProvider preferredVersionProvider; private @Nullable ExternalStorageRunner externalStorageRunner; + private CancellationToken storageCancellation = CancellationToken.none(); private Builder() {} @@ -77,6 +80,7 @@ private Builder(SingleWorkerOptions options) { this.workerControlTaskQueue = options.getWorkerControlTaskQueue(); this.preferredVersionProvider = options.getPreferredVersionProvider(); this.externalStorageRunner = options.getExternalStorageRunner(); + this.storageCancellation = options.getStorageCancellation(); } public Builder setIdentity(String identity) { @@ -189,6 +193,13 @@ public Builder setPreferredVersionProvider(PreferredVersionProvider preferredVer return this; } + /** Cancelled when this worker stops, to abandon its in-flight external storage work. */ + public Builder setStorageCancellation( + CancellationToken storageCancellation) { + this.storageCancellation = storageCancellation; + return this; + } + public Builder setExternalStorageRunner(@Nullable ExternalStorageRunner externalStorageRunner) { this.externalStorageRunner = externalStorageRunner; return this; @@ -237,7 +248,8 @@ public SingleWorkerOptions build() { this.allowActivityHeartbeatDuringShutdown, this.workerControlTaskQueue, this.preferredVersionProvider, - this.externalStorageRunner); + this.externalStorageRunner, + this.storageCancellation); } } @@ -263,6 +275,7 @@ public SingleWorkerOptions build() { private final String workerControlTaskQueue; private final PreferredVersionProvider preferredVersionProvider; private final @Nullable ExternalStorageRunner externalStorageRunner; + private final CancellationToken storageCancellation; private SingleWorkerOptions( String identity, @@ -286,7 +299,8 @@ private SingleWorkerOptions( boolean allowActivityHeartbeatDuringShutdown, String workerControlTaskQueue, PreferredVersionProvider preferredVersionProvider, - @Nullable ExternalStorageRunner externalStorageRunner) { + @Nullable ExternalStorageRunner externalStorageRunner, + CancellationToken storageCancellation) { this.identity = identity; this.binaryChecksum = binaryChecksum; this.buildId = buildId; @@ -309,6 +323,7 @@ private SingleWorkerOptions( this.workerControlTaskQueue = workerControlTaskQueue; this.preferredVersionProvider = preferredVersionProvider; this.externalStorageRunner = externalStorageRunner; + this.storageCancellation = storageCancellation; } public String getIdentity() { @@ -414,6 +429,10 @@ public ExternalStorageRunner getExternalStorageRunner() { return externalStorageRunner; } + public CancellationToken getStorageCancellation() { + return storageCancellation; + } + public WorkerVersioningOptions getWorkerVersioningOptions() { return new WorkerVersioningOptions( this.getBuildId(), this.isUsingBuildIdForVersioning(), this.getDeploymentOptions()); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java index be128a5e62..c86ff4ad77 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncWorkflowWorker.java @@ -10,6 +10,7 @@ import io.temporal.internal.activity.ActivityExecutionContextFactory; import io.temporal.internal.activity.ActivityTaskHandlerImpl; import io.temporal.internal.activity.LocalActivityExecutionContextFactoryImpl; +import io.temporal.internal.concurrent.structured.CancelSource; import io.temporal.internal.replay.ReplayWorkflowTaskHandler; import io.temporal.internal.sync.POJOWorkflowImplementationFactory; import io.temporal.internal.sync.WorkflowThreadExecutor; @@ -54,6 +55,8 @@ public class SyncWorkflowWorker implements SuspendableWorker { private final POJOWorkflowImplementationFactory factory; private final DataConverter dataConverter; private final ActivityTaskHandlerImpl laTaskHandler; + private final CancelSource storageCancellation = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); private boolean runningLocalActivityWorker; public SyncWorkflowWorker( @@ -71,6 +74,10 @@ public SyncWorkflowWorker( @Nonnull SlotSupplier slotSupplier, @Nonnull SlotSupplier laSlotSupplier, @Nonnull NamespaceCapabilities namespaceCapabilities) { + singleWorkerOptions = + SingleWorkerOptions.newBuilder(singleWorkerOptions) + .setStorageCancellation(storageCancellation.token()) + .build(); this.identity = singleWorkerOptions.getIdentity(); this.namespace = namespace; this.taskQueue = taskQueue; @@ -175,8 +182,12 @@ public boolean start() { @Override public CompletableFuture shutdown(ShutdownManager shutdownManager, boolean interruptTasks) { - return workflowWorker - .shutdown(shutdownManager, interruptTasks) + CompletableFuture workflowWorkerShutdown = + workflowWorker.shutdown(shutdownManager, interruptTasks); + if (interruptTasks) { + storageCancellation.cancel(); + } + return workflowWorkerShutdown .thenCompose(ignore -> laWorker.shutdown(shutdownManager, interruptTasks)) .exceptionally( e -> { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskHandler.java index 129847fffc..3f4a95959c 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowTaskHandler.java @@ -1,11 +1,13 @@ package io.temporal.internal.worker; +import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse; import io.temporal.api.workflowservice.v1.RespondQueryTaskCompletedRequest; import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; import io.temporal.api.workflowservice.v1.RespondWorkflowTaskFailedRequest; import io.temporal.serviceclient.RpcRetryOptions; import io.temporal.workflow.Functions; +import javax.annotation.Nullable; /** * Interface of workflow task handlers. @@ -23,6 +25,7 @@ final class Result { private final boolean completionCommand; private final Functions.Proc1 resetEventIdHandle; private final Runnable applyPostCompletionMetrics; + private final @Nullable WorkflowExecution completionParentExecution; public Result( String workflowType, @@ -33,6 +36,29 @@ public Result( boolean completionCommand, Functions.Proc1 resetEventIdHandle, Runnable applyPostCompletionMetrics) { + this( + workflowType, + taskCompleted, + taskFailed, + queryCompleted, + requestRetryOptions, + completionCommand, + resetEventIdHandle, + applyPostCompletionMetrics, + null); + } + + public Result( + String workflowType, + RespondWorkflowTaskCompletedRequest taskCompleted, + RespondWorkflowTaskFailedRequest taskFailed, + RespondQueryTaskCompletedRequest queryCompleted, + RpcRetryOptions requestRetryOptions, + boolean completionCommand, + Functions.Proc1 resetEventIdHandle, + Runnable applyPostCompletionMetrics, + @Nullable WorkflowExecution completionParentExecution) { + this.completionParentExecution = completionParentExecution; this.workflowType = workflowType; this.taskCompleted = taskCompleted; this.taskFailed = taskFailed; @@ -43,6 +69,15 @@ public Result( this.applyPostCompletionMetrics = applyPostCompletionMetrics; } + /** + * The workflow to attribute this workflow's own result to, or {@code null} to attribute it to + * the workflow itself. + */ + @Nullable + public WorkflowExecution getCompletionParentExecution() { + return completionParentExecution; + } + public RespondWorkflowTaskCompletedRequest getTaskCompleted() { return taskCompleted; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java index 1da77d85eb..9961eb5d5d 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/WorkflowWorker.java @@ -6,12 +6,13 @@ import com.google.common.base.Preconditions; import com.google.common.base.Strings; import com.google.protobuf.ByteString; +import com.google.protobuf.MessageOrBuilder; import com.uber.m3.tally.Scope; import com.uber.m3.tally.Stopwatch; import com.uber.m3.util.ImmutableMap; import io.grpc.Status; import io.grpc.StatusRuntimeException; -import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.*; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.enums.v1.QueryResultType; import io.temporal.api.enums.v1.TaskQueueKind; @@ -21,9 +22,13 @@ import io.temporal.api.workflowservice.v1.*; import io.temporal.failure.ApplicationFailure; import io.temporal.internal.logging.LoggerTag; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.internal.payload.visitor.MessageVisitor; import io.temporal.internal.retryer.GrpcMessageTooLargeException; import io.temporal.internal.retryer.GrpcRetryer; import io.temporal.payload.context.WorkflowSerializationContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.RpcRetryOptions; import io.temporal.serviceclient.StatusUtils; @@ -31,6 +36,7 @@ import io.temporal.worker.*; import io.temporal.worker.tuning.*; import java.util.*; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; @@ -392,6 +398,118 @@ public String toString() { options.getIdentity(), namespace, taskQueue); } + private void storeOutboundPayloads( + com.google.protobuf.Message.Builder builder, @Nullable StorageDriverTargetInfo target) { + storeOutboundPayloads(builder, target, null); + } + + private void storeOutboundPayloads( + com.google.protobuf.Message.Builder builder, + @Nullable StorageDriverTargetInfo target, + @Nullable MessageVisitor targetVisitor) { + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + if (externalStorageRunner == null) { + return; + } + try { + externalStorageRunner.store(builder, target, targetVisitor, options.getStorageCancellation()); + } catch (CancellationException e) { + // if the worker is shutting down, extstore will throw a CancellationException and we need to + // rethrow it here so the handle() method can decide what to do. + throw e; + } catch (Exception e) { + throw new ExternalStorageTaskFailure("External storage store failed", e); + } + } + + private static final class ExternalStorageTaskFailure extends RuntimeException { + ExternalStorageTaskFailure(String message, Throwable cause) { + super(message, cause); + } + } + + @Nullable + private StorageDriverTargetInfo parentStorageTarget(@Nullable WorkflowExecution parent) { + if (parent == null || options.getExternalStorageRunner() == null) { + return null; + } + return new StorageDriverWorkflowInfo( + namespace, + Strings.emptyToNull(parent.getWorkflowId()), + Strings.emptyToNull(parent.getRunId()), + null); + } + + @Nullable + private StorageDriverTargetInfo workflowStorageTarget( + WorkflowExecution execution, String workflowType) { + if (options.getExternalStorageRunner() == null) { + return null; + } + return new StorageDriverWorkflowInfo( + namespace, execution.getWorkflowId(), execution.getRunId(), workflowType); + } + + static StorageDriverTargetInfo deriveStorageTarget( + String namespace, StorageDriverTargetInfo current, MessageOrBuilder message) { + return deriveStorageTarget(namespace, current, message, null); + } + + static StorageDriverTargetInfo deriveStorageTarget( + String namespace, + StorageDriverTargetInfo current, + MessageOrBuilder message, + @Nullable StorageDriverTargetInfo completionTarget) { + if (!(message instanceof CommandOrBuilder)) { + return current; + } + CommandOrBuilder command = (CommandOrBuilder) message; + // Keep this exhaustive so new command attributes require an explicit target decision. + switch (command.getAttributesCase()) { + case START_CHILD_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + StartChildWorkflowExecutionCommandAttributesOrBuilder child = + command.getStartChildWorkflowExecutionCommandAttributesOrBuilder(); + return new StorageDriverWorkflowInfo( + namespace, child.getWorkflowId(), null, child.getWorkflowType().getName()); + case SIGNAL_EXTERNAL_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + WorkflowExecution execution = + command.getSignalExternalWorkflowExecutionCommandAttributes().getExecution(); + return new StorageDriverWorkflowInfo( + namespace, execution.getWorkflowId(), execution.getRunId(), null); + case CONTINUE_AS_NEW_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + if (current instanceof StorageDriverWorkflowInfo) { + ContinueAsNewWorkflowExecutionCommandAttributesOrBuilder continueAsNew = + command.getContinueAsNewWorkflowExecutionCommandAttributesOrBuilder(); + StorageDriverWorkflowInfo currentWorkflow = (StorageDriverWorkflowInfo) current; + String workflowType = continueAsNew.getWorkflowType().getName(); + return new StorageDriverWorkflowInfo( + namespace, + currentWorkflow.getId(), + null, + Strings.isNullOrEmpty(workflowType) ? currentWorkflow.getType() : workflowType); + } + return current; + case COMPLETE_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + return completionTarget != null ? completionTarget : current; + case SCHEDULE_ACTIVITY_TASK_COMMAND_ATTRIBUTES: + case ATTRIBUTES_NOT_SET: + case START_TIMER_COMMAND_ATTRIBUTES: + case FAIL_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + case REQUEST_CANCEL_ACTIVITY_TASK_COMMAND_ATTRIBUTES: + case CANCEL_TIMER_COMMAND_ATTRIBUTES: + case CANCEL_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + case REQUEST_CANCEL_EXTERNAL_WORKFLOW_EXECUTION_COMMAND_ATTRIBUTES: + case RECORD_MARKER_COMMAND_ATTRIBUTES: + case UPSERT_WORKFLOW_SEARCH_ATTRIBUTES_COMMAND_ATTRIBUTES: + case PROTOCOL_MESSAGE_COMMAND_ATTRIBUTES: + case MODIFY_WORKFLOW_PROPERTIES_COMMAND_ATTRIBUTES: + case SCHEDULE_NEXUS_OPERATION_COMMAND_ATTRIBUTES: + case REQUEST_CANCEL_NEXUS_OPERATION_COMMAND_ATTRIBUTES: + return current; + } + throw new IllegalStateException("Unhandled command attributes: " + command.getAttributesCase()); + } + private class TaskHandlerImpl implements PollTaskExecutor.TaskHandler { final WorkflowTaskHandler handler; @@ -464,7 +582,26 @@ public void handle(WorkflowTask task) throws Exception { if (queryCompleted != null) { try { sendDirectQueryCompletedResponse( - currentTask.getTaskToken(), queryCompleted.toBuilder(), workflowTypeScope); + currentTask.getTaskToken(), + queryCompleted.toBuilder(), + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); + } catch (ExternalStorageTaskFailure e) { + Failure failure = + storageFailure( + workflowExecution.getWorkflowId(), e, "Failed to send query response"); + RespondQueryTaskCompletedRequest.Builder queryFailedBuilder = + RespondQueryTaskCompletedRequest.newBuilder() + .setTaskToken(currentTask.getTaskToken()) + .setNamespace(namespace) + .setCompletedType(QueryResultType.QUERY_RESULT_TYPE_FAILED) + .setErrorMessage(failure.getMessage()) + .setFailure(failure); + sendDirectQueryCompletedResponse( + currentTask.getTaskToken(), + queryFailedBuilder, + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); } catch (StatusRuntimeException e) { GrpcMessageTooLargeException tooLargeException = GrpcMessageTooLargeException.tryWrap(e); @@ -484,56 +621,62 @@ public void handle(WorkflowTask task) throws Exception { .setErrorMessage(failure.getMessage()) .setFailure(failure); sendDirectQueryCompletedResponse( - currentTask.getTaskToken(), queryFailedBuilder, workflowTypeScope); + currentTask.getTaskToken(), + queryFailedBuilder, + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); } } else { try { - WorkflowTaskFailedCause requestTooLargeCause = - taskCompleted == null - ? null - : completionExceedingSizeLimitCause(taskCompleted); - if (requestTooLargeCause != null) { - // A completion whose recombined command bytes exceed the namespace limit would - // be - // rejected and the workflow terminated by the server, so fail it proactively - // rather than sending doomed pages. - taskFailedCause = requestTooLargeCause; - RespondWorkflowTaskFailedRequest.Builder taskFailedBuilder = - RespondWorkflowTaskFailedRequest.newBuilder() - .setFailure( - requestTooLargeFailure( - workflowExecution.getWorkflowId(), taskCompleted)) - .setCause(requestTooLargeCause); - sendTaskFailed( - currentTask.getTaskToken(), - taskFailedBuilder, - result.getRequestRetryOptions(), - workflowTypeScope); - } else if (taskCompleted != null) { + if (taskCompleted != null) { RespondWorkflowTaskCompletedRequest.Builder requestBuilder = taskCompleted.toBuilder(); try (EagerActivitySlotsReservation activitySlotsReservation = new EagerActivitySlotsReservation( eagerActivityDispatcher, maxEagerActivityReservationsPerWorkflowTask)) { activitySlotsReservation.applyToRequest(requestBuilder); - RespondWorkflowTaskCompletedResponse response = - sendTaskCompleted( + RespondWorkflowTaskCompletedRequest request = + prepareTaskCompleted( currentTask.getTaskToken(), requestBuilder, - result.getRequestRetryOptions(), - workflowTypeScope); - // If we were processing a speculative WFT the server may instruct us that the - // task was dropped by resting out event ID. - long resetEventId = response.getResetHistoryEventId(); - if (resetEventId != 0) { - result.getResetEventIdHandle().apply(resetEventId); + workflowStorageTarget(workflowExecution, workflowType), + parentStorageTarget(result.getCompletionParentExecution())); + WorkflowTaskFailedCause requestTooLargeCause = + completionExceedingSizeLimitCause(request); + if (requestTooLargeCause != null) { + // A completion whose recombined command bytes exceed the namespace limit + // would be rejected and the workflow terminated by the server, so fail it + // proactively rather than sending doomed pages. + taskFailedCause = requestTooLargeCause; + RespondWorkflowTaskFailedRequest.Builder taskFailedBuilder = + RespondWorkflowTaskFailedRequest.newBuilder() + .setFailure( + requestTooLargeFailure( + workflowExecution.getWorkflowId(), request)) + .setCause(requestTooLargeCause); + sendTaskFailed( + currentTask.getTaskToken(), + taskFailedBuilder, + result.getRequestRetryOptions(), + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); + } else { + RespondWorkflowTaskCompletedResponse response = + sendTaskCompleted( + request, result.getRequestRetryOptions(), workflowTypeScope); + // If we were processing a speculative WFT the server may instruct us that + // the task was dropped by resting out event ID. + long resetEventId = response.getResetHistoryEventId(); + if (resetEventId != 0) { + result.getResetEventIdHandle().apply(resetEventId); + } + nextWFTResponse = + response.hasWorkflowTask() + ? Optional.of(response.getWorkflowTask()) + : Optional.empty(); + // TODO we don't have to do this under the runId lock + activitySlotsReservation.handleResponse(response); } - nextWFTResponse = - response.hasWorkflowTask() - ? Optional.of(response.getWorkflowTask()) - : Optional.empty(); - // TODO we don't have to do this under the runId lock - activitySlotsReservation.handleResponse(response); } } else if (taskFailed != null) { taskFailedCause = taskFailed.getCause(); @@ -541,7 +684,8 @@ public void handle(WorkflowTask task) throws Exception { currentTask.getTaskToken(), taskFailed.toBuilder(), result.getRequestRetryOptions(), - workflowTypeScope); + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); } // Apply post-completion metrics only if runnable present and the above succeeded @@ -578,9 +722,41 @@ public void handle(WorkflowTask task) throws Exception { currentTask.getTaskToken(), taskFailedBuilder, result.getRequestRetryOptions(), - workflowTypeScope); + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); + } catch (ExternalStorageTaskFailure e) { + releaseReason = SlotReleaseReason.error(e); + handleReportingFailure( + e, currentTask, result, workflowExecution, workflowTypeScope); + taskFailedCause = + WorkflowTaskFailedCause + .WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE; + + String messagePrefix = + String.format( + "Failed to send workflow task %s", + taskFailed == null ? "completion" : "failure"); + RespondWorkflowTaskFailedRequest.Builder storageFailedBuilder = + RespondWorkflowTaskFailedRequest.newBuilder() + .setFailure( + storageFailure(workflowExecution.getWorkflowId(), e, messagePrefix)) + .setCause( + WorkflowTaskFailedCause + .WORKFLOW_TASK_FAILED_CAUSE_WORKFLOW_WORKER_UNHANDLED_FAILURE); + sendTaskFailed( + currentTask.getTaskToken(), + storageFailedBuilder, + result.getRequestRetryOptions(), + workflowTypeScope, + workflowStorageTarget(workflowExecution, workflowType)); } } + } catch (CancellationException e) { + if (!options.getStorageCancellation().isCancellationRequested()) { + throw e; + } + log.trace("Abandoned a workflow task while the worker was shutting down", e); + return; } catch (Exception e) { iterationFailed = true; releaseReason = SlotReleaseReason.error(e); @@ -616,6 +792,11 @@ public void handle(WorkflowTask task) throws Exception { workflowTypeScope.counter(MetricsType.WORKFLOW_TASK_HEARTBEAT_COUNTER).inc(1); } } catch (Exception e) { + if (e instanceof CancellationException + && options.getStorageCancellation().isCancellationRequested()) { + log.trace("Abandoned a workflow task while the worker was shutting down", e); + return; + } iterationFailed = true; throw e; } finally { @@ -684,11 +865,11 @@ private WorkflowTaskHandler.Result handleTask( } @SuppressWarnings("deprecation") - private RespondWorkflowTaskCompletedResponse sendTaskCompleted( + private RespondWorkflowTaskCompletedRequest prepareTaskCompleted( ByteString taskToken, RespondWorkflowTaskCompletedRequest.Builder taskCompleted, - RpcRetryOptions retryOptions, - Scope workflowTypeMetricsScope) { + @Nullable StorageDriverTargetInfo storageTarget, + @Nullable StorageDriverTargetInfo completionTarget) { taskCompleted .setIdentity(options.getIdentity()) .setNamespace(namespace) @@ -707,7 +888,16 @@ private RespondWorkflowTaskCompletedResponse sendTaskCompleted( taskCompleted.setBinaryChecksum(options.getBuildId()); } - RespondWorkflowTaskCompletedRequest request = taskCompleted.build(); + MessageVisitor storageTargetVisitor = + (current, message) -> deriveStorageTarget(namespace, current, message, completionTarget); + storeOutboundPayloads(taskCompleted, storageTarget, storageTargetVisitor); + return taskCompleted.build(); + } + + private RespondWorkflowTaskCompletedResponse sendTaskCompleted( + RespondWorkflowTaskCompletedRequest request, + RpcRetryOptions retryOptions, + Scope workflowTypeMetricsScope) { GrpcRetryer.GrpcRetryerOptions grpcRetryOptions = new GrpcRetryer.GrpcRetryerOptions( RpcRetryOptions.newBuilder().buildWithDefaultsFrom(retryOptions), null); @@ -789,7 +979,8 @@ private void sendTaskFailed( ByteString taskToken, RespondWorkflowTaskFailedRequest.Builder taskFailed, RpcRetryOptions retryOptions, - Scope workflowTypeMetricsScope) { + Scope workflowTypeMetricsScope, + @Nullable StorageDriverTargetInfo storageTarget) { GrpcRetryer.GrpcRetryerOptions grpcRetryOptions = new GrpcRetryer.GrpcRetryerOptions( RpcRetryOptions.newBuilder().buildWithDefaultsFrom(retryOptions), null); @@ -803,25 +994,30 @@ private void sendTaskFailed( taskFailed.setWorkerVersion(options.workerVersionStamp()); } + storeOutboundPayloads(taskFailed, storageTarget); + RespondWorkflowTaskFailedRequest request = taskFailed.build(); grpcRetryer.retry( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, workflowTypeMetricsScope) - .respondWorkflowTaskFailed(taskFailed.build()), + .respondWorkflowTaskFailed(request), grpcRetryOptions); } private void sendDirectQueryCompletedResponse( ByteString taskToken, RespondQueryTaskCompletedRequest.Builder queryCompleted, - Scope workflowTypeMetricsScope) { + Scope workflowTypeMetricsScope, + @Nullable StorageDriverTargetInfo storageTarget) { queryCompleted.setTaskToken(taskToken).setNamespace(namespace); + storeOutboundPayloads(queryCompleted, storageTarget); + RespondQueryTaskCompletedRequest request = queryCompleted.build(); // Do not retry query response service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, workflowTypeMetricsScope) - .respondQueryTaskCompleted(queryCompleted.build()); + .respondQueryTaskCompleted(request); } private void logExceptionDuringResultReporting( @@ -909,6 +1105,20 @@ private Failure requestTooLargeFailure( .exceptionToFailure(applicationFailure); } + private Failure storageFailure( + String workflowId, ExternalStorageTaskFailure e, String messagePrefix) { + ApplicationFailure applicationFailure = + ApplicationFailure.newBuilder() + .setMessage(messagePrefix + ": " + (e.getCause() != null ? e.getCause() : e)) + .setType(ExternalStorageTaskFailure.class.getSimpleName()) + .build(); + applicationFailure.setStackTrace(new StackTraceElement[0]); + return options + .getDataConverter() + .withContext(new WorkflowSerializationContext(namespace, workflowId)) + .exceptionToFailure(applicationFailure); + } + private Failure grpcMessageTooLargeFailure( String workflowId, GrpcMessageTooLargeException e, String messagePrefix) { ApplicationFailure applicationFailure = diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/StartDelayTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StartDelayTest.java index 4090c7c2b1..267d11462f 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StartDelayTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StartDelayTest.java @@ -1,6 +1,7 @@ package io.temporal.client.functional; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.history.v1.HistoryEvent; @@ -40,8 +41,9 @@ public void startWithDelay() { long start = System.currentTimeMillis(); stubF.func(); long end = System.currentTimeMillis(); - // Assert that the workflow took at least 5 seconds to start - assertEquals(1000, end - start, 500); + long elapsed = end - start; + assertTrue("start delay was not honored, took " + elapsed + "ms", elapsed >= 1000); + assertTrue("start delay took far longer than 1s, took " + elapsed + "ms", elapsed < 3000); WorkflowExecution workflowExecution = WorkflowStub.fromTyped(stubF).getExecution(); WorkflowExecutionHistory workflowExecutionHistory = testWorkflowRule.getWorkflowClient().fetchHistory(workflowExecution.getWorkflowId()); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java index b4fd9cc787..116880aef5 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java @@ -17,19 +17,12 @@ import io.temporal.payload.codec.PayloadCodec; import io.temporal.payload.storage.ExternalStorage; import io.temporal.payload.storage.StorageDriver; -import io.temporal.payload.storage.StorageDriverClaim; -import io.temporal.payload.storage.StorageDriverRetrieveContext; -import io.temporal.payload.storage.StorageDriverStoreContext; -import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.payload.storage.StorageDriverWorkflowInfo; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; @@ -39,7 +32,7 @@ public class ExternalStorageDataConverterTest { @Test public void payloadsRoundTripThroughStorage() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); DataConverter converter = resolving(driver, 0); Optional stored = converter.toPayloads("a", "b"); @@ -53,19 +46,19 @@ public void payloadsRoundTripThroughStorage() { @Test public void payloadsBelowThresholdStayInline() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); DataConverter converter = resolving(driver, 1024); Optional stored = converter.toPayloads("small"); assertFalse(ExternalStorageReferences.isReference(stored.get().getPayloads(0))); - assertTrue(driver.objects.isEmpty()); + assertTrue(driver.storedPayloads().isEmpty()); assertEquals("small", converter.fromPayloads(0, stored, String.class, String.class)); } @Test public void readingOneArgumentDoesNotFetchTheRest() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); DataConverter converter = resolving(driver, 0); Optional stored = converter.toPayloads("first", "second", "third"); @@ -78,7 +71,7 @@ public void readingOneArgumentDoesNotFetchTheRest() { @Test public void singlePayloadRoundTrips() { - DataConverter converter = resolving(new RecordingDriver(), 0); + DataConverter converter = resolving(TestStorageDriver.create(), 0); Optional stored = converter.toPayload("value"); @@ -88,7 +81,7 @@ public void singlePayloadRoundTrips() { @Test public void failureDetailsRoundTrip() { - DataConverter converter = resolving(new RecordingDriver(), 0); + DataConverter converter = resolving(TestStorageDriver.create(), 0); Failure failure = converter.exceptionToFailure( @@ -104,27 +97,27 @@ public void failureDetailsRoundTrip() { @Test public void storageTargetReachesTheDriver() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); StorageDriverWorkflowInfo target = new StorageDriverWorkflowInfo("ns", "wf-1", null, null); ExternalStorageDataConverter converter = new ExternalStorageDataConverter(plain, runner(driver, 0)).withStorageTarget(target); converter.toPayloads("x"); - assertEquals(target, driver.lastTarget); + assertEquals(target, driver.lastTarget()); } @Test public void withoutATargetTheDriverSeesNone() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); resolving(driver, 0).toPayloads("x"); - assertNull(driver.lastTarget); + assertNull(driver.lastTarget()); } @Test public void arrayFromPayloadsRoundTrips() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); DataConverter converter = resolving(driver, 0); Optional stored = converter.toPayloads("a", 42); @@ -141,7 +134,7 @@ public void arrayFromPayloadsRoundTrips() { @Test public void arrayFromPayloadsWithAbsentContentUsesDefaults() { - DataConverter converter = resolving(new RecordingDriver(), 0); + DataConverter converter = resolving(TestStorageDriver.create(), 0); Object[] values = converter.fromPayloads( @@ -152,7 +145,7 @@ public void arrayFromPayloadsWithAbsentContentUsesDefaults() { @Test public void arrayFromPayloadsDecodesThroughTheCodecInOneBatch() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); CountingCodec codec = new CountingCodec(); DataConverter converter = codecBacked(driver, codec); @@ -175,14 +168,14 @@ public void arrayFromPayloadsDecodesThroughTheCodecInOneBatch() { */ @Test public void driversOnlyEverSeeCodecEncodedPayloads() { - RecordingDriver driver = new RecordingDriver(); + TestStorageDriver driver = TestStorageDriver.create(); CountingCodec codec = new CountingCodec(); DataConverter converter = codecBacked(driver, codec); Optional stored = converter.toPayloads("a", "b", "c"); - assertEquals(3, driver.objects.size()); - for (Payload payload : driver.objects.values()) { + assertEquals(3, driver.storedCount()); + for (Payload payload : driver.storedPayloads()) { String data = payload.getData().toStringUtf8(); assertFalse(data.contains("\"a\"")); assertFalse(data.contains("\"b\"")); @@ -242,46 +235,4 @@ private static List apply(List payloads) { return out; } } - - private static final class RecordingDriver implements StorageDriver { - final Map objects = new HashMap<>(); - final List retrievedKeys = new ArrayList<>(); - volatile StorageDriverTargetInfo lastTarget; - private int counter = 0; - - @Override - public String getName() { - return "test"; - } - - @Override - public String getType() { - return "test.inmemory"; - } - - @Override - public synchronized CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - lastTarget = context.getTarget(); - List claims = new ArrayList<>(); - for (Payload payload : payloads) { - String key = "k-" + (counter++); - objects.put(key, payload); - claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); - } - return CompletableFuture.completedFuture(claims); - } - - @Override - public synchronized CompletableFuture> retrieve( - StorageDriverRetrieveContext context, List claims) { - List payloads = new ArrayList<>(); - for (StorageDriverClaim claim : claims) { - String key = claim.getClaimData().get("key"); - retrievedKeys.add(key); - payloads.add(objects.get(key)); - } - return CompletableFuture.completedFuture(payloads); - } - } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java index 110bd6ff2e..887c6f4e1b 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStoragePayloadTransformerTest.java @@ -21,7 +21,6 @@ import io.temporal.payload.storage.StorageDriverStoreContext; import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.payload.storage.StorageDriverWorkflowInfo; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -39,7 +38,7 @@ public class ExternalStoragePayloadTransformerTest { @Test public void storesAndRetrievesRoundTrip() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStoragePayloadTransformer transformer = transformer(driver, 0); List input = Arrays.asList(payload("a"), payload("b")); @@ -59,7 +58,7 @@ public void storesAndRetrievesRoundTrip() throws Exception { @Test public void payloadBelowThresholdStaysInline() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStoragePayloadTransformer transformer = transformer(driver, 100); Payload small = payload("x"); Payload large = payload(repeat("y", 200)); @@ -75,7 +74,7 @@ public void payloadBelowThresholdStaysInline() throws Exception { @Test public void selectorReturningNullKeepsInline() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( ExternalStorage.newBuilder() @@ -95,8 +94,8 @@ public void selectorReturningNullKeepsInline() throws Exception { @Test public void multipleDriversBatchPerDriverAndPreserveOrder() throws Exception { - InMemoryDriver d1 = new InMemoryDriver("d1"); - InMemoryDriver d2 = new InMemoryDriver("d2"); + TestStorageDriver d1 = TestStorageDriver.named("d1"); + TestStorageDriver d2 = TestStorageDriver.named("d2"); Map byPrefix = new HashMap<>(); byPrefix.put("1", d1); byPrefix.put("2", d2); @@ -121,16 +120,7 @@ public void multipleDriversBatchPerDriverAndPreserveOrder() throws Exception { @Test public void selectorReceivesSelectContextCarryingTheTarget() throws Exception { AtomicReference seen = new AtomicReference<>(); - AtomicReference storeSeen = new AtomicReference<>(); - InMemoryDriver driver = - new InMemoryDriver("d1") { - @Override - public CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - storeSeen.set(context); - return super.store(context, payloads); - } - }; + TestStorageDriver driver = TestStorageDriver.named("d1"); StorageDriverSelector selector = (context, payload) -> { seen.set(context); @@ -152,8 +142,7 @@ public CompletableFuture> store( assertNotNull(seen.get()); assertSame(target, seen.get().getTarget()); - assertNotNull(storeSeen.get()); - assertSame(target, storeSeen.get().getTarget()); + assertEquals(Collections.singletonList(target), driver.targets); } @Test @@ -220,7 +209,7 @@ public CompletableFuture> retrieve( @Test public void unknownDriverOnRetrieveFails() { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStoragePayloadTransformer transformer = transformer(driver, 0); Payload reference = ExternalStorageReferences.toReferencePayload( @@ -235,8 +224,8 @@ public void unknownDriverOnRetrieveFails() { @Test public void selectorReturningUnregisteredDriverFails() { - InMemoryDriver registered = new InMemoryDriver("d1"); - InMemoryDriver stranger = new InMemoryDriver("d2"); + TestStorageDriver registered = TestStorageDriver.named("d1"); + TestStorageDriver stranger = TestStorageDriver.named("d2"); ExternalStoragePayloadTransformer transformer = ExternalStoragePayloadTransformer.fromOptions( ExternalStorage.newBuilder() @@ -350,7 +339,7 @@ public CompletableFuture> retrieve( @Test public void selectorObservesCallerCancellationToken() { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); CancelSource caller = new CancelSource<>(CancellationException::new); AtomicReference> observed = new AtomicReference<>(); ExternalStoragePayloadTransformer transformer = @@ -441,39 +430,4 @@ public CompletableFuture> retrieve( throw new UnsupportedOperationException(); } } - - private static class InMemoryDriver extends FakeDriver { - final Map objects = new HashMap<>(); - final List storeBatchSizes = new ArrayList<>(); - final List retrieveBatchSizes = new ArrayList<>(); - private int counter = 0; - - InMemoryDriver(String name) { - super(name); - } - - @Override - public CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - storeBatchSizes.add(payloads.size()); - List claims = new ArrayList<>(); - for (Payload payload : payloads) { - String key = getName() + "-" + (counter++); - objects.put(key, payload); - claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); - } - return CompletableFuture.completedFuture(claims); - } - - @Override - public CompletableFuture> retrieve( - StorageDriverRetrieveContext context, List claims) { - retrieveBatchSizes.add(claims.size()); - List payloads = new ArrayList<>(); - for (StorageDriverClaim claim : claims) { - payloads.add(objects.get(claim.getClaimData().get("key"))); - } - return CompletableFuture.completedFuture(payloads); - } - } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java index 7cef800eb5..73d5010256 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java @@ -8,6 +8,7 @@ import com.google.protobuf.ByteString; import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.CommandOrBuilder; import io.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes; import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributesOrBuilder; @@ -16,6 +17,7 @@ import io.temporal.api.common.v1.Payload; import io.temporal.api.common.v1.Payloads; import io.temporal.api.common.v1.SearchAttributes; +import io.temporal.api.sdk.v1.UserMetadata; import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; import io.temporal.common.CancellationToken; import io.temporal.internal.concurrent.structured.CancelSource; @@ -23,18 +25,9 @@ import io.temporal.payload.storage.ExternalStorage; import io.temporal.payload.storage.StorageDriver; import io.temporal.payload.storage.StorageDriverActivityInfo; -import io.temporal.payload.storage.StorageDriverClaim; -import io.temporal.payload.storage.StorageDriverRetrieveContext; -import io.temporal.payload.storage.StorageDriverStoreContext; import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.payload.storage.StorageDriverWorkflowInfo; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; import java.util.concurrent.CancellationException; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; import org.junit.Test; @@ -43,7 +36,7 @@ public class ExternalStorageRunnerTest { @Test public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 0); Payloads message = Payloads.newBuilder().addPayloads(payload("a")).addPayloads(payload("b")).build(); @@ -61,7 +54,7 @@ public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { @Test public void walksNestedPayloads() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 0); Command command = Command.newBuilder() @@ -81,7 +74,7 @@ public void walksNestedPayloads() throws Exception { @Test public void payloadBelowThresholdLeavesMessageUnchanged() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 1024); Payloads message = Payloads.newBuilder().addPayloads(payload("small")).build(); @@ -96,7 +89,7 @@ public void payloadBelowThresholdLeavesMessageUnchanged() throws Exception { @Test public void searchAttributesAreNotOffloaded() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 0); Command command = Command.newBuilder() @@ -122,7 +115,7 @@ public void searchAttributesAreNotOffloaded() throws Exception { @Test public void throwIfContainsReferenceThrowsOnANestedReference() throws Exception { - ExternalStorageRunner transformer = transformer(new InMemoryDriver("d1"), 0); + ExternalStorageRunner transformer = transformer(TestStorageDriver.named("d1"), 0); RespondWorkflowTaskCompletedRequest.Builder request = RespondWorkflowTaskCompletedRequest.newBuilder() .addCommands( @@ -142,7 +135,7 @@ public void throwIfContainsReferenceThrowsOnANestedReference() throws Exception @Test public void throwIfContainsReferenceThrowsOnReference() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 0); Payloads.Builder builder = Payloads.newBuilder().addPayloads(payload("a")); transformer.store(builder, null, null, CancellationToken.none()); @@ -160,14 +153,16 @@ public void throwIfContainsReferenceAllowsInlinePayloads() { } @Test - public void storeAppliesPerCommandTargetFromMessageVisitor() { - TargetCapturingDriver driver = new TargetCapturingDriver("d1"); + public void storeScopesCommandTargetOverAttributesAndMetadata() { + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner storage = transformer(driver, 0); RespondWorkflowTaskCompletedRequest.Builder request = RespondWorkflowTaskCompletedRequest.newBuilder() .addCommands( Command.newBuilder() + .setUserMetadata( + UserMetadata.newBuilder().setSummary(payload("activity-summary"))) .setScheduleActivityTaskCommandAttributes( ScheduleActivityTaskCommandAttributes.newBuilder() .setActivityId("act-1") @@ -184,11 +179,15 @@ public void storeAppliesPerCommandTargetFromMessageVisitor() { new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "MyWorkflow"); MessageVisitor visitor = (current, message) -> { - if (message instanceof ScheduleActivityTaskCommandAttributesOrBuilder) { - ScheduleActivityTaskCommandAttributesOrBuilder attrs = - (ScheduleActivityTaskCommandAttributesOrBuilder) message; - return new StorageDriverActivityInfo( - "ns", attrs.getActivityId(), null, attrs.getActivityType().getName()); + if (message instanceof CommandOrBuilder) { + CommandOrBuilder command = (CommandOrBuilder) message; + if (command.getAttributesCase() + == Command.AttributesCase.SCHEDULE_ACTIVITY_TASK_COMMAND_ATTRIBUTES) { + ScheduleActivityTaskCommandAttributesOrBuilder attrs = + command.getScheduleActivityTaskCommandAttributesOrBuilder(); + return new StorageDriverActivityInfo( + "ns", attrs.getActivityId(), null, attrs.getActivityType().getName()); + } } return current; }; @@ -198,12 +197,15 @@ public void storeAppliesPerCommandTargetFromMessageVisitor() { assertEquals( new StorageDriverActivityInfo("ns", "act-1", null, "MyActivity"), driver.targetFor("activity-input")); + assertEquals( + new StorageDriverActivityInfo("ns", "act-1", null, "MyActivity"), + driver.targetFor("activity-summary")); assertEquals(workflowTarget, driver.targetFor("wf-result")); } @Test public void callerCancellationAbortsStore() { - ExternalStorageRunner storage = transformer(new HangingDriver("d1"), 0); + ExternalStorageRunner storage = transformer(TestStorageDriver.named("d1").neverAnswers(), 0); CancelSource caller = new CancelSource<>(CancellationException::new); caller.cancel(); Payloads message = Payloads.newBuilder().addPayloads(payload("big")).build(); @@ -216,7 +218,7 @@ public void callerCancellationAbortsStore() { @Test public void completedOperationsReleaseTheirCancellationRegistrations() { RegistrationCountingToken token = new RegistrationCountingToken(); - ExternalStorageRunner storage = transformer(new InMemoryDriver("d1"), 0); + ExternalStorageRunner storage = transformer(TestStorageDriver.named("d1"), 0); for (int i = 0; i < 5; i++) { Payloads.Builder builder = Payloads.newBuilder().addPayloads(payload("a")); @@ -263,121 +265,4 @@ public Registration onCancel(Runnable callback) { return open::decrementAndGet; } } - - private static final class InMemoryDriver implements StorageDriver { - private final String name; - private final Map objects = new HashMap<>(); - final List storeBatchSizes = new ArrayList<>(); - private int counter = 0; - - InMemoryDriver(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getType() { - return "test.inmemory"; - } - - @Override - public synchronized CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - storeBatchSizes.add(payloads.size()); - List claims = new ArrayList<>(); - for (Payload payload : payloads) { - String key = name + "-" + (counter++); - objects.put(key, payload); - claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); - } - return CompletableFuture.completedFuture(claims); - } - - @Override - public synchronized CompletableFuture> retrieve( - StorageDriverRetrieveContext context, List claims) { - List payloads = new ArrayList<>(); - for (StorageDriverClaim claim : claims) { - payloads.add(objects.get(claim.getClaimData().get("key"))); - } - return CompletableFuture.completedFuture(payloads); - } - } - - private static final class TargetCapturingDriver implements StorageDriver { - private final String name; - private final Map targetByData = new HashMap<>(); - private int counter = 0; - - TargetCapturingDriver(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getType() { - return "test.capture"; - } - - @Override - public synchronized CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - List claims = new ArrayList<>(); - for (Payload payload : payloads) { - targetByData.put(payload.getData().toStringUtf8(), context.getTarget()); - claims.add( - new StorageDriverClaim(Collections.singletonMap("key", name + "-" + (counter++)))); - } - return CompletableFuture.completedFuture(claims); - } - - synchronized StorageDriverTargetInfo targetFor(String data) { - return targetByData.get(data); - } - - @Override - public CompletableFuture> retrieve( - StorageDriverRetrieveContext context, List claims) { - throw new UnsupportedOperationException(); - } - } - - /** Driver whose operations never settle, so only cancellation can end a blocking call. */ - private static final class HangingDriver implements StorageDriver { - private final String name; - - HangingDriver(String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - - @Override - public String getType() { - return "test.hanging"; - } - - @Override - public CompletableFuture> store( - StorageDriverStoreContext context, List payloads) { - return new CompletableFuture<>(); - } - - @Override - public CompletableFuture> retrieve( - StorageDriverRetrieveContext context, List claims) { - return new CompletableFuture<>(); - } - } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/TestStorageDriver.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/TestStorageDriver.java new file mode 100644 index 0000000000..b7e21ce63b --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/TestStorageDriver.java @@ -0,0 +1,247 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.api.common.v1.Payload; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; + +/** + * In-memory storage driver for tests. Records what it was asked to do, and can be told to fail, + * block or never answer so that one driver covers the cases the tests need. + */ +public final class TestStorageDriver implements StorageDriver { + + private final String name; + private final Map objects = new HashMap<>(); + private final Map targetByData = new HashMap<>(); + private int counter; + + public final List storeBatchSizes = new CopyOnWriteArrayList<>(); + public final List retrieveBatchSizes = new CopyOnWriteArrayList<>(); + public final List retrievedKeys = new CopyOnWriteArrayList<>(); + public final List targets = new CopyOnWriteArrayList<>(); + public final List storedData = new CopyOnWriteArrayList<>(); + public final AtomicInteger stores = new AtomicInteger(); + public final AtomicInteger retrieves = new AtomicInteger(); + public final AtomicInteger injectedFailures = new AtomicInteger(); + + private volatile boolean neverAnswers; + private volatile boolean cancelInsteadOfFailing; + private final AtomicInteger storeFailures = new AtomicInteger(); + private final AtomicInteger retrieveFailures = new AtomicInteger(); + private volatile @Nullable String failStoresContaining; + private volatile @Nullable CountDownLatch storeEntered; + private volatile @Nullable CountDownLatch releaseStore; + + private TestStorageDriver(String name) { + this.name = name; + } + + public static TestStorageDriver create() { + return new TestStorageDriver("test"); + } + + public static TestStorageDriver named(String name) { + return new TestStorageDriver(name); + } + + /** Neither storing nor retrieving ever finishes, so only cancellation can end the call. */ + public TestStorageDriver neverAnswers() { + this.neverAnswers = true; + return this; + } + + public TestStorageDriver failStores(int times) { + this.storeFailures.set(times); + return this; + } + + /** Fails the next {@code times} stores the way an abandoned call does. */ + public TestStorageDriver cancelStores(int times) { + this.storeFailures.set(times); + this.cancelInsteadOfFailing = true; + return this; + } + + /** Fails the next {@code times} stores that carry a payload containing {@code marker}. */ + public TestStorageDriver failStoresContaining(String marker, int times) { + this.failStoresContaining = marker; + this.storeFailures.set(times); + return this; + } + + public TestStorageDriver failRetrieves(int times) { + this.retrieveFailures.set(times); + return this; + } + + /** Holds each store until {@code release}, counting down {@code entered} on the way in. */ + public TestStorageDriver blockStores(CountDownLatch entered, CountDownLatch release) { + this.storeEntered = entered; + this.releaseStore = release; + return this; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.in-memory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + stores.incrementAndGet(); + storeBatchSizes.add(payloads.size()); + targets.add(context.getTarget()); + + CountDownLatch entered = storeEntered; + if (entered != null) { + entered.countDown(); + } + CountDownLatch release = releaseStore; + if (release != null) { + try { + release.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + if (neverAnswers) { + return new CompletableFuture<>(); + } + if (shouldFailStore(payloads)) { + injectedFailures.incrementAndGet(); + return cancelInsteadOfFailing + ? cancelled("external storage stopped") + : failed("storage unavailable"); + } + + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String data = payload.getData().toStringUtf8(); + storedData.add(data); + targetByData.put(data, context.getTarget()); + String key = name + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + retrieves.incrementAndGet(); + retrieveBatchSizes.add(claims.size()); + for (StorageDriverClaim claim : claims) { + retrievedKeys.add(claim.getClaimData().get("key")); + } + if (neverAnswers) { + return new CompletableFuture<>(); + } + if (retrieveFailures.get() > 0) { + retrieveFailures.decrementAndGet(); + injectedFailures.incrementAndGet(); + return failed("storage unavailable"); + } + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + + /** Forgets everything stored and recorded, and clears any injected behaviour. */ + public synchronized void reset() { + objects.clear(); + targetByData.clear(); + counter = 0; + storeBatchSizes.clear(); + retrieveBatchSizes.clear(); + retrievedKeys.clear(); + targets.clear(); + storedData.clear(); + stores.set(0); + retrieves.set(0); + injectedFailures.set(0); + storeFailures.set(0); + retrieveFailures.set(0); + failStoresContaining = null; + storeEntered = null; + releaseStore = null; + neverAnswers = false; + cancelInsteadOfFailing = false; + } + + /** The target supplied when the payload with this data was stored. */ + public synchronized StorageDriverTargetInfo targetFor(String data) { + return targetByData.get(data); + } + + public synchronized int storedCount() { + return objects.size(); + } + + public synchronized Collection storedPayloads() { + return new ArrayList<>(objects.values()); + } + + /** The target supplied with the most recent store, or {@code null} if nothing was stored. */ + public StorageDriverTargetInfo lastTarget() { + return targets.isEmpty() ? null : targets.get(targets.size() - 1); + } + + public boolean stored(String substring) { + return storedData.stream().anyMatch(data -> data.contains(substring)); + } + + private boolean shouldFailStore(List payloads) { + if (storeFailures.get() <= 0) { + return false; + } + String marker = failStoresContaining; + if (marker == null) { + storeFailures.decrementAndGet(); + return true; + } + for (Payload payload : payloads) { + if (payload.getData().toStringUtf8().contains(marker)) { + storeFailures.decrementAndGet(); + return true; + } + } + return false; + } + + private static CompletableFuture cancelled(String message) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new CancellationException(message)); + return future; + } + + private static CompletableFuture failed(String message) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(new IllegalStateException(message)); + return future; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java b/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java index ed6446678a..046255218d 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java @@ -3,26 +3,36 @@ import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertNotNull; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.google.protobuf.ByteString; import com.google.protobuf.util.Durations; import com.uber.m3.tally.NoopScope; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; import io.temporal.api.enums.v1.EventType; import io.temporal.api.history.v1.History; import io.temporal.api.history.v1.HistoryEvent; import io.temporal.api.taskqueue.v1.StickyExecutionAttributes; import io.temporal.api.workflowservice.v1.*; +import io.temporal.common.CancellationToken; import io.temporal.internal.common.InternalUtils; +import io.temporal.internal.concurrent.structured.CancelSource; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.internal.payload.storage.TestStorageDriver; import io.temporal.internal.statemachines.ExecuteLocalActivityParameters; import io.temporal.internal.worker.SingleWorkerOptions; import io.temporal.internal.worker.WorkflowExecutorCache; import io.temporal.internal.worker.WorkflowRunLockManager; import io.temporal.internal.worker.WorkflowTaskHandler; +import io.temporal.payload.storage.ExternalStorage; import io.temporal.serviceclient.Version; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.testUtils.HistoryUtils; @@ -31,8 +41,10 @@ import java.util.HashMap; import java.util.List; import java.util.Optional; +import java.util.concurrent.CancellationException; import org.junit.Rule; import org.junit.Test; +import org.mockito.ArgumentCaptor; public class ReplayWorkflowRunTaskHandlerTaskHandlerTests { @@ -121,6 +133,231 @@ public void workflowTaskFailOnIncompleteHistory() throws Throwable { result.getTaskFailed().getFailure().getMessage()); } + @Test + public void resolvesExternalStorageReferencesInTheWorkflowTaskItself() throws Throwable { + TestStorageDriver driver = TestStorageDriver.create(); + ExternalStorageRunner externalStorage = + ExternalStorageRunner.create( + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build()); + PollWorkflowTaskQueueResponse fullTask = HistoryUtils.generateWorkflowTaskWithInitialHistory(); + HistoryEvent startedEvent = fullTask.getHistory().getEvents(0); + Payload input = Payload.newBuilder().setData(ByteString.copyFromUtf8("input")).build(); + History.Builder storedHistory = + fullTask.getHistory().toBuilder() + .setEvents( + 0, + startedEvent.toBuilder() + .setWorkflowExecutionStartedEventAttributes( + startedEvent.getWorkflowExecutionStartedEventAttributes().toBuilder() + .setInput(Payloads.newBuilder().addPayloads(input)))); + externalStorage.store(storedHistory, null, null, CancellationToken.none()); + assertNotEquals( + "the payload must be replaced by a reference, otherwise this test proves nothing", + input, + storedInput(storedHistory)); + + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + + ReplayWorkflow workflow = mock(ReplayWorkflow.class); + when(workflow.eventLoop()).thenReturn(true); + when(workflow.getOutput()).thenReturn(Optional.empty()); + WorkflowContext workflowContext = mock(WorkflowContext.class); + when(workflowContext.getRunningUpdateHandlers()).thenReturn(new HashMap<>()); + when(workflow.getWorkflowContext()).thenReturn(workflowContext); + ReplayWorkflowFactory workflowFactory = mock(ReplayWorkflowFactory.class); + when(workflowFactory.getWorkflow(any(), any())).thenReturn(workflow); + + WorkflowTaskHandler taskHandler = + new ReplayWorkflowTaskHandler( + "namespace", + workflowFactory, + new WorkflowExecutorCache(10, new WorkflowRunLockManager(), new NoopScope()), + SingleWorkerOptions.newBuilder().setExternalStorageRunner(externalStorage).build(), + null, + Duration.ofSeconds(5), + client, + null); + + taskHandler.handleWorkflowTask(fullTask.toBuilder().setHistory(storedHistory).build()); + + ArgumentCaptor event = ArgumentCaptor.forClass(HistoryEvent.class); + verify(workflow).start(event.capture(), any()); + assertEquals( + input, + event.getValue().getWorkflowExecutionStartedEventAttributes().getInput().getPayloads(0)); + } + + private static Payload storedInput(History.Builder history) { + return history + .getEvents(0) + .getWorkflowExecutionStartedEventAttributes() + .getInput() + .getPayloads(0); + } + + @Test + public void aCancelledDownloadIsNotReportedAsAWorkflowTaskFailure() throws Throwable { + TestStorageDriver driver = TestStorageDriver.create(); + ExternalStorageRunner externalStorage = + ExternalStorageRunner.create( + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build()); + PollWorkflowTaskQueueResponse fullTask = HistoryUtils.generateWorkflowTaskWithInitialHistory(); + HistoryEvent startedEvent = fullTask.getHistory().getEvents(0); + Payload input = Payload.newBuilder().setData(ByteString.copyFromUtf8("input")).build(); + History.Builder storedHistory = + fullTask.getHistory().toBuilder() + .setEvents( + 0, + startedEvent.toBuilder() + .setWorkflowExecutionStartedEventAttributes( + startedEvent.getWorkflowExecutionStartedEventAttributes().toBuilder() + .setInput(Payloads.newBuilder().addPayloads(input)))); + externalStorage.store(storedHistory, null, null, CancellationToken.none()); + driver.neverAnswers(); + + CancelSource stopping = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); + stopping.cancel(); + + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + + WorkflowTaskHandler taskHandler = + new ReplayWorkflowTaskHandler( + "namespace", + setUpMockWorkflowFactory(), + new WorkflowExecutorCache(10, new WorkflowRunLockManager(), new NoopScope()), + SingleWorkerOptions.newBuilder() + .setExternalStorageRunner(externalStorage) + .setStorageCancellation(stopping.token()) + .build(), + null, + Duration.ofSeconds(5), + client, + null); + + assertThrows( + "stopping storage must not be turned into a workflow task failure", + CancellationException.class, + () -> + taskHandler.handleWorkflowTask(fullTask.toBuilder().setHistory(storedHistory).build())); + } + + @Test + public void aFailedDownloadIsReportedAsAWorkflowTaskFailure() throws Throwable { + TestStorageDriver driver = TestStorageDriver.create(); + ExternalStorageRunner externalStorage = + ExternalStorageRunner.create( + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build()); + PollWorkflowTaskQueueResponse fullTask = HistoryUtils.generateWorkflowTaskWithInitialHistory(); + HistoryEvent startedEvent = fullTask.getHistory().getEvents(0); + Payload input = Payload.newBuilder().setData(ByteString.copyFromUtf8("input")).build(); + History.Builder storedHistory = + fullTask.getHistory().toBuilder() + .setEvents( + 0, + startedEvent.toBuilder() + .setWorkflowExecutionStartedEventAttributes( + startedEvent.getWorkflowExecutionStartedEventAttributes().toBuilder() + .setInput(Payloads.newBuilder().addPayloads(input)))); + externalStorage.store(storedHistory, null, null, CancellationToken.none()); + driver.failRetrieves(1); + + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + + WorkflowTaskHandler taskHandler = + new ReplayWorkflowTaskHandler( + "namespace", + setUpMockWorkflowFactory(), + new WorkflowExecutorCache(10, new WorkflowRunLockManager(), new NoopScope()), + SingleWorkerOptions.newBuilder().setExternalStorageRunner(externalStorage).build(), + null, + Duration.ofSeconds(5), + client, + null); + + WorkflowTaskHandler.Result result = + taskHandler.handleWorkflowTask(fullTask.toBuilder().setHistory(storedHistory).build()); + + assertNotNull( + "a failed download must be reported rather than ending the task", result.getTaskFailed()); + assertTrue(result.getTaskFailed().hasFailure()); + assertTrue( + "the reported failure must say what went wrong, got: " + + result.getTaskFailed().getFailure().getMessage(), + result.getTaskFailed().getFailure().getMessage().contains("storage unavailable")); + } + + @Test + public void resolvesExternalStorageReferencesInFetchedFullHistory() throws Throwable { + ExternalStorageRunner externalStorage = + ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(TestStorageDriver.create()) + .setPayloadSizeThreshold(0) + .build()); + PollWorkflowTaskQueueResponse fullTask = HistoryUtils.generateWorkflowTaskWithInitialHistory(); + HistoryEvent startedEvent = fullTask.getHistory().getEvents(0); + Payload input = Payload.newBuilder().setData(ByteString.copyFromUtf8("input")).build(); + History.Builder storedHistory = + fullTask.getHistory().toBuilder() + .setEvents( + 0, + startedEvent.toBuilder() + .setWorkflowExecutionStartedEventAttributes( + startedEvent.getWorkflowExecutionStartedEventAttributes().toBuilder() + .setInput(Payloads.newBuilder().addPayloads(input)))); + externalStorage.store(storedHistory, null, null, CancellationToken.none()); + assertNotEquals( + "the payload must be replaced by a reference, otherwise this test proves nothing", + input, + storedInput(storedHistory)); + + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(client.blockingStub()).thenReturn(blockingStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + when(blockingStub.getWorkflowExecutionHistory(any())) + .thenReturn( + GetWorkflowExecutionHistoryResponse.newBuilder().setHistory(storedHistory).build()); + + ReplayWorkflow workflow = mock(ReplayWorkflow.class); + when(workflow.eventLoop()).thenReturn(true); + when(workflow.getOutput()).thenReturn(Optional.empty()); + WorkflowContext workflowContext = mock(WorkflowContext.class); + when(workflowContext.getRunningUpdateHandlers()).thenReturn(new HashMap<>()); + when(workflow.getWorkflowContext()).thenReturn(workflowContext); + ReplayWorkflowFactory workflowFactory = mock(ReplayWorkflowFactory.class); + when(workflowFactory.getWorkflow(any(), any())).thenReturn(workflow); + WorkflowTaskHandler taskHandler = + new ReplayWorkflowTaskHandler( + "namespace", + workflowFactory, + new WorkflowExecutorCache(10, new WorkflowRunLockManager(), new NoopScope()), + SingleWorkerOptions.newBuilder().setExternalStorageRunner(externalStorage).build(), + null, + Duration.ofSeconds(5), + client, + null); + + taskHandler.handleWorkflowTask( + fullTask.toBuilder().setHistory(History.getDefaultInstance()).build()); + + ArgumentCaptor event = ArgumentCaptor.forClass(HistoryEvent.class); + verify(workflow).start(event.capture(), any()); + assertEquals( + input, + event.getValue().getWorkflowExecutionStartedEventAttributes().getInput().getPayloads(0)); + } + @Test public void localActivityMeteringHelper() { ReplayWorkflowRunTaskHandler.LocalActivityMeteringHelper laMeteringHelper = diff --git a/temporal-sdk/src/test/java/io/temporal/internal/replay/ServiceWorkflowHistoryIteratorTest.java b/temporal-sdk/src/test/java/io/temporal/internal/replay/ServiceWorkflowHistoryIteratorTest.java index ad0c665800..672b0a7515 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/replay/ServiceWorkflowHistoryIteratorTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/replay/ServiceWorkflowHistoryIteratorTest.java @@ -1,12 +1,23 @@ package io.temporal.internal.replay; import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.api.history.v1.WorkflowExecutionStartedEventAttributes; import io.temporal.api.workflowservice.v1.GetWorkflowExecutionHistoryResponse; import io.temporal.api.workflowservice.v1.PollWorkflowTaskQueueResponse; +import io.temporal.common.CancellationToken; +import io.temporal.internal.concurrent.structured.CancelSource; +import io.temporal.internal.payload.storage.ExternalStorageNotConfiguredException; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.internal.payload.storage.TestStorageDriver; +import io.temporal.payload.storage.ExternalStorage; import io.temporal.testUtils.HistoryUtils; import java.nio.charset.Charset; import java.util.NoSuchElementException; +import java.util.concurrent.CancellationException; import java.util.concurrent.atomic.AtomicInteger; import org.junit.Assert; import org.junit.Test; @@ -84,4 +95,91 @@ GetWorkflowExecutionHistoryResponse queryWorkflowExecutionHistory() { Assert.assertThrows(NoSuchElementException.class, iterator::next); Assert.assertEquals(4, timesCalledServer.get()); } + + @Test + public void resolvesExternalStorageReferencesInFetchedPages() { + ExternalStorageRunner storage = inMemoryStorage(); + History inline = historyWithInput(payload("big-input")); + History.Builder builder = inline.toBuilder(); + storage.store(builder, null, null, CancellationToken.none()); + History stored = builder.build(); + Assert.assertNotEquals( + "stored history should hold a reference, not the inline payload", inline, stored); + + ServiceWorkflowHistoryIterator iterator = fetchingIterator(stored, storage); + + HistoryEvent event = iterator.next(); + Assert.assertEquals( + payload("big-input"), + event.getWorkflowExecutionStartedEventAttributes().getInput().getPayloads(0)); + } + + @Test + public void failsLoudWhenAFetchedPageHasAReferenceAndStorageIsNotConfigured() { + History.Builder builder = historyWithInput(payload("big-input")).toBuilder(); + inMemoryStorage().store(builder, null, null, CancellationToken.none()); + History stored = builder.build(); + + ServiceWorkflowHistoryIterator iterator = fetchingIterator(stored, null); + + Assert.assertThrows(ExternalStorageNotConfiguredException.class, iterator::hasNext); + } + + @Test + public void aCancelledTokenAbortsRetrievalOfAFetchedPage() { + ExternalStorageRunner storage = inMemoryStorage(); + History.Builder builder = historyWithInput(payload("big-input")).toBuilder(); + storage.store(builder, null, null, CancellationToken.none()); + History stored = builder.build(); + + CancelSource source = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); + source.cancel(); + + ServiceWorkflowHistoryIterator iterator = fetchingIterator(stored, storage, source.token()); + + Assert.assertThrows(CancellationException.class, iterator::hasNext); + } + + private static ServiceWorkflowHistoryIterator fetchingIterator( + History page, ExternalStorageRunner storage) { + return fetchingIterator(page, storage, CancellationToken.none()); + } + + private static ServiceWorkflowHistoryIterator fetchingIterator( + History page, + ExternalStorageRunner storage, + CancellationToken storageCancellation) { + PollWorkflowTaskQueueResponse workflowTask = + PollWorkflowTaskQueueResponse.newBuilder().setNextPageToken(NEXT_PAGE_TOKEN).build(); + return new ServiceWorkflowHistoryIterator( + null, "default", workflowTask, null, storage, storageCancellation) { + @Override + GetWorkflowExecutionHistoryResponse queryWorkflowExecutionHistory() { + return GetWorkflowExecutionHistoryResponse.newBuilder().setHistory(page).build(); + } + }; + } + + private static ExternalStorageRunner inMemoryStorage() { + return ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(TestStorageDriver.create()) + .setPayloadSizeThreshold(0) + .build()); + } + + private static History historyWithInput(Payload payload) { + return History.newBuilder() + .addEvents( + HistoryEvent.newBuilder() + .setWorkflowExecutionStartedEventAttributes( + WorkflowExecutionStartedEventAttributes.newBuilder() + .setInput(Payloads.newBuilder().addPayloads(payload)))) + .build(); + } + + private static Payload payload(String data) { + return Payload.newBuilder().setData(ByteString.copyFromUtf8(data)).build(); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java index 5faa34ca7c..9d641fab0d 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java @@ -202,7 +202,7 @@ public void testSlots() throws InterruptedException, AsyncPoller.PollTaskAsyncAb pollLatch.await(); assertEventually( - Duration.ofSeconds(1), + Duration.ofSeconds(5), () -> { assertEquals(5, slotSupplierInner.reservedCount.get()); assertEquals(0, slotSupplier.getUsedSlots().size()); @@ -212,7 +212,7 @@ public void testSlots() throws InterruptedException, AsyncPoller.PollTaskAsyncAb future.complete(new TestScalingTask(null, slotSupplier)); assertEventually( - Duration.ofSeconds(1), + Duration.ofSeconds(5), () -> { assertEquals(5, executor.processed.get()); }); @@ -371,7 +371,7 @@ public void testSuspendPolling() assertFalse(poller.isSuspended()); pollLatch.await(); assertEventually( - Duration.ofSeconds(1), + Duration.ofSeconds(5), () -> { assertEquals(0, executor.processed.get()); assertEquals(1, slotSupplierInner.reservedCount.get()); @@ -381,7 +381,7 @@ public void testSuspendPolling() poller.suspendPolling(); completePoll.get().apply(); assertEventually( - Duration.ofSeconds(1), + Duration.ofSeconds(5), () -> { assertEquals(1, executor.processed.get()); assertEquals(2, slotSupplierInner.reservedCount.get()); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageFailureTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageFailureTest.java new file mode 100644 index 0000000000..a1713e16e4 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageFailureTest.java @@ -0,0 +1,120 @@ +package io.temporal.internal.worker; + +import io.temporal.api.enums.v1.EventType; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.internal.payload.storage.TestStorageDriver; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.UUID; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class WorkflowWorkerExternalStorageFailureTest { + + private static final TestStorageDriver driver = TestStorageDriver.named("wf-flaky"); + + private static final ExternalStorage storage = + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(EchoWorkflowImpl.class) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setExternalStorage(storage).build()) + .build(); + + @Before + public void resetDriver() { + driver.reset(); + } + + @Test + public void aFailedOutboundStoreFailsTheWorkflowTaskInsteadOfTimingOut() throws Exception { + String workflowId = "extstore-wft-" + UUID.randomUUID(); + String input = "wft-store-" + UUID.randomUUID(); + driver.failStoresContaining("echo: " + input, 1); + + TestWorkflows.TestWorkflow1 workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + TestWorkflows.TestWorkflow1.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowId(workflowId) + .build()); + WorkflowClient.start(workflow::execute, input); + + awaitEvent(workflowId, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED); + + Assert.assertEquals( + "expected exactly one injected store failure", 1, driver.injectedFailures.get()); + testWorkflowRule.assertHistoryEvent(workflowId, EventType.EVENT_TYPE_WORKFLOW_TASK_FAILED); + String reported = + testWorkflowRule + .getHistoryEvent(workflowId, EventType.EVENT_TYPE_WORKFLOW_TASK_FAILED) + .getWorkflowTaskFailedEventAttributes() + .getFailure() + .getMessage(); + Assert.assertTrue( + "the reported failure must say what went wrong, got: " + reported, + reported.contains("storage unavailable")); + Assert.assertTrue( + "a reported failure must not leave a workflow task timeout in history", + testWorkflowRule + .getHistoryEvents(workflowId, EventType.EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT) + .isEmpty()); + } + + @Test + public void aStorageFailureIsReportedOnEveryAttemptNotJustTheFirst() throws Exception { + String workflowId = "extstore-wft-retry-" + UUID.randomUUID(); + String input = "wft-retry-" + UUID.randomUUID(); + driver.failStoresContaining("echo: " + input, 2); + + TestWorkflows.TestWorkflow1 workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + TestWorkflows.TestWorkflow1.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowId(workflowId) + .build()); + WorkflowClient.start(workflow::execute, input); + + awaitEvent(workflowId, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED); + + Assert.assertEquals("expected two injected store failures", 2, driver.injectedFailures.get()); + Assert.assertTrue( + "a reported failure must not leave a workflow task timeout in history", + testWorkflowRule + .getHistoryEvents(workflowId, EventType.EVENT_TYPE_WORKFLOW_TASK_TIMED_OUT) + .isEmpty()); + } + + private void awaitEvent(String workflowId, EventType eventType) throws InterruptedException { + long deadline = System.nanoTime() + Duration.ofSeconds(8).toNanos(); + while (System.nanoTime() < deadline) { + if (!testWorkflowRule.getHistoryEvents(workflowId, eventType).isEmpty()) { + return; + } + Thread.sleep(100); + } + Assert.fail("timed out waiting for " + eventType + " on " + workflowId); + } + + public static class EchoWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return "echo: " + input; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageTest.java new file mode 100644 index 0000000000..efdade1f72 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerExternalStorageTest.java @@ -0,0 +1,113 @@ +package io.temporal.internal.worker; + +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.internal.payload.storage.TestStorageDriver; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.TestWorkflows; +import java.util.UUID; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** e2e tests */ +public class WorkflowWorkerExternalStorageTest { + + private static final int THRESHOLD = 4096; + private static final String CONTINUED_MARKER = "continued:"; + + private static final TestStorageDriver driver = TestStorageDriver.named("wf-happy"); + + private static final ExternalStorage storage = + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(THRESHOLD).build(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(ContinueAsNewWorkflowImpl.class, EchoWorkflowImpl.class) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setExternalStorage(storage).build()) + .build(); + + @Before + public void resetDriver() { + driver.reset(); + } + + @Test + public void aLargeContinueAsNewInputIsStoredThenRestoredOnTheContinuedRun() { + TestWorkflows.TestWorkflow1 workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + TestWorkflows.TestWorkflow1.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowId("extstore-can-" + UUID.randomUUID()) + .build()); + + // Blocking call follows the continue-as-new and returns the continued run's result. + String result = workflow.execute("start"); + + int expectedInputLength = CONTINUED_MARKER.length() + THRESHOLD * 2; + Assert.assertEquals( + "the continued run must observe the full restored input, not a storage reference", + "len:" + expectedInputLength, + result); + Assert.assertTrue("the worker must have offloaded a payload", driver.stores.get() > 0); + Assert.assertTrue("the continued run must have retrieved it", driver.retrieves.get() > 0); + Assert.assertTrue( + "the large continue-as-new input must be what was stored", driver.stored(CONTINUED_MARKER)); + } + + @Test + public void aResultUnderTheThresholdIsLeftInlineAndReadableByTheClient() { + EchoWorkflow workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + EchoWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowId("extstore-inline-" + UUID.randomUUID()) + .build()); + + String result = workflow.echo("small"); + + Assert.assertEquals("echo: small", result); + Assert.assertEquals("nothing under the threshold should be offloaded", 0, driver.stores.get()); + } + + public static class ContinueAsNewWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + if (input.startsWith(CONTINUED_MARKER)) { + return "len:" + input.length(); + } + StringBuilder large = new StringBuilder(CONTINUED_MARKER); + for (int i = 0; i < THRESHOLD * 2; i++) { + large.append('x'); + } + Workflow.newContinueAsNewStub(TestWorkflows.TestWorkflow1.class).execute(large.toString()); + throw new IllegalStateException("unreachable: continue-as-new ends the run"); + } + } + + @WorkflowInterface + public interface EchoWorkflow { + @WorkflowMethod + String echo(String input); + } + + public static class EchoWorkflowImpl implements EchoWorkflow { + @Override + public String echo(String input) { + return "echo: " + input; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java index 5cd1fc8d3e..d85d913494 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/WorkflowWorkerTest.java @@ -3,23 +3,53 @@ import static java.nio.charset.StandardCharsets.UTF_8; import static junit.framework.TestCase.assertEquals; import static org.junit.Assert.*; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import com.google.common.util.concurrent.Futures; import com.google.protobuf.ByteString; import com.uber.m3.tally.NoopScope; import com.uber.m3.tally.RootScopeBuilder; import com.uber.m3.tally.Scope; import com.uber.m3.util.ImmutableMap; +import io.temporal.api.command.v1.Command; +import io.temporal.api.command.v1.CompleteWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.ContinueAsNewWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributes; +import io.temporal.api.command.v1.SignalExternalWorkflowExecutionCommandAttributes; +import io.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes; +import io.temporal.api.common.v1.ActivityType; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.common.v1.WorkflowType; +import io.temporal.api.enums.v1.WorkflowTaskFailedCause; +import io.temporal.api.failure.v1.ApplicationFailureInfo; +import io.temporal.api.failure.v1.Failure; +import io.temporal.api.namespace.v1.NamespaceInfo; import io.temporal.api.workflowservice.v1.*; +import io.temporal.api.workflowservice.v1.RespondQueryTaskCompletedRequest; +import io.temporal.api.workflowservice.v1.RespondWorkflowTaskFailedRequest; +import io.temporal.common.CancellationToken; import io.temporal.common.reporter.TestStatsReporter; import io.temporal.internal.common.InternalUtils; +import io.temporal.internal.concurrent.structured.CancelSource; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.internal.payload.storage.TestStorageDriver; import io.temporal.internal.replay.ReplayWorkflow; import io.temporal.internal.replay.ReplayWorkflowFactory; import io.temporal.internal.replay.ReplayWorkflowTaskHandler; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.testUtils.Eventually; import io.temporal.testUtils.HistoryUtils; @@ -31,7 +61,12 @@ import java.time.Duration; import java.util.UUID; import java.util.concurrent.*; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import javax.annotation.Nullable; import org.junit.Test; +import org.mockito.ArgumentCaptor; import org.mockito.stubbing.Answer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -448,4 +483,658 @@ private ReplayWorkflowFactory setUpMockWorkflowFactory() throws Throwable { when(mockWorkflow.eventLoop()).thenReturn(false); return mockFactory; } + + @Test + public void aTaskAbandonedWhileShuttingDownIsNotReported() throws Exception { + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + WorkflowRunLockManager runLockManager = new WorkflowRunLockManager(); + Scope metricsScope = + new RootScopeBuilder() + .reporter(reporter) + .reportEvery(com.uber.m3.util.Duration.ofMillis(1)); + WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLockManager, metricsScope); + WorkflowTaskHandler taskHandler = mock(WorkflowTaskHandler.class); + when(taskHandler.isAnyTypeSupported()).thenReturn(true); + + CountDownLatch handlerEntered = new CountDownLatch(1); + CountDownLatch releaseHandler = new CountDownLatch(1); + CountDownLatch escaped = new CountDownLatch(1); + CancelSource storageCancellation = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); + + WorkflowWorker worker = + new WorkflowWorker( + client, + "default", + "task_queue", + "sticky_task_queue", + SingleWorkerOptions.newBuilder() + .setIdentity("test_identity") + .setBuildId(UUID.randomUUID().toString()) + .setWorkerInstanceKey(UUID.randomUUID().toString()) + .setPollerOptions( + PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) + .setUncaughtExceptionHandler((thread, error) -> escaped.countDown()) + .build()) + .setMetricsScope(metricsScope) + .setStorageCancellation(storageCancellation.token()) + .build(), + runLockManager, + cache, + taskHandler, + mock(EagerActivityDispatcher.class), + 3, + new FixedSizeSlotSupplier<>(10), + new NamespaceCapabilities()); + + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) + .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(client.blockingStub()).thenReturn(blockingStub); + when(client.futureStub()).thenReturn(futureStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + + PollWorkflowTaskQueueResponse pollResponse = + PollWorkflowTaskQueueResponse.newBuilder() + .setTaskToken(ByteString.copyFrom("token", UTF_8)) + .setWorkflowExecution( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).setRunId(RUN_ID).build()) + .setWorkflowType(WorkflowType.newBuilder().setName(WORKFLOW_TYPE).build()) + .build(); + CountDownLatch blockPolls = new CountDownLatch(1); + when(blockingStub.pollWorkflowTaskQueue(any(PollWorkflowTaskQueueRequest.class))) + .thenReturn(pollResponse) + .thenAnswer( + (Answer) + invocation -> { + blockPolls.await(); + return null; + }); + + // The task is abandoned part way through, which is what stopping storage looks like. + when(taskHandler.handleWorkflowTask(any(PollWorkflowTaskQueueResponse.class))) + .thenAnswer( + (Answer) + invocation -> { + handlerEntered.countDown(); + try { + releaseHandler.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new CancellationException("Worker shutdown"); + }); + + assertTrue(worker.start()); + assertTrue(handlerEntered.await(10, TimeUnit.SECONDS)); + storageCancellation.cancel(); + CompletableFuture shutdown = worker.shutdown(new ShutdownManager(), true); + releaseHandler.countDown(); + + assertFalse( + "abandoning a task while shutting down must not surface as an error", + escaped.await(2, TimeUnit.SECONDS)); + verify(blockingStub, never()) + .respondWorkflowTaskFailed(any(RespondWorkflowTaskFailedRequest.class)); + assertEquals( + "a task abandoned while shutting down must not count as a failed task", + 0, + worker.getTaskCounter().getTotalFailed()); + shutdown.get(); + } + + @Test + public void storageBreakingDuringAForcedShutdownIsStillReported() throws Exception { + // Cancelling storage means we abandoned the work. Storage genuinely breaking at the same + // moment is a different thing and must not disappear with it. + TestStorageDriver driver = TestStorageDriver.create().failStores(1); + Payload result = Payload.newBuilder().setData(ByteString.copyFrom("result", UTF_8)).build(); + RespondWorkflowTaskCompletedRequest taskCompleted = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder() + .setResult(Payloads.newBuilder().addPayloads(result)))) + .build(); + CancelSource storageCancellation = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); + storageCancellation.cancel(); + + runOneTask( + driver, + new WorkflowTaskHandler.Result( + WORKFLOW_TYPE, taskCompleted, null, null, null, false, null, null), + storageCancellation.token(), + blockingStub -> + verify(blockingStub) + .respondWorkflowTaskFailed(any(RespondWorkflowTaskFailedRequest.class))); + + assertEquals("expected one injected store failure", 1, driver.injectedFailures.get()); + } + + @Test + public void payloadsInAFailedWorkflowTaskAreOffloaded() throws Exception { + TestStorageDriver driver = TestStorageDriver.create(); + Payload details = Payload.newBuilder().setData(ByteString.copyFrom("details", UTF_8)).build(); + RespondWorkflowTaskFailedRequest taskFailed = + RespondWorkflowTaskFailedRequest.newBuilder() + .setFailure( + Failure.newBuilder() + .setMessage("boom") + .setApplicationFailureInfo( + ApplicationFailureInfo.newBuilder() + .setDetails(Payloads.newBuilder().addPayloads(details)))) + .build(); + + ArgumentCaptor sent = + ArgumentCaptor.forClass(RespondWorkflowTaskFailedRequest.class); + runOneTask( + driver, + new WorkflowTaskHandler.Result( + WORKFLOW_TYPE, null, taskFailed, null, null, false, null, null), + blockingStub -> verify(blockingStub).respondWorkflowTaskFailed(sent.capture())); + + assertEquals("the failure details must be offloaded", 1, driver.storedCount()); + assertNotEquals( + "the failure details must be replaced by a reference", + details, + sent.getValue().getFailure().getApplicationFailureInfo().getDetails().getPayloads(0)); + } + + @Test + public void payloadsInADirectQueryResponseAreOffloaded() throws Exception { + TestStorageDriver driver = TestStorageDriver.create(); + Payload answer = Payload.newBuilder().setData(ByteString.copyFrom("answer", UTF_8)).build(); + RespondQueryTaskCompletedRequest queryCompleted = + RespondQueryTaskCompletedRequest.newBuilder() + .setQueryResult(Payloads.newBuilder().addPayloads(answer)) + .build(); + + ArgumentCaptor sent = + ArgumentCaptor.forClass(RespondQueryTaskCompletedRequest.class); + runOneTask( + driver, + new WorkflowTaskHandler.Result( + WORKFLOW_TYPE, null, null, queryCompleted, null, false, null, null), + blockingStub -> verify(blockingStub).respondQueryTaskCompleted(sent.capture())); + + assertEquals("the query answer must be offloaded", 1, driver.storedCount()); + assertNotEquals( + "the query answer must be replaced by a reference", + answer, + sent.getValue().getQueryResult().getPayloads(0)); + } + + @Test + public void anOversizedCompletionIsOffloadedRatherThanFailed() throws Exception { + TestStorageDriver driver = TestStorageDriver.create(); + + runOneTask( + driver, + completionSizeLimit(ONE_MEGABYTE), + oversizedCompletion(), + blockingStub -> { + verify(blockingStub) + .respondWorkflowTaskCompleted(any(RespondWorkflowTaskCompletedRequest.class)); + verify(blockingStub, never()) + .respondWorkflowTaskFailed(any(RespondWorkflowTaskFailedRequest.class)); + }); + + assertEquals("the oversized result must be offloaded", 1, driver.storedCount()); + } + + @Test + public void anOversizedCompletionStillFailsWithoutExternalStorage() throws Exception { + ArgumentCaptor sent = + ArgumentCaptor.forClass(RespondWorkflowTaskFailedRequest.class); + + runOneTask( + null, + completionSizeLimit(ONE_MEGABYTE), + oversizedCompletion(), + blockingStub -> { + verify(blockingStub).respondWorkflowTaskFailed(sent.capture()); + verify(blockingStub, never()) + .respondWorkflowTaskCompleted(any(RespondWorkflowTaskCompletedRequest.class)); + }); + + assertEquals( + WorkflowTaskFailedCause.WORKFLOW_TASK_FAILED_CAUSE_REQUEST_TOO_LARGE, + sent.getValue().getCause()); + } + + private static final int ONE_MEGABYTE = 1024 * 1024; + + private static NamespaceCapabilities completionSizeLimit(long limitBytes) { + NamespaceCapabilities capabilities = new NamespaceCapabilities(); + capabilities.setFromCapabilities( + NamespaceInfo.Capabilities.newBuilder().setWorkflowTaskCompletionPagination(true).build()); + capabilities.setFromLimits( + NamespaceInfo.Limits.newBuilder() + .setWorkflowTaskCompletionSizeLimitError(limitBytes) + .build()); + return capabilities; + } + + private static WorkflowTaskHandler.Result oversizedCompletion() { + Payload result = + Payload.newBuilder() + .setData(ByteString.copyFrom(new byte[WorkflowTaskCompletionPaginator.MAX_PAGE_BYTES])) + .build(); + RespondWorkflowTaskCompletedRequest taskCompleted = + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder() + .setResult(Payloads.newBuilder().addPayloads(result)))) + .build(); + return new WorkflowTaskHandler.Result( + WORKFLOW_TYPE, taskCompleted, null, null, null, false, null, null); + } + + /** Runs a single workflow task through a worker wired to {@code driver}, then verifies. */ + private void runOneTask( + TestStorageDriver driver, + WorkflowTaskHandler.Result handlerResult, + java.util.function.Consumer verification) + throws Exception { + runOneTask(driver, handlerResult, CancellationToken.none(), verification); + } + + private void runOneTask( + @Nullable TestStorageDriver driver, + NamespaceCapabilities namespaceCapabilities, + WorkflowTaskHandler.Result handlerResult, + java.util.function.Consumer verification) + throws Exception { + runOneTask( + driver, namespaceCapabilities, handlerResult, CancellationToken.none(), verification); + } + + private void runOneTask( + TestStorageDriver driver, + WorkflowTaskHandler.Result handlerResult, + CancellationToken storageCancellation, + java.util.function.Consumer verification) + throws Exception { + runOneTask( + driver, new NamespaceCapabilities(), handlerResult, storageCancellation, verification); + } + + private void runOneTask( + @Nullable TestStorageDriver driver, + NamespaceCapabilities namespaceCapabilities, + WorkflowTaskHandler.Result handlerResult, + CancellationToken storageCancellation, + java.util.function.Consumer verification) + throws Exception { + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + WorkflowRunLockManager runLockManager = new WorkflowRunLockManager(); + Scope metricsScope = + new RootScopeBuilder() + .reporter(reporter) + .reportEvery(com.uber.m3.util.Duration.ofMillis(1)); + WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLockManager, metricsScope); + WorkflowTaskHandler taskHandler = mock(WorkflowTaskHandler.class); + when(taskHandler.isAnyTypeSupported()).thenReturn(true); + + WorkflowWorker worker = + new WorkflowWorker( + client, + "default", + "task_queue", + "sticky_task_queue", + SingleWorkerOptions.newBuilder() + .setIdentity("test_identity") + .setBuildId(UUID.randomUUID().toString()) + .setWorkerInstanceKey(UUID.randomUUID().toString()) + .setPollerOptions( + PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) + .build()) + .setMetricsScope(metricsScope) + .setExternalStorageRunner( + driver == null + ? null + : ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(0) + .build())) + .setStorageCancellation(storageCancellation) + .build(), + runLockManager, + cache, + taskHandler, + mock(EagerActivityDispatcher.class), + 3, + new FixedSizeSlotSupplier<>(10), + namespaceCapabilities); + + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) + .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(client.blockingStub()).thenReturn(blockingStub); + when(client.futureStub()).thenReturn(futureStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + when(blockingStub.respondWorkflowTaskCompleted(any(RespondWorkflowTaskCompletedRequest.class))) + .thenReturn(RespondWorkflowTaskCompletedResponse.getDefaultInstance()); + + PollWorkflowTaskQueueResponse pollResponse = + PollWorkflowTaskQueueResponse.newBuilder() + .setTaskToken(ByteString.copyFrom("token", UTF_8)) + .setWorkflowExecution( + WorkflowExecution.newBuilder().setWorkflowId(WORKFLOW_ID).setRunId(RUN_ID).build()) + .setWorkflowType(WorkflowType.newBuilder().setName(WORKFLOW_TYPE).build()) + .build(); + CountDownLatch blockPolls = new CountDownLatch(1); + when(blockingStub.pollWorkflowTaskQueue(any(PollWorkflowTaskQueueRequest.class))) + .thenReturn(pollResponse) + .thenAnswer( + (Answer) + invocation -> { + blockPolls.await(); + return null; + }); + + CountDownLatch handled = new CountDownLatch(1); + when(taskHandler.handleWorkflowTask(any(PollWorkflowTaskQueueResponse.class))) + .thenAnswer( + (Answer) + invocation -> { + handled.countDown(); + return handlerResult; + }); + + assertTrue(worker.start()); + assertTrue(handled.await(10, TimeUnit.SECONDS)); + worker.shutdown(new ShutdownManager(), false).get(); + verification.accept(blockingStub); + } + + @Test + public void aStoreThatFailsWhileShuttingDownIsNotTreatedAsAProblem() throws Exception { + LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); + ListAppender logs = new ListAppender<>(); + logs.setContext(loggerContext); + logs.start(); + ch.qos.logback.classic.Logger workerLog = + loggerContext.getLogger(WorkflowWorker.class.getName()); + workerLog.addAppender(logs); + try { + WorkflowServiceStubs client = mock(WorkflowServiceStubs.class); + when(client.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.newBuilder().build()); + + WorkflowRunLockManager runLockManager = new WorkflowRunLockManager(); + Scope metricsScope = + new RootScopeBuilder() + .reporter(reporter) + .reportEvery(com.uber.m3.util.Duration.ofMillis(1)); + WorkflowExecutorCache cache = new WorkflowExecutorCache(10, runLockManager, metricsScope); + SlotSupplier slotSupplier = new FixedSizeSlotSupplier<>(10); + + WorkflowTaskHandler taskHandler = mock(WorkflowTaskHandler.class); + when(taskHandler.isAnyTypeSupported()).thenReturn(true); + + CountDownLatch storeEntered = new CountDownLatch(1); + CountDownLatch releaseStore = new CountDownLatch(1); + TestStorageDriver driver = + TestStorageDriver.create().blockStores(storeEntered, releaseStore).cancelStores(1); + CountDownLatch escaped = new CountDownLatch(1); + CancelSource storageCancellation = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); + + WorkflowWorker worker = + new WorkflowWorker( + client, + "default", + "task_queue", + "sticky_task_queue", + SingleWorkerOptions.newBuilder() + .setIdentity("test_identity") + .setBuildId(UUID.randomUUID().toString()) + .setWorkerInstanceKey(UUID.randomUUID().toString()) + .setPollerOptions( + PollerOptions.newBuilder() + .setPollerBehavior(new PollerBehaviorSimpleMaximum(1)) + .setUncaughtExceptionHandler((thread, error) -> escaped.countDown()) + .build()) + .setMetricsScope(metricsScope) + .setExternalStorageRunner( + ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(0) + .build())) + .setStorageCancellation(storageCancellation.token()) + .build(), + runLockManager, + cache, + taskHandler, + mock(EagerActivityDispatcher.class), + 3, + slotSupplier, + new NamespaceCapabilities()); + + WorkflowServiceGrpc.WorkflowServiceFutureStub futureStub = + mock(WorkflowServiceGrpc.WorkflowServiceFutureStub.class); + when(futureStub.shutdownWorker(any(ShutdownWorkerRequest.class))) + .thenReturn(Futures.immediateFuture(ShutdownWorkerResponse.newBuilder().build())); + WorkflowServiceGrpc.WorkflowServiceBlockingStub blockingStub = + mock(WorkflowServiceGrpc.WorkflowServiceBlockingStub.class); + when(client.blockingStub()).thenReturn(blockingStub); + when(client.futureStub()).thenReturn(futureStub); + when(blockingStub.withOption(any(), any())).thenReturn(blockingStub); + + PollWorkflowTaskQueueResponse pollResponse = + PollWorkflowTaskQueueResponse.newBuilder() + .setTaskToken(ByteString.copyFrom("token", UTF_8)) + .setWorkflowExecution( + WorkflowExecution.newBuilder() + .setWorkflowId(WORKFLOW_ID) + .setRunId(RUN_ID) + .build()) + .setWorkflowType(WorkflowType.newBuilder().setName(WORKFLOW_TYPE).build()) + .build(); + CountDownLatch pollTaskQueueLatch = new CountDownLatch(1); + CountDownLatch blockPollTaskQueueLatch = new CountDownLatch(1); + when(blockingStub.pollWorkflowTaskQueue(any(PollWorkflowTaskQueueRequest.class))) + .thenReturn(pollResponse) + .thenAnswer( + (Answer) + invocation -> { + pollTaskQueueLatch.countDown(); + blockPollTaskQueueLatch.await(); + return null; + }); + + when(taskHandler.handleWorkflowTask(any(PollWorkflowTaskQueueResponse.class))) + .thenAnswer( + (Answer) + invocation -> + new WorkflowTaskHandler.Result( + WORKFLOW_TYPE, + RespondWorkflowTaskCompletedRequest.newBuilder() + .addCommands( + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder() + .setResult( + Payloads.newBuilder() + .addPayloads( + Payload.newBuilder() + .setData( + ByteString.copyFrom( + "result", UTF_8)))))) + .build(), + null, + null, + null, + false, + null, + null)); + + assertTrue(worker.start()); + assertTrue(storeEntered.await(10, TimeUnit.SECONDS)); + + CompletableFuture shutdown = worker.shutdown(new ShutdownManager(), true); + storageCancellation.cancel(); + releaseStore.countDown(); + + assertFalse( + "a store that fails while shutting down must not surface as an error on the task", + escaped.await(2, TimeUnit.SECONDS)); + verify(blockingStub, never()) + .respondWorkflowTaskFailed(any(RespondWorkflowTaskFailedRequest.class)); + shutdown.get(); + + assertFalse( + "shutting down must not be logged as a failure to report progress", + logs.list.stream() + .anyMatch( + event -> + event.getLevel() == Level.WARN + && event.getMessage().contains("Failure while reporting"))); + } finally { + workerLog.detachAppender(logs); + logs.stop(); + } + } + + /** One driver for these tests: stores in memory, and can block or fail on demand. */ + @Test + public void deriveStorageTargetPointsACompletionAtItsParent() { + StorageDriverTargetInfo child = new StorageDriverWorkflowInfo("ns", "child", "run-1", "Child"); + StorageDriverTargetInfo parent = + new StorageDriverWorkflowInfo("ns", "parent", "parent-run", null); + Command command = + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder()) + .build(); + + assertEquals(parent, WorkflowWorker.deriveStorageTarget("ns", child, command, parent)); + } + + @Test + public void deriveStorageTargetKeepsACompletionOnItselfWithoutAParent() { + StorageDriverTargetInfo self = + new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "MyWorkflow"); + Command command = + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder()) + .build(); + + assertEquals(self, WorkflowWorker.deriveStorageTarget("ns", self, command, null)); + } + + @Test + public void deriveStorageTargetKeepsActivityCommandsOnTheWorkflow() { + StorageDriverTargetInfo workflowDefault = + new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "MyWorkflow"); + Command command = + Command.newBuilder() + .setScheduleActivityTaskCommandAttributes( + ScheduleActivityTaskCommandAttributes.newBuilder() + .setActivityId("act-1") + .setActivityType(ActivityType.newBuilder().setName("MyActivity"))) + .build(); + + assertEquals( + workflowDefault, WorkflowWorker.deriveStorageTarget("ns", workflowDefault, command)); + } + + @Test + public void deriveStorageTargetPointsChildWorkflowCommandsAtTheChild() { + StorageDriverTargetInfo parent = + new StorageDriverWorkflowInfo("ns", "parent", "parent-run", "Parent"); + Command command = + Command.newBuilder() + .setStartChildWorkflowExecutionCommandAttributes( + StartChildWorkflowExecutionCommandAttributes.newBuilder() + .setWorkflowId("child-1") + .setWorkflowType(WorkflowType.newBuilder().setName("Child"))) + .build(); + + assertEquals( + new StorageDriverWorkflowInfo("ns", "child-1", null, "Child"), + WorkflowWorker.deriveStorageTarget("ns", parent, command)); + } + + @Test + public void deriveStorageTargetPointsSignalCommandsAtTheTargetWorkflow() { + StorageDriverTargetInfo self = new StorageDriverWorkflowInfo("ns", "self", "self-run", "Self"); + Command command = + Command.newBuilder() + .setSignalExternalWorkflowExecutionCommandAttributes( + SignalExternalWorkflowExecutionCommandAttributes.newBuilder() + .setExecution( + WorkflowExecution.newBuilder() + .setWorkflowId("other") + .setRunId("other-run"))) + .build(); + + assertEquals( + new StorageDriverWorkflowInfo("ns", "other", "other-run", null), + WorkflowWorker.deriveStorageTarget("ns", self, command)); + } + + @Test + public void deriveStorageTargetPointsContinueAsNewAtTheNewRun() { + StorageDriverTargetInfo current = + new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "CurrentWorkflow"); + Command command = + Command.newBuilder() + .setContinueAsNewWorkflowExecutionCommandAttributes( + ContinueAsNewWorkflowExecutionCommandAttributes.newBuilder() + .setWorkflowType(WorkflowType.newBuilder().setName("NextWorkflow"))) + .build(); + + assertEquals( + new StorageDriverWorkflowInfo("ns", "wf-1", null, "NextWorkflow"), + WorkflowWorker.deriveStorageTarget("ns", current, command)); + } + + @Test + public void deriveStorageTargetKeepsWorkflowTypeForContinueAsNewWithoutOverride() { + StorageDriverTargetInfo current = + new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "CurrentWorkflow"); + Command command = + Command.newBuilder() + .setContinueAsNewWorkflowExecutionCommandAttributes( + ContinueAsNewWorkflowExecutionCommandAttributes.newBuilder()) + .build(); + + assertEquals( + new StorageDriverWorkflowInfo("ns", "wf-1", null, "CurrentWorkflow"), + WorkflowWorker.deriveStorageTarget("ns", current, command)); + } + + @Test + public void deriveStorageTargetKeepsTheCurrentTargetForOtherCommands() { + StorageDriverTargetInfo current = + new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "MyWorkflow"); + Command command = + Command.newBuilder() + .setCompleteWorkflowExecutionCommandAttributes( + CompleteWorkflowExecutionCommandAttributes.newBuilder()) + .build(); + + assertSame(current, WorkflowWorker.deriveStorageTarget("ns", current, command)); + } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LongLocalActivityWorkflowTaskHeartbeatTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LongLocalActivityWorkflowTaskHeartbeatTest.java index e307c86452..f04fdac410 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LongLocalActivityWorkflowTaskHeartbeatTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LongLocalActivityWorkflowTaskHeartbeatTest.java @@ -37,7 +37,7 @@ public void testLongLocalActivityWorkflowTaskHeartbeat() { WorkflowOptions options = WorkflowOptions.newBuilder() .setWorkflowRunTimeout(Duration.ofMinutes(5)) - .setWorkflowTaskTimeout(Duration.ofSeconds(2)) + .setWorkflowTaskTimeout(Duration.ofSeconds(5)) .setTaskQueue(testWorkflowRule.getTaskQueue()) .build(); TestWorkflow1 workflowStub = From 77b2db5109b04c7c4ee38a2145bec1504239f4ca Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Thu, 10 Sep 2026 17:08:47 -0400 Subject: [PATCH 092/107] External Storage Integration: Activity worker, client (#3020) * feat(extstore): integrate into activity worker, heartbeats, and client pipelines. * fix(extstore): derive external storage runner from data converter in TestActivityEnvironment * use ExternalStorageDataConverter, remove external storage client decorator, address some feedback points. * externalStorage -> externalStorageRunner * fix(extstore): send a RespondActivityTaskFailed when extstore fails to store. * refactor(extstore): add a client data converter factory so that we only have one exposed path for getting the data converter. * fix(extstore): fix storage targets and add a factory to consolidate logic. * fix(extstore): fix TestActivityEnvironment heartbeat listeners so they use extstore. * small nit refactor * a hanging store call will no longer block heartbeat cancellation * handle failures without extstore * tighten up heartbeat cancellation * getSummary and getDetails now use the correct namespace for decoding * offload headers to extstore * always use ExternalStrorageDataConverter * Revert "getSummary and getDetails now use the correct namespace for decoding" This reverts commit 6a6fbe5b600ff34add71625d5c435f10323154e4. * fix(extstore): fix path that was throwing ExternalStorageNotConfiguredException unconditionally. * increase test timeouts * another flakey test fix * add new category of tests for tests that are sensitive to timing and moved a few flakey tests there * more test fixes * fix flakey tests relying on real world timing * code formatting * fix more tests relying on wall clock time --- .../client/WorkflowClientInternalImpl.java | 9 +- .../client/WorkflowExecutionDescription.java | 13 +- .../ActivityExecutionContextFactoryImpl.java | 12 +- .../ActivityExecutionContextImpl.java | 13 +- .../activity/HeartbeatContextImpl.java | 134 ++++++++- .../internal/client/ActivityClientHelper.java | 36 +-- .../client/RootWorkflowClientInvoker.java | 207 ++++++++------ .../WorkflowClientDataConverterFactory.java | 37 +++ ...ManualActivityCompletionClientFactory.java | 23 +- ...alActivityCompletionClientFactoryImpl.java | 53 +++- .../ManualActivityCompletionClientImpl.java | 97 ++++--- .../storage/ActivityStorageTargets.java | 61 ++++ .../storage/ExternalStorageDataConverter.java | 19 +- .../storage/ExternalStorageRunner.java | 11 +- .../internal/worker/ActivityWorker.java | 147 +++++++++- .../internal/worker/SyncActivityWorker.java | 3 +- .../client/WorkflowExecutionMetadataTest.java | 103 +++++++ .../ActivityExecutionContextImplTest.java | 76 +++++ .../activity/HeartbeatContextImplTest.java | 184 +++++++++++- ...orkflowClientInvokerStorageTargetTest.java | 265 ++++++++++++++++++ ...pletionClientFactoryStorageTargetTest.java | 159 +++++++++++ ...anualActivityCompletionClientImplTest.java | 146 ++++++++++ .../ExternalStorageDataConverterTest.java | 41 +++ .../storage/ExternalStorageRunnerTest.java | 45 +++ .../ActivityTestingExternalStorageTest.java | 125 +++++++++ ...ivityWorkerExternalStorageFailureTest.java | 186 ++++++++++++ .../internal/worker/ActivityWorkerTest.java | 88 ++++++ .../StickyWorkflowDrainShutdownTest.java | 4 - .../EagerActivityDispatchingTest.java | 2 +- .../activityTests/TryCancelActivityTest.java | 35 ++- .../AbandonOnCancelActivityTest.java | 10 +- ...ogWithWorkflowExecutionExceptionsTest.java | 83 +----- ...ngWorkflowQueryReplaysDontSpamLogTest.java | 125 +++++++++ .../workflow/shared/TestActivities.java | 6 + .../TestActivityEnvironmentInternal.java | 36 ++- 35 files changed, 2279 insertions(+), 315 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientDataConverterFactory.java create mode 100644 temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ActivityStorageTargets.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/WorkflowExecutionMetadataTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/activity/ActivityExecutionContextImplTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerStorageTargetTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryStorageTargetTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientImplTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/testing/ActivityTestingExternalStorageTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerExternalStorageFailureTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/queryTests/RunningWorkflowQueryReplaysDontSpamLogTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java index 0b892425e0..a3b92aa219 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowClientInternalImpl.java @@ -113,8 +113,9 @@ public static WorkflowClient newInstance( .getMetricsScope() .tagged(MetricsTag.defaultTags(options.getNamespace())); ExternalStorage externalStorage = options.getExternalStorage(); - this.externalStorageRunner = + ExternalStorageRunner externalStorageRunner = externalStorage == null ? null : ExternalStorageRunner.create(externalStorage); + this.externalStorageRunner = externalStorageRunner; this.genericClient = new GenericWorkflowClientImpl(workflowServiceStubs, metricsScope); this.interceptors = options.getInterceptors(); this.workflowClientCallsInvoker = initializeClientInvoker(); @@ -123,7 +124,8 @@ public static WorkflowClient newInstance( workflowServiceStubs, options.getNamespace(), options.getIdentity(), - options.getDataConverter()); + options.getDataConverter(), + externalStorageRunner); java.time.Duration heartbeatInterval = options.getWorkerHeartbeatInterval(); if (!heartbeatInterval.isNegative()) { @@ -139,7 +141,8 @@ public static WorkflowClient newInstance( private WorkflowClientCallsInterceptor initializeClientInvoker() { WorkflowClientCallsInterceptor workflowClientInvoker = - new RootWorkflowClientInvoker(genericClient, options, workerFactoryRegistry); + new RootWorkflowClientInvoker( + genericClient, options, workerFactoryRegistry, externalStorageRunner); for (WorkflowClientInterceptor clientInterceptor : interceptors) { workflowClientInvoker = clientInterceptor.workflowClientCallsInterceptor(workflowClientInvoker); diff --git a/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java index 6f54031a97..37122381c7 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/WorkflowExecutionDescription.java @@ -1,5 +1,6 @@ package io.temporal.client; +import io.temporal.api.common.v1.Payload; import io.temporal.api.workflowservice.v1.DescribeWorkflowExecutionResponse; import io.temporal.common.converter.DataConverter; import io.temporal.payload.context.WorkflowSerializationContext; @@ -29,15 +30,13 @@ public String getStaticSummary() { if (!response.getExecutionConfig().getUserMetadata().hasSummary()) { return null; } + Payload summary = response.getExecutionConfig().getUserMetadata().getSummary(); return dataConverter .withContext( new WorkflowSerializationContext( response.getWorkflowExecutionInfo().getParentNamespaceId(), response.getWorkflowExecutionInfo().getExecution().getWorkflowId())) - .fromPayload( - response.getExecutionConfig().getUserMetadata().getSummary(), - String.class, - String.class); + .fromPayload(summary, String.class, String.class); } /** @@ -51,15 +50,13 @@ public String getStaticDetails() { if (!response.getExecutionConfig().getUserMetadata().hasDetails()) { return null; } + Payload details = response.getExecutionConfig().getUserMetadata().getDetails(); return dataConverter .withContext( new WorkflowSerializationContext( response.getWorkflowExecutionInfo().getParentNamespaceId(), response.getWorkflowExecutionInfo().getExecution().getWorkflowId())) - .fromPayload( - response.getExecutionConfig().getUserMetadata().getDetails(), - String.class, - String.class); + .fromPayload(details, String.class, String.class); } /** Returns the raw response from the Temporal service. */ diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java index 4acc1d17dd..5f9584180f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextFactoryImpl.java @@ -4,6 +4,7 @@ import io.temporal.client.WorkflowClient; import io.temporal.common.converter.DataConverter; import io.temporal.internal.client.external.ManualActivityCompletionClientFactory; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import java.nio.ByteBuffer; import java.time.Duration; import java.util.Arrays; @@ -11,6 +12,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledExecutorService; +import javax.annotation.Nullable; public class ActivityExecutionContextFactoryImpl implements ActivityExecutionContextFactory { private final WorkflowClient client; @@ -21,6 +23,7 @@ public class ActivityExecutionContextFactoryImpl implements ActivityExecutionCon private final DataConverter dataConverter; private final ScheduledExecutorService heartbeatExecutor; private final ManualActivityCompletionClientFactory manualCompletionClientFactory; + private final @Nullable ExternalStorageRunner externalStorage; private final ConcurrentMap activeContexts = new ConcurrentHashMap<>(); @@ -31,7 +34,8 @@ public ActivityExecutionContextFactoryImpl( Duration maxHeartbeatThrottleInterval, Duration defaultHeartbeatThrottleInterval, DataConverter dataConverter, - ScheduledExecutorService heartbeatExecutor) { + ScheduledExecutorService heartbeatExecutor, + @Nullable ExternalStorageRunner externalStorage) { this.client = Objects.requireNonNull(client); this.identity = identity; this.namespace = Objects.requireNonNull(namespace); @@ -40,9 +44,10 @@ public ActivityExecutionContextFactoryImpl( Objects.requireNonNull(defaultHeartbeatThrottleInterval); this.dataConverter = Objects.requireNonNull(dataConverter); this.heartbeatExecutor = Objects.requireNonNull(heartbeatExecutor); + this.externalStorage = externalStorage; this.manualCompletionClientFactory = ManualActivityCompletionClientFactory.newFactory( - client.getWorkflowServiceStubs(), namespace, identity, dataConverter); + client.getWorkflowServiceStubs(), namespace, identity, dataConverter, externalStorage); } @Override @@ -63,7 +68,8 @@ public InternalActivityExecutionContext createContext( identity, maxHeartbeatThrottleInterval, defaultHeartbeatThrottleInterval, - () -> cleanupContext(info.getTaskToken(), false)); + () -> cleanupContext(info.getTaskToken(), false), + externalStorage); activeContexts.put(taskToken, context); return context; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java index 40fe45c326..64cdd75383 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/ActivityExecutionContextImpl.java @@ -10,6 +10,7 @@ import io.temporal.common.CancellationToken; import io.temporal.common.converter.DataConverter; import io.temporal.internal.client.external.ManualActivityCompletionClientFactory; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.payload.context.ActivitySerializationContext; import io.temporal.workflow.Functions; import java.lang.reflect.Type; @@ -18,6 +19,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; /** @@ -55,7 +57,8 @@ class ActivityExecutionContextImpl implements InternalActivityExecutionContext { String identity, Duration maxHeartbeatThrottleInterval, Duration defaultHeartbeatThrottleInterval, - Functions.Proc closeCallback) { + Functions.Proc closeCallback, + @Nullable ExternalStorageRunner externalStorage) { this.client = client; this.activity = activity; this.metricsScope = metricsScope; @@ -73,7 +76,8 @@ class ActivityExecutionContextImpl implements InternalActivityExecutionContext { metricsScope, identity, maxHeartbeatThrottleInterval, - defaultHeartbeatThrottleInterval); + defaultHeartbeatThrottleInterval, + externalStorage); } /** @@ -155,7 +159,10 @@ public ManualActivityCompletionClient useLocalManualCompletion() { new ActivitySerializationContext(info); return new CompletionAwareManualCompletionClient( manualCompletionClientFactory.getClient( - info.getTaskToken(), metricsScope, activitySerializationContext), + info.getTaskToken(), + metricsScope, + activitySerializationContext, + HeartbeatContextImpl.storageTargetForActivity(info.getNamespace(), info)), completionHandle); } finally { lock.unlock(); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java index 91da94ab0a..c41f3740d9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/activity/HeartbeatContextImpl.java @@ -1,5 +1,8 @@ package io.temporal.internal.activity; +import com.google.common.base.Strings; +import com.google.common.base.Throwables; +import com.google.protobuf.ByteString; import com.uber.m3.tally.Scope; import io.grpc.Status; import io.grpc.StatusRuntimeException; @@ -7,6 +10,7 @@ import io.temporal.activity.ActivityInfo; import io.temporal.api.common.v1.Payloads; import io.temporal.api.enums.v1.TimeoutType; +import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatResponse; import io.temporal.client.*; import io.temporal.common.CancellationToken; @@ -14,22 +18,38 @@ import io.temporal.failure.TimeoutFailure; import io.temporal.internal.client.ActivityClientHelper; import io.temporal.internal.concurrent.structured.CancelSource; +import io.temporal.internal.payload.storage.ActivityStorageTargets; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.serviceclient.WorkflowServiceStubs; import java.lang.reflect.Type; import java.time.Duration; import java.util.Optional; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import javax.annotation.Nullable; import javax.annotation.concurrent.ThreadSafe; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @ThreadSafe class HeartbeatContextImpl implements HeartbeatContext { + private static final class HeartbeatAbandonedException extends RuntimeException { + HeartbeatAbandonedException() { + super(null, null, false, false); + } + } + private static final Logger log = LoggerFactory.getLogger(HeartbeatContextImpl.class); private static final long HEARTBEAT_RETRY_WAIT_MILLIS = 1000; // Buffer added to the heartbeat timeout to avoid racing with the server's own timeout tracking. @@ -58,6 +78,7 @@ static long getLocalHeartbeatTimeoutBufferMillis() { private final long heartbeatIntervalMillis; private final DataConverter dataConverter; private final DataConverter dataConverterWithActivityContext; + private final @Nullable ExternalStorageRunner externalStorage; private final Scope metricsScope; private final Optional prevAttemptHeartbeatDetails; @@ -76,6 +97,9 @@ static long getLocalHeartbeatTimeoutBufferMillis() { private boolean heartbeatTimedOut; private boolean rejectNewHeartbeats; + private volatile CompletableFuture outstandingOffloadAbandon; + private final AtomicInteger pendingAbandons = new AtomicInteger(); + private ActivityCompletionException lastException; private final CancelSource cancellationSource = new CancelSource<>(ActivityCanceledException::new); @@ -89,7 +113,8 @@ public HeartbeatContextImpl( Scope metricsScope, String identity, Duration maxHeartbeatThrottleInterval, - Duration defaultHeartbeatThrottleInterval) { + Duration defaultHeartbeatThrottleInterval, + @Nullable ExternalStorageRunner externalStorage) { this( service, namespace, @@ -100,6 +125,7 @@ public HeartbeatContextImpl( identity, maxHeartbeatThrottleInterval, defaultHeartbeatThrottleInterval, + externalStorage, getLocalHeartbeatTimeoutBufferMillis()); } @@ -113,10 +139,12 @@ public HeartbeatContextImpl( String identity, Duration maxHeartbeatThrottleInterval, Duration defaultHeartbeatThrottleInterval, + @Nullable ExternalStorageRunner externalStorage, long localHeartbeatTimeoutBufferMillis) { this.service = service; this.metricsScope = metricsScope; this.dataConverter = dataConverter; + this.externalStorage = externalStorage; this.dataConverterWithActivityContext = dataConverter.withContext( new ActivitySerializationContext( @@ -151,7 +179,9 @@ public void heartbeat(V details) throws ActivityCompletionException { if (heartbeatExecutor.isShutdown()) { throw new ActivityWorkerShutdownException(info); } + requestOffloadAbandon(); lock.lock(); + pendingAbandons.decrementAndGet(); try { checkHeartbeatTimeoutDeadlineLocked(); if (rejectNewHeartbeats) { @@ -224,7 +254,9 @@ public Object getLatestHeartbeatDetails() { @Override public void cancelOutstandingHeartbeat() { + requestOffloadAbandon(); lock.lock(); + pendingAbandons.decrementAndGet(); try { if (scheduledHeartbeat != null) { scheduledHeartbeat.cancel(false); @@ -239,7 +271,9 @@ public void cancelOutstandingHeartbeat() { @Override public void cancelFromWorkerCommand() { + requestOffloadAbandon(); lock.lock(); + pendingAbandons.decrementAndGet(); try { requestCancelLocked(); } finally { @@ -274,6 +308,9 @@ private void doHeartBeatLocked(Object details) { if (heartbeatTimeoutDeadlineNanos != 0) { heartbeatTimeoutDeadlineNanos = computeHeartbeatTimeoutDeadlineNanos(); } + } catch (HeartbeatAbandonedException e) { + scheduledHeartbeat = null; + return; } catch (StatusRuntimeException e) { // Not rethrowing to not fail activity implementation on intermittent connection or Temporal // errors. @@ -330,16 +367,97 @@ private void checkHeartbeatTimeoutDeadlineLocked() { } } + private StorageDriverTargetInfo activityStorageTarget() { + return storageTargetForActivity(namespace, info); + } + + /** + * Standalone activities target the activity; workflow activities target their workflow, matching + * where {@link io.temporal.internal.worker.ActivityWorker} stores the activity task payloads. A + * non-empty {@code activityRunId} marks a standalone activity. + */ + static StorageDriverTargetInfo storageTargetForActivity(String namespace, ActivityInfo info) { + return ActivityStorageTargets.newBuilder(namespace) + .setActivity(info.getActivityId(), info.getActivityRunId(), info.getActivityType()) + .setWorkflow( + Strings.emptyToNull(info.getWorkflowId()), + Strings.emptyToNull(info.getWorkflowRunId()), + info.getWorkflowType()) + .build(); + } + + /** + * Offloads large heartbeat payloads aborting if the store call runs longer than the heartbeat + * interval, if a newer heartbeat supersedes this one, or if the activity is cancelled. + */ + private void offloadHeartbeat(RecordActivityTaskHeartbeatRequest.Builder builder) { + CancelSource offloadCancel = + new CancelSource<>(CancellationException::new); + CompletableFuture abandon = new CompletableFuture<>(); + CancellationToken.Registration onActivityCancel = + cancellationSource + .token() + .onCancel( + () -> { + offloadCancel.cancel(); + abandon.complete(null); + }); + outstandingOffloadAbandon = abandon; + try { + if (pendingAbandons.get() > 0) { + throw new HeartbeatAbandonedException(); + } + CompletableFuture store = + externalStorage.storeAsync(builder, activityStorageTarget(), null, offloadCancel.token()); + CompletableFuture.anyOf(store, abandon).get(heartbeatIntervalMillis, TimeUnit.MILLISECONDS); + if (!store.isDone()) { + offloadCancel.cancel(); + throw new HeartbeatAbandonedException(); + } + store.get(); + } catch (TimeoutException e) { + offloadCancel.cancel(); + throw new CancellationException( + "External storage did not store the heartbeat details within the heartbeat interval"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + offloadCancel.cancel(); + CancellationException cancelled = + new CancellationException("External storage store interrupted"); + cancelled.initCause(e); + throw cancelled; + } catch (ExecutionException e) { + Throwable cause = e.getCause() != null ? e.getCause() : e; + Throwables.throwIfUnchecked(cause); + throw new CompletionException(cause); + } finally { + outstandingOffloadAbandon = null; + onActivityCancel.close(); + } + } + + private void requestOffloadAbandon() { + pendingAbandons.incrementAndGet(); + CompletableFuture outstanding = outstandingOffloadAbandon; + if (outstanding != null) { + outstanding.complete(null); + } + } + private void sendHeartbeatRequest(Object details) { try { + RecordActivityTaskHeartbeatRequest.Builder builder = + RecordActivityTaskHeartbeatRequest.newBuilder() + .setTaskToken(ByteString.copyFrom(info.getTaskToken())) + .setNamespace(namespace) + .setIdentity(identity); + dataConverterWithActivityContext.toPayloads(details).ifPresent(builder::setDetails); + if (externalStorage != null) { + offloadHeartbeat(builder); + } + RecordActivityTaskHeartbeatRequest request = builder.build(); RecordActivityTaskHeartbeatResponse status = - ActivityClientHelper.sendHeartbeatRequest( - service, - namespace, - identity, - info.getTaskToken(), - dataConverterWithActivityContext.toPayloads(details), - metricsScope); + ActivityClientHelper.sendHeartbeatRequest(service, request, metricsScope); if (status.getCancelRequested()) { requestCancelLocked(); } else if (status.getActivityReset()) { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientHelper.java b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientHelper.java index eb3e98107c..cedbf38684 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientHelper.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityClientHelper.java @@ -2,19 +2,13 @@ import static io.temporal.serviceclient.MetricsTag.METRICS_TAGS_CALL_OPTIONS_KEY; -import com.google.common.base.Preconditions; -import com.google.protobuf.ByteString; import com.uber.m3.tally.Scope; import io.temporal.activity.ManualActivityCompletionClient; -import io.temporal.api.common.v1.Payloads; -import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatByIdRequest; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatByIdResponse; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatResponse; import io.temporal.serviceclient.WorkflowServiceStubs; -import java.util.Optional; -import javax.annotation.Nonnull; /** * Contains methods that could but didn't become a part of the main {@link @@ -26,43 +20,21 @@ private ActivityClientHelper() {} public static RecordActivityTaskHeartbeatResponse sendHeartbeatRequest( WorkflowServiceStubs service, - String namespace, - String identity, - byte[] taskToken, - Optional payloads, + RecordActivityTaskHeartbeatRequest request, Scope metricsScope) { - RecordActivityTaskHeartbeatRequest.Builder request = - RecordActivityTaskHeartbeatRequest.newBuilder() - .setTaskToken(ByteString.copyFrom(taskToken)) - .setNamespace(namespace) - .setIdentity(identity); - payloads.ifPresent(request::setDetails); return service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .recordActivityTaskHeartbeat(request.build()); + .recordActivityTaskHeartbeat(request); } public static RecordActivityTaskHeartbeatByIdResponse recordActivityTaskHeartbeatById( WorkflowServiceStubs service, - String namespace, - String identity, - WorkflowExecution execution, - @Nonnull String activityId, - Optional payloads, + RecordActivityTaskHeartbeatByIdRequest request, Scope metricsScope) { - Preconditions.checkNotNull(activityId, "Either activity id or task token are required"); - RecordActivityTaskHeartbeatByIdRequest.Builder request = - RecordActivityTaskHeartbeatByIdRequest.newBuilder() - .setRunId(execution.getRunId()) - .setWorkflowId(execution.getWorkflowId()) - .setActivityId(activityId) - .setNamespace(namespace) - .setIdentity(identity); - payloads.ifPresent(request::setDetails); return service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .recordActivityTaskHeartbeatById(request.build()); + .recordActivityTaskHeartbeatById(request); } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java index 121c62fc19..d3b3321ebe 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootWorkflowClientInvoker.java @@ -5,11 +5,14 @@ import static io.temporal.internal.common.HeaderUtils.intoPayloadMap; import static io.temporal.internal.common.WorkflowExecutionUtils.makeUserMetaData; +import com.google.common.base.Strings; import com.google.common.collect.Iterators; import io.grpc.Deadline; import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.temporal.api.common.v1.*; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.enums.v1.QueryRejectCondition; import io.temporal.api.enums.v1.UpdateWorkflowExecutionLifecycleStage; import io.temporal.api.enums.v1.WorkflowExecutionStatus; import io.temporal.api.errordetails.v1.MultiOperationExecutionFailure; @@ -19,6 +22,7 @@ import io.temporal.api.update.v1.*; import io.temporal.api.workflowservice.v1.*; import io.temporal.client.*; +import io.temporal.common.CancellationToken; import io.temporal.common.converter.DataConverter; import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; @@ -28,8 +32,9 @@ import io.temporal.internal.nexus.InternalNexusOperationContext; import io.temporal.internal.nexus.NexusOperationMetadata; import io.temporal.internal.nexus.OperationTokenUtil; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.worker.WorkerVersioningProtoUtils; -import io.temporal.payload.context.WorkflowSerializationContext; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; import io.temporal.serviceclient.StatusUtils; import io.temporal.worker.WorkflowTaskDispatchHandle; import java.lang.reflect.Type; @@ -47,28 +52,73 @@ public class RootWorkflowClientInvoker implements WorkflowClientCallsInterceptor private static final long POLL_UPDATE_TIMEOUT_S = 60L; private final GenericWorkflowClient genericClient; - private final WorkflowClientOptions clientOptions; + private final String namespace; + private final String identity; + private final QueryRejectCondition queryRejectCondition; private final EagerWorkflowTaskDispatcher eagerWorkflowTaskDispatcher; private final WorkflowClientRequestFactory requestsHelper; + private final WorkflowClientDataConverterFactory converterFactory; + private final @Nullable ExternalStorageRunner externalStorage; public RootWorkflowClientInvoker( GenericWorkflowClient genericClient, WorkflowClientOptions clientOptions, WorkerFactoryRegistry workerFactoryRegistry) { + this(genericClient, clientOptions, workerFactoryRegistry, null); + } + + public RootWorkflowClientInvoker( + GenericWorkflowClient genericClient, + WorkflowClientOptions clientOptions, + WorkerFactoryRegistry workerFactoryRegistry, + @Nullable ExternalStorageRunner externalStorage) { + this.converterFactory = new WorkflowClientDataConverterFactory(clientOptions, externalStorage); + this.externalStorage = externalStorage; this.genericClient = genericClient; - this.clientOptions = clientOptions; + this.namespace = clientOptions.getNamespace(); + this.identity = clientOptions.getIdentity(); + this.queryRejectCondition = clientOptions.getQueryRejectCondition(); this.eagerWorkflowTaskDispatcher = new EagerWorkflowTaskDispatcher(workerFactoryRegistry); this.requestsHelper = new WorkflowClientRequestFactory(clientOptions); } + private DataConverter workflowConverter(WorkflowExecution execution) { + return workflowConverter(execution, null); + } + + private DataConverter workflowConverter( + WorkflowExecution execution, @Nullable String workflowType) { + return workflowConverter(execution.getWorkflowId(), execution.getRunId(), workflowType); + } + + private DataConverter workflowConverter( + String workflowId, @Nullable String runId, @Nullable String workflowType) { + return converterFactory.forWorkflow(workflowId, runId, workflowType); + } + + private void storeHeader( + Header.Builder header, + String workflowId, + @Nullable String runId, + @Nullable String workflowType) { + if (externalStorage == null || header.getFieldsCount() == 0) { + return; + } + externalStorage.store( + header, + new StorageDriverWorkflowInfo( + namespace, + Strings.emptyToNull(workflowId), + Strings.emptyToNull(runId), + Strings.emptyToNull(workflowType)), + null, + CancellationToken.none()); + } + @Override public WorkflowStartOutput start(WorkflowStartInput input) { DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowId())); + workflowConverter(input.getWorkflowId(), null, input.getWorkflowType()); StartWorkflowExecutionRequest.Builder startRequest = toStartRequest(dataConverterWithWorkflowContext, input); @@ -126,8 +176,8 @@ public WorkflowSignalOutput signal(WorkflowSignalInput input) { SignalWorkflowExecutionRequest.newBuilder() .setSignalName(input.getSignalName()) .setWorkflowExecution(input.getWorkflowExecution()) - .setIdentity(clientOptions.getIdentity()) - .setNamespace(clientOptions.getNamespace()) + .setIdentity(identity) + .setNamespace(namespace) .setRequestId(UUID.randomUUID().toString()) .setHeader(HeaderUtils.toHeaderGrpc(input.getHeader(), null)); @@ -138,15 +188,15 @@ public WorkflowSignalOutput signal(WorkflowSignalInput input) { request.addAllLinks(CurrentNexusOperationContext.get().getRequestLinks()); } - DataConverter dataConverterWitSignalContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + DataConverter dataConverterWitSignalContext = workflowConverter(input.getWorkflowExecution()); Optional inputArgs = dataConverterWitSignalContext.toPayloads(input.getArguments()); inputArgs.ifPresent(request::setInput); + storeHeader( + request.getHeaderBuilder(), + input.getWorkflowExecution().getWorkflowId(), + input.getWorkflowExecution().getRunId(), + null); SignalWorkflowExecutionResponse response = genericClient.signal(request.build()); // Server >=1.31 with EnableCHASMSignalBacklinks returns a response link pointing at the signal // event; older servers leave it unset. Propagate when present. @@ -161,11 +211,8 @@ public WorkflowSignalWithStartOutput signalWithStart(WorkflowSignalWithStartInpu WorkflowStartInput workflowStartInput = input.getWorkflowStartInput(); DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), workflowStartInput.getWorkflowId())); + workflowConverter( + workflowStartInput.getWorkflowId(), null, workflowStartInput.getWorkflowType()); StartWorkflowExecutionRequestOrBuilder startRequest = toStartRequest(dataConverterWithWorkflowContext, workflowStartInput); @@ -204,15 +251,11 @@ public WorkflowUpdateWithStartOutput updateWithStart( WorkflowStartInput startInput = input.getWorkflowStartInput(); DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), startInput.getWorkflowId())); + workflowConverter(startInput.getWorkflowId(), null, startInput.getWorkflowType()); ExecuteMultiOperationRequest request = ExecuteMultiOperationRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) + .setNamespace(namespace) .addOperations( 0, ExecuteMultiOperationRequest.Operation.newBuilder() @@ -342,24 +385,27 @@ private StartWorkflowExecutionRequest.Builder toStartRequest( workflowStartInput.getOptions().getStaticDetails(), dataConverterWithWorkflowContext); - return requestsHelper.newStartWorkflowExecutionRequest( + StartWorkflowExecutionRequest.Builder startRequest = + requestsHelper.newStartWorkflowExecutionRequest( + workflowStartInput.getWorkflowId(), + workflowStartInput.getWorkflowType(), + workflowStartInput.getHeader(), + workflowStartInput.getOptions(), + workflowInput.orElse(null), + memo, + userMetadata); + storeHeader( + startRequest.getHeaderBuilder(), workflowStartInput.getWorkflowId(), - workflowStartInput.getWorkflowType(), - workflowStartInput.getHeader(), - workflowStartInput.getOptions(), - workflowInput.orElse(null), - memo, - userMetadata); + null, + workflowStartInput.getWorkflowType()); + return startRequest; } @Override public GetResultOutput getResult(GetResultInput input) throws TimeoutException { DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter(input.getWorkflowExecution()); Optional resultValue = WorkflowClientLongPollHelper.getWorkflowExecutionResult( genericClient, @@ -380,11 +426,7 @@ public GetResultOutput getResult(GetResultInput input) throws TimeoutE @Override public GetResultAsyncOutput getResultAsync(GetResultInput input) { DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter(input.getWorkflowExecution()); CompletableFuture> resultValue = WorkflowClientLongPollAsyncHelper.getWorkflowExecutionResultAsync( genericClient, @@ -411,24 +453,25 @@ public QueryOutput query(QueryInput input) { .setQueryType(input.getQueryType()) .setHeader(HeaderUtils.toHeaderGrpc(input.getHeader(), null)); DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter(input.getWorkflowExecution()); Optional inputArgs = dataConverterWithWorkflowContext.toPayloads(input.getArguments()); inputArgs.ifPresent(query::setQueryArgs); + storeHeader( + query.getHeaderBuilder(), + input.getWorkflowExecution().getWorkflowId(), + input.getWorkflowExecution().getRunId(), + null); QueryWorkflowRequest request = QueryWorkflowRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) + .setNamespace(namespace) .setExecution( WorkflowExecution.newBuilder() .setWorkflowId(input.getWorkflowExecution().getWorkflowId()) .setRunId(input.getWorkflowExecution().getRunId())) .setQuery(query) - .setQueryRejectCondition(clientOptions.getQueryRejectCondition()) + .setQueryRejectCondition(queryRejectCondition) .build(); QueryWorkflowResponse result; @@ -459,11 +502,7 @@ public QueryOutput query(QueryInput input) { @Override public WorkflowUpdateHandle startUpdate(StartUpdateInput input) { DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter(input.getWorkflowExecution()); UpdateWorkflowExecutionRequest updateRequest = toUpdateWorkflowExecutionRequest(input, dataConverterWithWorkflowContext); @@ -519,13 +558,15 @@ private UpdateWorkflowExecutionRequest toUpdateWorkflowExecutionRequest( .setHeader(HeaderUtils.toHeaderGrpc(input.getHeader(), null)) .setName(input.getUpdateName()); inputArgs.ifPresent(updateInput::setArgs); + storeHeader( + updateInput.getHeaderBuilder(), + input.getWorkflowExecution().getWorkflowId(), + input.getWorkflowExecution().getRunId(), + null); Request.Builder requestBuilder = Request.newBuilder() - .setMeta( - Meta.newBuilder() - .setUpdateId(input.getUpdateId()) - .setIdentity(clientOptions.getIdentity())) + .setMeta(Meta.newBuilder().setUpdateId(input.getUpdateId()).setIdentity(identity)) .setInput(updateInput); // If this update is being issued via TemporalNexusClientImpl.startWorkflowUpdate, @@ -538,7 +579,7 @@ private UpdateWorkflowExecutionRequest toUpdateWorkflowExecutionRequest( try { nexusOperationMetadata.operationToken = OperationTokenUtil.generateWorkflowUpdateOperationToken( - clientOptions.getNamespace(), + namespace, input.getWorkflowExecution().getWorkflowId(), input.getWorkflowExecution().getRunId(), input.getUpdateId()); @@ -563,7 +604,7 @@ private UpdateWorkflowExecutionRequest toUpdateWorkflowExecutionRequest( Request request = requestBuilder.build(); return UpdateWorkflowExecutionRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) + .setNamespace(namespace) .setWaitPolicy(input.getWaitPolicy()) .setWorkflowExecution( WorkflowExecution.newBuilder() @@ -630,11 +671,7 @@ private WorkflowUpdateHandle toUpdateHandle( @Override public PollWorkflowUpdateOutput pollWorkflowUpdate(PollWorkflowUpdateInput input) { DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter(input.getWorkflowExecution()); UpdateRef update = UpdateRef.newBuilder() @@ -651,8 +688,8 @@ public PollWorkflowUpdateOutput pollWorkflowUpdate(PollWorkflowUpdateInpu PollWorkflowExecutionUpdateRequest pollUpdateRequest = PollWorkflowExecutionUpdateRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) - .setIdentity(clientOptions.getIdentity()) + .setNamespace(namespace) + .setIdentity(identity) .setUpdateRef(update) .setWaitPolicy(waitPolicy) .build(); @@ -728,8 +765,8 @@ public CancelOutput cancel(CancelInput input) { RequestCancelWorkflowExecutionRequest.newBuilder() .setRequestId(UUID.randomUUID().toString()) .setWorkflowExecution(input.getWorkflowExecution()) - .setNamespace(clientOptions.getNamespace()) - .setIdentity(clientOptions.getIdentity()); + .setNamespace(namespace) + .setIdentity(identity); if (input.getReason() != null) { request.setReason(input.getReason()); } @@ -744,8 +781,8 @@ public CancelOutput cancel(CancelInput input) { public TerminateOutput terminate(TerminateInput input) { TerminateWorkflowExecutionRequest.Builder request = TerminateWorkflowExecutionRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) - .setIdentity(clientOptions.getIdentity()) + .setNamespace(namespace) + .setIdentity(identity) .setWorkflowExecution(input.getWorkflowExecution()); if (input.getReason() != null) { request.setReason(input.getReason()); @@ -754,11 +791,7 @@ public TerminateOutput terminate(TerminateInput input) { request.setFirstExecutionRunId(input.getFirstExecutionRunId()); } DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter(input.getWorkflowExecution()); Optional payloads = dataConverterWithWorkflowContext.toPayloads(input.getDetails()); payloads.ifPresent(request::setDetails); genericClient.terminate(request.build()); @@ -770,16 +803,14 @@ public DescribeWorkflowOutput describe(DescribeWorkflowInput input) { DescribeWorkflowExecutionResponse response = genericClient.describeWorkflowExecution( DescribeWorkflowExecutionRequest.newBuilder() - .setNamespace(clientOptions.getNamespace()) + .setNamespace(namespace) .setExecution(input.getWorkflowExecution()) .build()); DataConverter dataConverterWithWorkflowContext = - clientOptions - .getDataConverter() - .withContext( - new WorkflowSerializationContext( - clientOptions.getNamespace(), input.getWorkflowExecution().getWorkflowId())); + workflowConverter( + response.getWorkflowExecutionInfo().getExecution(), + response.getWorkflowExecutionInfo().getType().getName()); return new DescribeWorkflowOutput( new WorkflowExecutionDescription(response, dataConverterWithWorkflowContext)); @@ -788,7 +819,7 @@ public DescribeWorkflowOutput describe(DescribeWorkflowInput input) { @Override public CountWorkflowOutput countWorkflows(CountWorkflowsInput input) { CountWorkflowExecutionsRequest.Builder req = - CountWorkflowExecutionsRequest.newBuilder().setNamespace(clientOptions.getNamespace()); + CountWorkflowExecutionsRequest.newBuilder().setNamespace(namespace); if (input.getQuery() != null) { req.setQuery(input.getQuery()); } @@ -800,12 +831,14 @@ public CountWorkflowOutput countWorkflows(CountWorkflowsInput input) { public ListWorkflowExecutionsOutput listWorkflowExecutions(ListWorkflowExecutionsInput input) { ListWorkflowExecutionIterator iterator = new ListWorkflowExecutionIterator( - input.getQuery(), clientOptions.getNamespace(), input.getPageSize(), genericClient); + input.getQuery(), namespace, input.getPageSize(), genericClient); iterator.init(); Iterator wrappedIterator = Iterators.transform( iterator, - info -> new WorkflowExecutionMetadata(info, clientOptions.getDataConverter())); + info -> + new WorkflowExecutionMetadata( + info, workflowConverter(info.getExecution(), info.getType().getName()))); // IMMUTABLE here means that "interference" (in Java Streams terms) to this spliterator is // impossible diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientDataConverterFactory.java b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientDataConverterFactory.java new file mode 100644 index 0000000000..9384be6e00 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/WorkflowClientDataConverterFactory.java @@ -0,0 +1,37 @@ +package io.temporal.internal.client; + +import com.google.common.base.Strings; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.DataConverter; +import io.temporal.internal.payload.storage.ExternalStorageDataConverter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.context.WorkflowSerializationContext; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import javax.annotation.Nullable; + +/** Supplies a {@link DataConverter} for clients. */ +final class WorkflowClientDataConverterFactory { + + private final String namespace; + private final DataConverter baseConverter; + + WorkflowClientDataConverterFactory( + WorkflowClientOptions clientOptions, @Nullable ExternalStorageRunner externalStorage) { + this.namespace = clientOptions.getNamespace(); + this.baseConverter = + new ExternalStorageDataConverter(clientOptions.getDataConverter(), externalStorage); + } + + DataConverter forWorkflow( + String workflowId, @Nullable String runId, @Nullable String workflowType) { + DataConverter converter = + baseConverter.withContext(new WorkflowSerializationContext(namespace, workflowId)); + return ((ExternalStorageDataConverter) converter) + .withStorageTarget( + new StorageDriverWorkflowInfo( + namespace, + Strings.emptyToNull(workflowId), + Strings.emptyToNull(runId), + Strings.emptyToNull(workflowType))); + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactory.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactory.java index 74eb0a5e7d..327a5cb6d4 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactory.java @@ -4,24 +4,31 @@ import io.temporal.activity.ManualActivityCompletionClient; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.common.converter.DataConverter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.serviceclient.WorkflowServiceStubs; import javax.annotation.Nonnull; import javax.annotation.Nullable; public interface ManualActivityCompletionClientFactory { - /** - * Create a {@link ManualActivityCompletionClientFactory} that emits simple {@link - * ManualActivityCompletionClientImpl} implementations - */ static ManualActivityCompletionClientFactory newFactory( @Nonnull WorkflowServiceStubs service, @Nonnull String namespace, @Nonnull String identity, @Nonnull DataConverter dataConverter) { + return newFactory(service, namespace, identity, dataConverter, null); + } + + static ManualActivityCompletionClientFactory newFactory( + @Nonnull WorkflowServiceStubs service, + @Nonnull String namespace, + @Nonnull String identity, + @Nonnull DataConverter dataConverter, + @Nullable ExternalStorageRunner externalStorage) { return new ManualActivityCompletionClientFactoryImpl( - service, namespace, identity, dataConverter); + service, namespace, identity, dataConverter, externalStorage); } ManualActivityCompletionClient getClient(@Nonnull byte[] taskToken, @Nonnull Scope metricsScope); @@ -31,6 +38,12 @@ ManualActivityCompletionClient getClient( @Nonnull Scope metricsScope, @Nullable ActivitySerializationContext activitySerializationContext); + ManualActivityCompletionClient getClient( + @Nonnull byte[] taskToken, + @Nonnull Scope metricsScope, + @Nullable ActivitySerializationContext activitySerializationContext, + @Nullable StorageDriverTargetInfo storageTarget); + ManualActivityCompletionClient getClient( @Nonnull WorkflowExecution execution, @Nonnull String activityId, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryImpl.java index 6c8237401e..286d11902a 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryImpl.java @@ -1,11 +1,15 @@ package io.temporal.internal.client.external; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; import com.uber.m3.tally.Scope; import io.temporal.activity.ManualActivityCompletionClient; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.common.converter.DataConverter; +import io.temporal.internal.payload.storage.ActivityStorageTargets; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.serviceclient.WorkflowServiceStubs; import java.util.Objects; import javax.annotation.Nonnull; @@ -16,16 +20,19 @@ class ManualActivityCompletionClientFactoryImpl implements ManualActivityComplet private final DataConverter dataConverter; private final String namespace; private final String identity; + private final @Nullable ExternalStorageRunner externalStorage; ManualActivityCompletionClientFactoryImpl( @Nonnull WorkflowServiceStubs service, @Nonnull String namespace, @Nonnull String identity, - @Nonnull DataConverter dataConverter) { + @Nonnull DataConverter dataConverter, + @Nullable ExternalStorageRunner externalStorage) { this.service = Objects.requireNonNull(service); this.namespace = Objects.requireNonNull(namespace); this.identity = Objects.requireNonNull(identity); this.dataConverter = Objects.requireNonNull(dataConverter); + this.externalStorage = externalStorage; } @Override @@ -39,6 +46,26 @@ public ManualActivityCompletionClient getClient( @Nonnull byte[] taskToken, @Nonnull Scope metricsScope, @Nullable ActivitySerializationContext activitySerializationContext) { + StorageDriverTargetInfo storageTarget = + activitySerializationContext == null + ? null + : ActivityStorageTargets.newBuilder(namespace) + .setActivity( + null, null, Strings.emptyToNull(activitySerializationContext.getActivityType())) + .setWorkflow( + Strings.emptyToNull(activitySerializationContext.getWorkflowId()), + null, + Strings.emptyToNull(activitySerializationContext.getWorkflowType())) + .build(); + return getClient(taskToken, metricsScope, activitySerializationContext, storageTarget); + } + + @Override + public ManualActivityCompletionClient getClient( + @Nonnull byte[] taskToken, + @Nonnull Scope metricsScope, + @Nullable ActivitySerializationContext activitySerializationContext, + @Nullable StorageDriverTargetInfo storageTarget) { Preconditions.checkNotNull(metricsScope, "metricsScope"); Preconditions.checkNotNull(taskToken, "taskToken"); Preconditions.checkArgument(taskToken.length > 0, "empty taskToken"); @@ -51,7 +78,9 @@ public ManualActivityCompletionClient getClient( taskToken, null, null, - activitySerializationContext); + activitySerializationContext, + storageTarget, + externalStorage); } @Override @@ -71,6 +100,16 @@ public ManualActivityCompletionClient getClient( Preconditions.checkNotNull(metricsScope, "metricsScope"); Preconditions.checkNotNull(execution, "execution"); Preconditions.checkNotNull(activityId, "activityId"); + String activityRunId = + execution.getWorkflowId().isEmpty() ? Strings.emptyToNull(execution.getRunId()) : null; + String activityType = + activitySerializationContext == null + ? null + : Strings.emptyToNull(activitySerializationContext.getActivityType()); + String workflowType = + activitySerializationContext == null + ? null + : Strings.emptyToNull(activitySerializationContext.getWorkflowType()); return new ManualActivityCompletionClientImpl( service, namespace, @@ -80,6 +119,14 @@ public ManualActivityCompletionClient getClient( null, execution, activityId, - activitySerializationContext); + activitySerializationContext, + ActivityStorageTargets.newBuilder(namespace) + .setActivity(activityId, activityRunId, activityType) + .setWorkflow( + Strings.emptyToNull(execution.getWorkflowId()), + Strings.emptyToNull(execution.getRunId()), + workflowType) + .build(), + externalStorage); } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientImpl.java index 0e68b107b5..5e597cbb58 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/external/ManualActivityCompletionClientImpl.java @@ -4,6 +4,7 @@ import com.google.common.base.Preconditions; import com.google.protobuf.ByteString; +import com.google.protobuf.Message; import com.uber.m3.tally.Scope; import io.grpc.Status; import io.grpc.StatusRuntimeException; @@ -12,12 +13,15 @@ import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.workflowservice.v1.*; import io.temporal.client.*; +import io.temporal.common.CancellationToken; import io.temporal.common.converter.DataConverter; import io.temporal.failure.CanceledFailure; import io.temporal.internal.client.ActivityClientHelper; import io.temporal.internal.common.OptionsUtils; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.retryer.GrpcRetryer; import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.serviceclient.RpcRetryOptions; import io.temporal.serviceclient.WorkflowServiceStubs; import java.util.Optional; @@ -41,6 +45,8 @@ class ManualActivityCompletionClientImpl implements ManualActivityCompletionClie private final byte[] taskToken; private final GrpcRetryer grpcRetryer; private final GrpcRetryer.GrpcRetryerOptions replyGrpcRetryerOptions; + private final @Nullable StorageDriverTargetInfo storageTarget; + private final @Nullable ExternalStorageRunner externalStorage; ManualActivityCompletionClientImpl( @Nonnull WorkflowServiceStubs service, @@ -51,8 +57,12 @@ class ManualActivityCompletionClientImpl implements ManualActivityCompletionClie @Nullable byte[] taskToken, @Nullable WorkflowExecution execution, @Nullable String activityId, - @Nullable ActivitySerializationContext context) { + @Nullable ActivitySerializationContext context, + @Nullable StorageDriverTargetInfo storageTarget, + @Nullable ExternalStorageRunner externalStorage) { this.service = service; + this.externalStorage = externalStorage; + this.storageTarget = storageTarget; this.dataConverterWithActivityExecutionContext = context != null ? dataConverter.withContext(context) : dataConverter; this.namespace = namespace; @@ -75,23 +85,35 @@ class ManualActivityCompletionClientImpl implements ManualActivityCompletionClie this.activityId = activityId; } + private T storeOutbound(T request) { + if (externalStorage == null) { + return request; + } + Message.Builder builder = request.toBuilder(); + externalStorage.store(builder, storageTarget, null, CancellationToken.none()); + @SuppressWarnings("unchecked") + T stored = (T) builder.build(); + return stored; + } + @Override public void complete(@Nullable Object result) { Optional payloads = dataConverterWithActivityExecutionContext.toPayloads(result); if (taskToken != null) { - RespondActivityTaskCompletedRequest.Builder request = + RespondActivityTaskCompletedRequest.Builder builder = RespondActivityTaskCompletedRequest.newBuilder() .setNamespace(namespace) .setIdentity(identity) .setTaskToken(ByteString.copyFrom(taskToken)); - payloads.ifPresent(request::setResult); + payloads.ifPresent(builder::setResult); try { + RespondActivityTaskCompletedRequest request = storeOutbound(builder.build()); grpcRetryer.retry( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .respondActivityTaskCompleted(request.build()), + .respondActivityTaskCompleted(request), replyGrpcRetryerOptions); } catch (Exception e) { processException(e); @@ -100,20 +122,21 @@ public void complete(@Nullable Object result) { if (activityId == null) { throw new IllegalArgumentException("Either activity id or task token are required"); } - RespondActivityTaskCompletedByIdRequest.Builder request = + RespondActivityTaskCompletedByIdRequest.Builder builder = RespondActivityTaskCompletedByIdRequest.newBuilder() .setActivityId(activityId) .setNamespace(namespace) .setWorkflowId(execution.getWorkflowId()) .setRunId(execution.getRunId()); - payloads.ifPresent(request::setResult); + payloads.ifPresent(builder::setResult); try { + RespondActivityTaskCompletedByIdRequest request = storeOutbound(builder.build()); grpcRetryer.retry( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .respondActivityTaskCompletedById(request.build()), + .respondActivityTaskCompletedById(request), replyGrpcRetryerOptions); } catch (Exception e) { processException(e); @@ -126,13 +149,13 @@ public void fail(@Nonnull Throwable exception) { Preconditions.checkNotNull(exception, "null exception"); // When converting failures reason is class name, details are serialized exception. if (taskToken != null) { - RespondActivityTaskFailedRequest request = + RespondActivityTaskFailedRequest.Builder builder = RespondActivityTaskFailedRequest.newBuilder() .setFailure(dataConverterWithActivityExecutionContext.exceptionToFailure(exception)) .setNamespace(namespace) - .setTaskToken(ByteString.copyFrom(taskToken)) - .build(); + .setTaskToken(ByteString.copyFrom(taskToken)); try { + RespondActivityTaskFailedRequest request = storeOutbound(builder.build()); grpcRetryer.retry( () -> service @@ -152,15 +175,15 @@ public void fail(@Nonnull Throwable exception) { if (activityId == null) { throw new IllegalArgumentException("Either activity id or task token are required"); } - RespondActivityTaskFailedByIdRequest request = + RespondActivityTaskFailedByIdRequest.Builder builder = RespondActivityTaskFailedByIdRequest.newBuilder() .setFailure(dataConverterWithActivityExecutionContext.exceptionToFailure(exception)) .setNamespace(namespace) .setWorkflowId(execution.getWorkflowId()) .setRunId(execution.getRunId()) - .setActivityId(activityId) - .build(); + .setActivityId(activityId); try { + RespondActivityTaskFailedByIdRequest request = storeOutbound(builder.build()); grpcRetryer.retry( () -> service @@ -177,15 +200,17 @@ public void fail(@Nonnull Throwable exception) { @Override public void recordHeartbeat(@Nullable Object details) throws CanceledFailure { try { + Optional payloads = dataConverterWithActivityExecutionContext.toPayloads(details); if (taskToken != null) { + RecordActivityTaskHeartbeatRequest.Builder builder = + RecordActivityTaskHeartbeatRequest.newBuilder() + .setNamespace(namespace) + .setIdentity(identity) + .setTaskToken(ByteString.copyFrom(taskToken)); + payloads.ifPresent(builder::setDetails); + RecordActivityTaskHeartbeatRequest request = storeOutbound(builder.build()); RecordActivityTaskHeartbeatResponse status = - ActivityClientHelper.sendHeartbeatRequest( - service, - namespace, - identity, - taskToken, - dataConverterWithActivityExecutionContext.toPayloads(details), - metricsScope); + ActivityClientHelper.sendHeartbeatRequest(service, request, metricsScope); if (status.getCancelRequested()) { throw new ActivityCanceledException(); } else if (status.getActivityReset()) { @@ -194,15 +219,17 @@ public void recordHeartbeat(@Nullable Object details) throws CanceledFailure { throw new ActivityPausedException(); } } else { + RecordActivityTaskHeartbeatByIdRequest.Builder builder = + RecordActivityTaskHeartbeatByIdRequest.newBuilder() + .setNamespace(namespace) + .setIdentity(identity) + .setWorkflowId(execution.getWorkflowId()) + .setRunId(execution.getRunId()) + .setActivityId(activityId); + payloads.ifPresent(builder::setDetails); + RecordActivityTaskHeartbeatByIdRequest request = storeOutbound(builder.build()); RecordActivityTaskHeartbeatByIdResponse status = - ActivityClientHelper.recordActivityTaskHeartbeatById( - service, - namespace, - identity, - execution, - activityId, - dataConverterWithActivityExecutionContext.toPayloads(details), - metricsScope); + ActivityClientHelper.recordActivityTaskHeartbeatById(service, request, metricsScope); if (status.getCancelRequested()) { throw new ActivityCanceledException(); } else if (status.getActivityReset()) { @@ -221,18 +248,19 @@ public void reportCancellation(@Nullable Object details) { Optional convertedDetails = dataConverterWithActivityExecutionContext.toPayloads(details); if (taskToken != null) { - RespondActivityTaskCanceledRequest.Builder request = + RespondActivityTaskCanceledRequest.Builder builder = RespondActivityTaskCanceledRequest.newBuilder() .setNamespace(namespace) .setTaskToken(ByteString.copyFrom(taskToken)); - convertedDetails.ifPresent(request::setDetails); + convertedDetails.ifPresent(builder::setDetails); try { + RespondActivityTaskCanceledRequest request = storeOutbound(builder.build()); grpcRetryer.retry( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .respondActivityTaskCanceled(request.build()), + .respondActivityTaskCanceled(request), replyGrpcRetryerOptions); } catch (Exception e) { // There is nothing that can be done at this point. @@ -243,20 +271,21 @@ public void reportCancellation(@Nullable Object details) { if (activityId == null) { throw new IllegalArgumentException("Either activity id or task token are required"); } - RespondActivityTaskCanceledByIdRequest.Builder request = + RespondActivityTaskCanceledByIdRequest.Builder builder = RespondActivityTaskCanceledByIdRequest.newBuilder() .setNamespace(namespace) .setWorkflowId(execution.getWorkflowId()) .setRunId(OptionsUtils.safeGet(execution.getRunId())) .setActivityId(activityId); - convertedDetails.ifPresent(request::setDetails); + convertedDetails.ifPresent(builder::setDetails); try { + RespondActivityTaskCanceledByIdRequest request = storeOutbound(builder.build()); grpcRetryer.retry( () -> service .blockingStub() .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) - .respondActivityTaskCanceledById(request.build()), + .respondActivityTaskCanceledById(request), replyGrpcRetryerOptions); } catch (Exception e) { // There is nothing that can be done at this point. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ActivityStorageTargets.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ActivityStorageTargets.java new file mode 100644 index 0000000000..a8cda4b4e9 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ActivityStorageTargets.java @@ -0,0 +1,61 @@ +package io.temporal.internal.payload.storage; + +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import javax.annotation.Nullable; + +/** Chooses the storage target an activity's payloads belong to. */ +public final class ActivityStorageTargets { + + public static Builder newBuilder(String namespace) { + return new Builder(namespace); + } + + private ActivityStorageTargets() {} + + public static final class Builder { + private final String namespace; + private @Nullable String activityId; + private @Nullable String activityRunId; + private @Nullable String activityType; + private @Nullable String workflowId; + private @Nullable String workflowRunId; + private @Nullable String workflowType; + + private Builder(String namespace) { + this.namespace = namespace; + } + + public Builder setActivity( + @Nullable String activityId, + @Nullable String activityRunId, + @Nullable String activityType) { + this.activityId = activityId; + this.activityRunId = activityRunId; + this.activityType = activityType; + return this; + } + + public Builder setWorkflow( + @Nullable String workflowId, + @Nullable String workflowRunId, + @Nullable String workflowType) { + this.workflowId = workflowId; + this.workflowRunId = workflowRunId; + this.workflowType = workflowType; + return this; + } + + /** + * An activity scheduled by a workflow targets that workflow; a standalone activity targets + * itself. A workflow id is present only in the former case. + */ + public StorageDriverTargetInfo build() { + if (workflowId != null) { + return new StorageDriverWorkflowInfo(namespace, workflowId, workflowRunId, workflowType); + } + return new StorageDriverActivityInfo(namespace, activityId, activityRunId, activityType); + } + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java index 4c725752a2..1f8c1198c7 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageDataConverter.java @@ -22,17 +22,17 @@ public final class ExternalStorageDataConverter implements DataConverter { private final DataConverter delegate; - private final ExternalStorageRunner externalStorage; + private final @Nullable ExternalStorageRunner externalStorage; private final @Nullable StorageDriverTargetInfo storageTarget; public ExternalStorageDataConverter( - @Nonnull DataConverter delegate, @Nonnull ExternalStorageRunner externalStorage) { + @Nonnull DataConverter delegate, @Nullable ExternalStorageRunner externalStorage) { this(delegate, externalStorage, null); } private ExternalStorageDataConverter( @Nonnull DataConverter delegate, - @Nonnull ExternalStorageRunner externalStorage, + @Nullable ExternalStorageRunner externalStorage, @Nullable StorageDriverTargetInfo storageTarget) { this.delegate = delegate; this.externalStorage = externalStorage; @@ -94,6 +94,10 @@ public Object[] fromPayloads( @Override @Nonnull public RuntimeException failureToException(@Nonnull Failure failure) { + if (externalStorage == null) { + ExternalStorageRunner.throwIfContainsReference(failure); + return delegate.failureToException(failure); + } return delegate.failureToException(retrieveMessage(failure)); } @@ -127,16 +131,25 @@ private Payload retrieve(Payload payload) { } private Payloads store(Payloads payloads) { + if (externalStorage == null) { + return payloads; + } Payloads.Builder builder = payloads.toBuilder(); externalStorage.store(builder, storageTarget, null, CancellationToken.none()); return builder.build(); } private T retrieveMessage(T message) { + if (externalStorage == null) { + throw new ExternalStorageNotConfiguredException(); + } return externalStorage.retrieve(message, CancellationToken.none()); } private Failure storeMessage(Failure failure) { + if (externalStorage == null) { + return failure; + } Failure.Builder builder = failure.toBuilder(); externalStorage.store(builder, storageTarget, null, CancellationToken.none()); return builder.build(); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java index c05f8c5d03..8fbe763cfa 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/payload/storage/ExternalStorageRunner.java @@ -45,8 +45,15 @@ public void store( @Nullable MessageVisitor targetVisitor, CancellationToken cancellationToken) { getOrThrowIfCancelled( - PayloadVisitors.visit(builder, storeOptions(target, targetVisitor, cancellationToken)), - cancellationToken); + storeAsync(builder, target, targetVisitor, cancellationToken), cancellationToken); + } + + public CompletableFuture storeAsync( + Message.Builder builder, + @Nullable StorageDriverTargetInfo target, + @Nullable MessageVisitor targetVisitor, + CancellationToken cancellationToken) { + return PayloadVisitors.visit(builder, storeOptions(target, targetVisitor, cancellationToken)); } public T retrieve( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java index ff528d46b3..db71a18bf4 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/ActivityWorker.java @@ -2,7 +2,9 @@ import static io.temporal.serviceclient.MetricsTag.METRICS_TAGS_CALL_OPTIONS_KEY; +import com.google.common.base.Strings; import com.google.protobuf.ByteString; +import com.google.protobuf.Message; import com.uber.m3.tally.Scope; import com.uber.m3.tally.Stopwatch; import com.uber.m3.util.Duration; @@ -10,11 +12,16 @@ import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributesOrBuilder; import io.temporal.api.common.v1.WorkflowExecution; import io.temporal.api.workflowservice.v1.*; +import io.temporal.failure.ApplicationFailure; import io.temporal.internal.activity.ActivityPollResponseToInfo; import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.concurrent.structured.CancelSource; import io.temporal.internal.logging.LoggerTag; +import io.temporal.internal.payload.storage.ActivityStorageTargets; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.retryer.GrpcRetryer; import io.temporal.internal.worker.ActivityTaskHandler.Result; +import io.temporal.payload.storage.StorageDriverTargetInfo; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.serviceclient.rpcretry.DefaultStubServiceOperationRpcRetryOptions; @@ -24,9 +31,11 @@ import io.temporal.worker.tuning.PollerBehaviorAutoscaling; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -52,6 +61,9 @@ final class ActivityWorker implements SuspendableWorker { private final PollerTracker pollerTracker; private final NamespaceCapabilities namespaceCapabilities; + final CancelSource storageCancellation = + new CancelSource<>(() -> new CancellationException("Worker shutdown")); + public ActivityWorker( @Nonnull WorkflowServiceStubs service, @Nonnull String namespace, @@ -159,6 +171,9 @@ private String workerControlTaskQueue() { @Override public CompletableFuture shutdown(ShutdownManager shutdownManager, boolean interruptTasks) { + if (interruptTasks) { + storageCancellation.cancel(); + } String supplierName = this + "#executorSlots"; return poller .shutdown(shutdownManager, interruptTasks) @@ -257,6 +272,27 @@ public String toString() { options.getIdentity(), namespace, taskQueue); } + static StorageDriverTargetInfo storageTargetForActivityTask( + String namespace, PollActivityTaskQueueResponseOrBuilder pollResponse) { + WorkflowExecution execution = pollResponse.getWorkflowExecution(); + return ActivityStorageTargets.newBuilder(namespace) + .setActivity( + pollResponse.getActivityId(), + Strings.emptyToNull(pollResponse.getActivityRunId()), + pollResponse.getActivityType().getName()) + .setWorkflow( + Strings.emptyToNull(execution.getWorkflowId()), + Strings.emptyToNull(execution.getRunId()), + pollResponse.getWorkflowType().getName()) + .build(); + } + + private static final class ExternalStorageTaskFailure extends RuntimeException { + ExternalStorageTaskFailure(String message, Throwable cause) { + super(message, cause); + } + } + private class TaskHandlerImpl implements PollTaskExecutor.TaskHandler { final ActivityTaskHandler handler; @@ -331,8 +367,19 @@ public void handle(ActivityTask task) throws Exception { } private ActivityTaskHandler.Result handleActivity(ActivityTask task, Scope metricsScope) { + ByteString taskToken = task.getResponse().getTaskToken(); + try { + task = retrieveInboundPayloads(task); + } catch (Exception e) { + RespondActivityTaskFailedRequest sent = sendStorageFailure(taskToken, metricsScope, e); + return new ActivityTaskHandler.Result( + task.getResponse().getActivityId(), + null, + new ActivityTaskHandler.Result.TaskFailedResult(sent, e), + null, + false); + } PollActivityTaskQueueResponseOrBuilder pollResponse = task.getResponse(); - ByteString taskToken = pollResponse.getTaskToken(); metricsScope .timer(MetricsType.ACTIVITY_SCHEDULE_TO_START_LATENCY) .record( @@ -354,7 +401,10 @@ private ActivityTaskHandler.Result handleActivity(ActivityTask task, Scope metri } try { - sendReply(taskToken, result, metricsScope); + sendReply(taskToken, result, metricsScope, activityStorageTarget(pollResponse)); + } catch (ExternalStorageTaskFailure e) { + sendStorageFailure(taskToken, metricsScope, e.getCause()); + return result; } catch (Exception e) { logExceptionDuringResultReporting(e, pollResponse, result); // TODO this class doesn't report activity success and failure metrics now, instead it's @@ -392,16 +442,20 @@ public Throwable wrapFailure(ActivityTask t, Throwable failure) { // TODO: Suppress warning until the SDK supports deployment @SuppressWarnings("deprecation") private void sendReply( - ByteString taskToken, ActivityTaskHandler.Result response, Scope metricsScope) { + ByteString taskToken, + ActivityTaskHandler.Result response, + Scope metricsScope, + @Nullable StorageDriverTargetInfo storageTarget) { RespondActivityTaskCompletedRequest taskCompleted = response.getTaskCompleted(); if (taskCompleted != null) { - RespondActivityTaskCompletedRequest request = + RespondActivityTaskCompletedRequest.Builder completedBuilder = taskCompleted.toBuilder() .setTaskToken(taskToken) .setIdentity(options.getIdentity()) .setNamespace(namespace) - .setWorkerVersion(options.workerVersionStamp()) - .build(); + .setWorkerVersion(options.workerVersionStamp()); + storeOutboundPayloads(completedBuilder, storageTarget); + RespondActivityTaskCompletedRequest request = completedBuilder.build(); grpcRetryer.retry( () -> @@ -413,13 +467,14 @@ private void sendReply( } else { Result.TaskFailedResult taskFailed = response.getTaskFailed(); if (taskFailed != null) { - RespondActivityTaskFailedRequest request = + RespondActivityTaskFailedRequest.Builder failedBuilder = taskFailed.getTaskFailedRequest().toBuilder() .setTaskToken(taskToken) .setIdentity(options.getIdentity()) .setNamespace(namespace) - .setWorkerVersion(options.workerVersionStamp()) - .build(); + .setWorkerVersion(options.workerVersionStamp()); + storeOutboundPayloads(failedBuilder, storageTarget); + RespondActivityTaskFailedRequest request = failedBuilder.build(); grpcRetryer.retry( () -> @@ -431,13 +486,14 @@ private void sendReply( } else { RespondActivityTaskCanceledRequest taskCanceled = response.getTaskCanceled(); if (taskCanceled != null) { - RespondActivityTaskCanceledRequest request = + RespondActivityTaskCanceledRequest.Builder canceledBuilder = taskCanceled.toBuilder() .setTaskToken(taskToken) .setIdentity(options.getIdentity()) .setNamespace(namespace) - .setWorkerVersion(options.workerVersionStamp()) - .build(); + .setWorkerVersion(options.workerVersionStamp()); + storeOutboundPayloads(canceledBuilder, storageTarget); + RespondActivityTaskCanceledRequest request = canceledBuilder.build(); grpcRetryer.retry( () -> @@ -452,6 +508,73 @@ private void sendReply( // Manual activity completion } + private ActivityTask retrieveInboundPayloads(ActivityTask task) { + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + PollActivityTaskQueueResponseOrBuilder response = task.getResponse(); + PollActivityTaskQueueResponse built = + response instanceof PollActivityTaskQueueResponse + ? (PollActivityTaskQueueResponse) response + : ((PollActivityTaskQueueResponse.Builder) response).build(); + if (externalStorageRunner == null) { + ExternalStorageRunner.throwIfContainsReference(built); + return task; + } + return new ActivityTask( + externalStorageRunner.retrieve(built, storageCancellation.token()), + task.getPermit(), + task.getCompletionCallback()); + } + + private void storeOutboundPayloads( + Message.Builder builder, @Nullable StorageDriverTargetInfo target) { + ExternalStorageRunner externalStorageRunner = options.getExternalStorageRunner(); + if (externalStorageRunner == null) { + return; + } + try { + externalStorageRunner.store(builder, target, null, storageCancellation.token()); + } catch (Throwable e) { + throw new ExternalStorageTaskFailure("External storage store failed", e); + } + } + + @SuppressWarnings("deprecation") + private RespondActivityTaskFailedRequest sendStorageFailure( + ByteString taskToken, Scope metricsScope, Throwable e) { + log.warn("External storage failed for an activity task", e); + ApplicationFailure applicationFailure = + ApplicationFailure.newBuilder() + .setMessage("External storage failed: " + e.getMessage()) + .setType(ExternalStorageTaskFailure.class.getSimpleName()) + .build(); + applicationFailure.setStackTrace(new StackTraceElement[0]); + RespondActivityTaskFailedRequest request = + RespondActivityTaskFailedRequest.newBuilder() + .setTaskToken(taskToken) + .setIdentity(options.getIdentity()) + .setNamespace(namespace) + .setWorkerVersion(options.workerVersionStamp()) + .setFailure(options.getDataConverter().exceptionToFailure(applicationFailure)) + .build(); + grpcRetryer.retry( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .respondActivityTaskFailed(request), + replyGrpcRetryerOptions); + return request; + } + + @Nullable + private StorageDriverTargetInfo activityStorageTarget( + PollActivityTaskQueueResponseOrBuilder pollResponse) { + if (options.getExternalStorageRunner() == null) { + return null; + } + return storageTargetForActivityTask(namespace, pollResponse); + } + private void logExceptionDuringResultReporting( Exception e, PollActivityTaskQueueResponseOrBuilder pollResponse, diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java index 94d2f5dee3..d5cf77f135 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/SyncActivityWorker.java @@ -59,7 +59,8 @@ public SyncActivityWorker( options.getMaxHeartbeatThrottleInterval(), options.getDefaultHeartbeatThrottleInterval(), options.getDataConverter(), - heartbeatExecutor); + heartbeatExecutor, + options.getExternalStorageRunner()); this.taskHandler = new ActivityTaskHandlerImpl( namespace, diff --git a/temporal-sdk/src/test/java/io/temporal/client/WorkflowExecutionMetadataTest.java b/temporal-sdk/src/test/java/io/temporal/client/WorkflowExecutionMetadataTest.java new file mode 100644 index 0000000000..f0fb9ed37f --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/WorkflowExecutionMetadataTest.java @@ -0,0 +1,103 @@ +package io.temporal.client; + +import static org.junit.Assert.assertEquals; + +import io.temporal.api.common.v1.Memo; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.workflow.v1.WorkflowExecutionInfo; +import io.temporal.common.CancellationToken; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.internal.payload.storage.ExternalStorageDataConverter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; + +public class WorkflowExecutionMetadataTest { + + @Test + public void getMemoResolvesAnExternalStorageReference() { + ExternalStorage config = + ExternalStorage.newBuilder() + .setDriver(new InMemoryDriver()) + .setPayloadSizeThreshold(0) + .build(); + DataConverter converter = DefaultDataConverter.newDefaultInstance(); + ExternalStorageRunner storage = ExternalStorageRunner.create(config); + + Payloads.Builder value = converter.toPayloads("big-memo").get().toBuilder(); + storage.store(value, null, null, CancellationToken.none()); + Payload reference = value.build().getPayloads(0); + WorkflowExecutionInfo info = + WorkflowExecutionInfo.newBuilder() + .setMemo(Memo.newBuilder().putFields("k", reference)) + .build(); + + WorkflowExecutionMetadata metadata = + new WorkflowExecutionMetadata(info, new ExternalStorageDataConverter(converter, storage)); + + assertEquals("big-memo", metadata.getMemo("k", String.class)); + } + + @Test + public void getMemoReadsAnInlineValueWithoutExternalStorage() { + DataConverter converter = DefaultDataConverter.newDefaultInstance(); + Payload inline = converter.toPayloads("plain").get().getPayloads(0); + WorkflowExecutionInfo info = + WorkflowExecutionInfo.newBuilder() + .setMemo(Memo.newBuilder().putFields("k", inline)) + .build(); + + WorkflowExecutionMetadata metadata = new WorkflowExecutionMetadata(info, converter); + + assertEquals("plain", metadata.getMemo("k", String.class)); + } + + private static final class InMemoryDriver implements StorageDriver { + private final Map objects = new HashMap<>(); + private int counter = 0; + + @Override + public String getName() { + return "test"; + } + + @Override + public String getType() { + return "test.inmemory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = "k-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/activity/ActivityExecutionContextImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/activity/ActivityExecutionContextImplTest.java new file mode 100644 index 0000000000..8cff52cad2 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/activity/ActivityExecutionContextImplTest.java @@ -0,0 +1,76 @@ +package io.temporal.internal.activity; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.uber.m3.tally.NoopScope; +import com.uber.m3.tally.Scope; +import io.temporal.activity.ActivityInfo; +import io.temporal.activity.ManualActivityCompletionClient; +import io.temporal.client.WorkflowClient; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.internal.client.external.ManualActivityCompletionClientFactory; +import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.serviceclient.WorkflowServiceStubs; +import java.time.Duration; +import java.util.concurrent.ScheduledExecutorService; +import org.junit.Test; + +public class ActivityExecutionContextImplTest { + + @Test + public void localManualCompletionIncludesActivityTarget() { + WorkflowClient client = mock(WorkflowClient.class); + when(client.getWorkflowServiceStubs()).thenReturn(mock(WorkflowServiceStubs.class)); + ActivityInfo info = mock(ActivityInfo.class); + when(info.getNamespace()).thenReturn("test-namespace"); + when(info.getWorkflowId()).thenReturn(null); + when(info.getWorkflowType()).thenReturn(null); + when(info.getActivityId()).thenReturn("activity-id"); + when(info.getActivityRunId()).thenReturn("activity-run-id"); + when(info.getActivityType()).thenReturn("activity-type"); + when(info.getActivityTaskQueue()).thenReturn("task-queue"); + when(info.getTaskToken()).thenReturn(new byte[] {1, 2, 3}); + ManualActivityCompletionClientFactory completionClientFactory = + mock(ManualActivityCompletionClientFactory.class); + when(completionClientFactory.getClient( + any(byte[].class), + any(Scope.class), + any(ActivitySerializationContext.class), + any(StorageDriverTargetInfo.class))) + .thenReturn(mock(ManualActivityCompletionClient.class)); + NoopScope metricsScope = new NoopScope(); + ActivityExecutionContextImpl context = + new ActivityExecutionContextImpl( + client, + "test-namespace", + new Object(), + info, + GlobalDataConverter.get(), + mock(ScheduledExecutorService.class), + completionClientFactory, + () -> {}, + metricsScope, + "test-identity", + Duration.ofSeconds(60), + Duration.ofSeconds(30), + () -> {}, + null); + + context.useLocalManualCompletion(); + + verify(completionClientFactory) + .getClient( + eq(new byte[] {1, 2, 3}), + eq(metricsScope), + any(ActivitySerializationContext.class), + eq( + new StorageDriverActivityInfo( + "test-namespace", "activity-id", "activity-run-id", "activity-type"))); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java index 1379aed154..19fac4d71a 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/activity/HeartbeatContextImplTest.java @@ -8,6 +8,7 @@ import io.grpc.Status; import io.grpc.StatusRuntimeException; import io.temporal.activity.ActivityInfo; +import io.temporal.api.common.v1.Payload; import io.temporal.api.enums.v1.TimeoutType; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatRequest; import io.temporal.api.workflowservice.v1.RecordActivityTaskHeartbeatResponse; @@ -18,14 +19,29 @@ import io.temporal.common.CancellationToken; import io.temporal.common.converter.GlobalDataConverter; import io.temporal.failure.TimeoutFailure; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.testUtils.Eventually; import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.After; import org.junit.Before; @@ -329,7 +345,8 @@ public void factoryCancelByTaskTokenCompletesCancellationToken() { Duration.ofSeconds(60), Duration.ofSeconds(30), GlobalDataConverter.get(), - heartbeatExecutor); + heartbeatExecutor, + null); ActivityInfoInternal info = activityInfoWithHeartbeatTimeout(Duration.ofSeconds(10)); InternalActivityExecutionContext context = @@ -363,6 +380,7 @@ private HeartbeatContextImpl createHeartbeatContext( "test-identity", maxHeartbeatThrottleInterval, defaultHeartbeatThrottleInterval, + null, TEST_BUFFER_MILLIS); } @@ -390,4 +408,168 @@ private static ActivityInfoInternal activityInfoWithHeartbeatTimeout(Duration he when(info.getCompletionHandle()).thenReturn(() -> {}); return info; } + + @Test + public void storageTargetForStandaloneActivityTargetsTheActivity() { + ActivityInfo info = mock(ActivityInfo.class); + when(info.getActivityRunId()).thenReturn("act-run-1"); + when(info.getActivityId()).thenReturn("act-1"); + when(info.getActivityType()).thenReturn("MyActivity"); + + assertEquals( + new StorageDriverActivityInfo("ns", "act-1", "act-run-1", "MyActivity"), + HeartbeatContextImpl.storageTargetForActivity("ns", info)); + } + + @Test + public void storageTargetForWorkflowActivityTargetsTheWorkflow() { + ActivityInfo info = mock(ActivityInfo.class); + when(info.getActivityRunId()).thenReturn(null); + when(info.getWorkflowId()).thenReturn("wf-1"); + when(info.getWorkflowRunId()).thenReturn("wf-run-1"); + when(info.getWorkflowType()).thenReturn("MyWorkflow"); + + assertEquals( + new StorageDriverWorkflowInfo("ns", "wf-1", "wf-run-1", "MyWorkflow"), + HeartbeatContextImpl.storageTargetForActivity("ns", info)); + } + + private static final Duration OFFLOAD_INTERVAL = Duration.ofMillis(500); + + @Test + public void aNewerHeartbeatAbandonsAnInFlightOffload() throws Exception { + BlockingDriver driver = new BlockingDriver(); + HeartbeatContextImpl ctx = createHeartbeatContextWithStorage(driver); + + Thread first = new Thread(() -> ctx.heartbeat(largeDetails("first"))); + first.start(); + assertTrue("the first store should start", driver.storeStarted.await(5, TimeUnit.SECONDS)); + + ctx.heartbeat(largeDetails("second")); + first.join(5000); + assertFalse("the superseded heartbeat should not still be running", first.isAlive()); + + assertEquals("both heartbeats should attempt a store", 2, driver.stores.get()); + verify(blockingStub, times(1)) + .recordActivityTaskHeartbeat(any(RecordActivityTaskHeartbeatRequest.class)); + assertTrue( + "the abandoned store should have its cancellation token tripped", driver.firstCancelled()); + } + + @Test + public void aThrottledOffloadIsAbandonedWhenTheHeartbeatPoolHasNoSpareThread() throws Exception { + when(blockingStub.recordActivityTaskHeartbeat(any(RecordActivityTaskHeartbeatRequest.class))) + .thenReturn(RecordActivityTaskHeartbeatResponse.getDefaultInstance()); + BlockingDriver driver = new BlockingDriver(); + HeartbeatContextImpl ctx = createHeartbeatContextWithStorage(driver); + + ctx.heartbeat("small"); + ctx.heartbeat(largeDetails("throttled")); + + assertTrue( + "the throttled heartbeat should offload on the heartbeat pool", + driver.storeStarted.await(5, TimeUnit.SECONDS)); + Eventually.assertEventually( + Duration.ofSeconds(5), + () -> + assertTrue( + "the offload must be abandoned even though it occupies the only pool thread", + driver.firstCancelled())); + } + + @Test + public void activityCancellationAbandonsAnInFlightOffload() throws Exception { + BlockingDriver driver = new BlockingDriver(); + HeartbeatContextImpl ctx = createHeartbeatContextWithStorage(driver); + + Thread heartbeating = new Thread(() -> ctx.heartbeat(largeDetails("cancelled"))); + heartbeating.start(); + assertTrue("the store should start", driver.storeStarted.await(5, TimeUnit.SECONDS)); + + long startNanos = System.nanoTime(); + ctx.cancelFromWorkerCommand(); + heartbeating.join(5000); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + + assertFalse("cancellation should not wait for the driver", heartbeating.isAlive()); + assertTrue( + "cancellation should abandon the store rather than wait out the heartbeat interval, took " + + elapsedMillis + + "ms", + elapsedMillis < OFFLOAD_INTERVAL.toMillis() / 2); + assertTrue("the abandoned store should be cancelled", driver.firstCancelled()); + } + + private HeartbeatContextImpl createHeartbeatContextWithStorage(StorageDriver driver) { + ExternalStorageRunner runner = + ExternalStorageRunner.create( + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(100).build()); + return new HeartbeatContextImpl( + service, + "test-namespace", + activityInfoWithHeartbeatTimeout(Duration.ZERO), + GlobalDataConverter.get(), + heartbeatExecutor, + new NoopScope(), + "test-identity", + Duration.ofSeconds(60), + OFFLOAD_INTERVAL, + runner, + TEST_BUFFER_MILLIS); + } + + private static String largeDetails(String tag) { + return tag + String.join("", Collections.nCopies(20, "0123456789")); + } + + /** Never completes its first store, so the SDK has to abandon it. */ + private static final class BlockingDriver implements StorageDriver { + final CountDownLatch storeStarted = new CountDownLatch(1); + final AtomicInteger stores = new AtomicInteger(); + private final Map objects = new HashMap<>(); + private volatile CancellationToken firstStoreToken; + private int counter = 0; + + boolean firstCancelled() { + CancellationToken token = firstStoreToken; + return token != null && token.isCancellationRequested(); + } + + @Override + public String getName() { + return "blocking"; + } + + @Override + public String getType() { + return "test.blocking"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + if (stores.getAndIncrement() == 0) { + firstStoreToken = context.getCancellationToken(); + storeStarted.countDown(); + return new CompletableFuture<>(); + } + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = "k-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } } diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerStorageTargetTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerStorageTargetTest.java new file mode 100644 index 0000000000..488c573349 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootWorkflowClientInvokerStorageTargetTest.java @@ -0,0 +1,265 @@ +package io.temporal.internal.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.workflowservice.v1.QueryWorkflowRequest; +import io.temporal.api.workflowservice.v1.QueryWorkflowResponse; +import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse; +import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.StartWorkflowExecutionResponse; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.QueryInput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalInput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalWithStartInput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowStartInput; +import io.temporal.internal.client.external.GenericWorkflowClient; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +public class RootWorkflowClientInvokerStorageTargetTest { + + private static final String NAMESPACE = "test-namespace"; + + @Test + public void startCarriesTheWorkflowTypeButNoRunIdYet() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + when(rpc.start(any())).thenReturn(StartWorkflowExecutionResponse.getDefaultInstance()); + + invoker(rpc, driver) + .start( + new WorkflowStartInput( + "wf-1", + "MyWorkflowType", + Header.empty(), + new Object[] {"argument"}, + WorkflowOptions.newBuilder().setTaskQueue("tq").build())); + + StorageDriverWorkflowInfo target = (StorageDriverWorkflowInfo) driver.lastTarget; + assertEquals(NAMESPACE, target.getNamespace()); + assertEquals("wf-1", target.getId()); + assertEquals("MyWorkflowType", target.getType()); + assertNull(target.getRunId()); + } + + @Test + public void signalCarriesTheRunId() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + + invoker(rpc, driver) + .signal( + new WorkflowSignalInput( + WorkflowExecution.newBuilder().setWorkflowId("wf-2").setRunId("run-9").build(), + "mySignal", + Header.empty(), + new Object[] {"argument"})); + + StorageDriverWorkflowInfo target = (StorageDriverWorkflowInfo) driver.lastTarget; + assertEquals("wf-2", target.getId()); + assertEquals("run-9", target.getRunId()); + } + + @Test + public void anAbsentRunIdArrivesAsNullNotEmptyString() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + + invoker(rpc, driver) + .signal( + new WorkflowSignalInput( + WorkflowExecution.newBuilder().setWorkflowId("wf-3").build(), + "mySignal", + Header.empty(), + new Object[] {"argument"})); + + assertNull(((StorageDriverWorkflowInfo) driver.lastTarget).getRunId()); + } + + @Test + public void signalOffloadsHeaderPayloads() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + ArgumentCaptor sent = + ArgumentCaptor.forClass(SignalWorkflowExecutionRequest.class); + + invoker(rpc, driver) + .signal( + new WorkflowSignalInput( + WorkflowExecution.newBuilder().setWorkflowId("wf-h").setRunId("run-h").build(), + "mySignal", + headerWith("trace", "some-tracing-context"), + new Object[] {"argument"})); + + verify(rpc).signal(sent.capture()); + Payload original = tracePayload("some-tracing-context"); + assertTrue("the header value must reach the driver", driver.stored.contains(original)); + assertNotEquals( + "the sent header must be a reference, not the original bytes", + original, + sent.getValue().getHeader().getFieldsOrThrow("trace")); + } + + @Test + public void queryOffloadsHeaderPayloads() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + when(rpc.query(any())).thenReturn(QueryWorkflowResponse.getDefaultInstance()); + ArgumentCaptor sent = ArgumentCaptor.forClass(QueryWorkflowRequest.class); + + invoker(rpc, driver) + .query( + new QueryInput<>( + WorkflowExecution.newBuilder().setWorkflowId("wf-q").build(), + "myQuery", + headerWith("trace", "some-tracing-context"), + new Object[] {"argument"}, + String.class, + String.class)); + + verify(rpc).query(sent.capture()); + Payload original = tracePayload("some-tracing-context"); + assertTrue("the header value must reach the driver", driver.stored.contains(original)); + assertNotEquals( + "the sent header must be a reference, not the original bytes", + original, + sent.getValue().getQuery().getHeader().getFieldsOrThrow("trace")); + } + + @Test + public void startOffloadsHeaderPayloads() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + when(rpc.start(any())).thenReturn(StartWorkflowExecutionResponse.getDefaultInstance()); + ArgumentCaptor sent = + ArgumentCaptor.forClass(StartWorkflowExecutionRequest.class); + + invoker(rpc, driver) + .start( + new WorkflowStartInput( + "wf-s", + "MyWorkflowType", + headerWith("trace", "some-tracing-context"), + new Object[] {"argument"}, + WorkflowOptions.newBuilder().setTaskQueue("tq").build())); + + verify(rpc).start(sent.capture()); + Payload original = tracePayload("some-tracing-context"); + assertTrue("the header value must reach the driver", driver.stored.contains(original)); + assertNotEquals( + "a start header lands in history, so it must be offloaded", + original, + sent.getValue().getHeader().getFieldsOrThrow("trace")); + } + + @Test + public void signalWithStartOffloadsHeaderPayloads() { + CapturingDriver driver = new CapturingDriver(); + GenericWorkflowClient rpc = mock(GenericWorkflowClient.class); + when(rpc.signalWithStart(any())) + .thenReturn(SignalWithStartWorkflowExecutionResponse.getDefaultInstance()); + ArgumentCaptor sent = + ArgumentCaptor.forClass(SignalWithStartWorkflowExecutionRequest.class); + + invoker(rpc, driver) + .signalWithStart( + new WorkflowSignalWithStartInput( + new WorkflowStartInput( + "wf-sws", + "MyWorkflowType", + headerWith("trace", "some-tracing-context"), + new Object[] {"argument"}, + WorkflowOptions.newBuilder().setTaskQueue("tq").build()), + "mySignal", + new Object[] {"signal-arg"})); + + verify(rpc).signalWithStart(sent.capture()); + Payload original = tracePayload("some-tracing-context"); + assertTrue("the header value must reach the driver", driver.stored.contains(original)); + assertNotEquals( + "the copied start header must carry the reference", + original, + sent.getValue().getHeader().getFieldsOrThrow("trace")); + } + + private static Header headerWith(String key, String value) { + return new Header(Collections.singletonMap(key, tracePayload(value))); + } + + private static Payload tracePayload(String value) { + return WorkflowClientOptions.newBuilder() + .validateAndBuildWithDefaults() + .getDataConverter() + .toPayload(value) + .get(); + } + + private static RootWorkflowClientInvoker invoker( + GenericWorkflowClient rpc, StorageDriver driver) { + return new RootWorkflowClientInvoker( + rpc, + WorkflowClientOptions.newBuilder().setNamespace(NAMESPACE).validateAndBuildWithDefaults(), + new WorkerFactoryRegistry(), + ExternalStorageRunner.create( + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(0).build())); + } + + private static final class CapturingDriver implements StorageDriver { + volatile StorageDriverTargetInfo lastTarget; + final List stored = Collections.synchronizedList(new ArrayList<>()); + private int counter = 0; + + @Override + public String getName() { + return "test"; + } + + @Override + public String getType() { + return "test.capturing"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + lastTarget = context.getTarget(); + stored.addAll(payloads); + List claims = new ArrayList<>(); + for (int i = 0; i < payloads.size(); i++) { + claims.add(new StorageDriverClaim(Collections.singletonMap("key", "k-" + (counter++)))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryStorageTargetTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryStorageTargetTest.java new file mode 100644 index 0000000000..08b2f0f295 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientFactoryStorageTargetTest.java @@ -0,0 +1,159 @@ +package io.temporal.internal.client.external; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.uber.m3.tally.NoopScope; +import io.temporal.activity.ManualActivityCompletionClient; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Before; +import org.junit.Test; + +/** + * A workflow-scheduled activity must target its workflow no matter which completion entry point is + * used, so that manual completion and {@link io.temporal.internal.worker.ActivityWorker} select the + * same driver for the same activity. + */ +public class ManualActivityCompletionClientFactoryStorageTargetTest { + + private static final String NAMESPACE = "test-namespace"; + + private final CapturingDriver driver = new CapturingDriver(); + private ManualActivityCompletionClientFactoryImpl factory; + + @Before + public void setUp() { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.getDefaultInstance()); + when(service.getOptions()).thenReturn(WorkflowServiceStubsOptions.getDefaultInstance()); + factory = + new ManualActivityCompletionClientFactoryImpl( + service, + NAMESPACE, + "test-identity", + DefaultDataConverter.newDefaultInstance(), + ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(0) + .setMaxConcurrentPayloadVisits(1) + .build())); + } + + @Test + public void byIdWorkflowActivityTargetsItsWorkflow() { + StorageDriverTargetInfo target = + capture( + factory.getClient( + WorkflowExecution.newBuilder() + .setWorkflowId("workflow-id") + .setRunId("workflow-run-id") + .build(), + "activity-id", + new NoopScope(), + serializationContext())); + + assertEquals( + new StorageDriverWorkflowInfo(NAMESPACE, "workflow-id", "workflow-run-id", "workflow-type"), + target); + } + + @Test + public void byIdStandaloneActivityTargetsItself() { + StorageDriverTargetInfo target = + capture( + factory.getClient( + WorkflowExecution.newBuilder().setRunId("activity-run-id").build(), + "activity-id", + new NoopScope(), + serializationContext())); + + assertEquals( + new StorageDriverActivityInfo(NAMESPACE, "activity-id", "activity-run-id", "activity-type"), + target); + } + + @Test + public void taskTokenWorkflowActivityTargetsItsWorkflow() { + StorageDriverTargetInfo target = + capture(factory.getClient(new byte[] {1, 2, 3}, new NoopScope(), serializationContext())); + + assertEquals( + new StorageDriverWorkflowInfo(NAMESPACE, "workflow-id", null, "workflow-type"), target); + } + + @Test + public void taskTokenStandaloneActivityTargetsItself() { + ActivitySerializationContext standalone = + new ActivitySerializationContext(NAMESPACE, "", "", "activity-type", "task-queue", false); + + StorageDriverTargetInfo target = + capture(factory.getClient(new byte[] {1, 2, 3}, new NoopScope(), standalone)); + + assertEquals(new StorageDriverActivityInfo(NAMESPACE, null, null, "activity-type"), target); + } + + private static ActivitySerializationContext serializationContext() { + return new ActivitySerializationContext( + NAMESPACE, "workflow-id", "workflow-type", "activity-type", "task-queue", false); + } + + /** + * The driver records the target then fails, so completion aborts before any RPC and the test + * needs no service response. + */ + private StorageDriverTargetInfo capture(ManualActivityCompletionClient client) { + driver.lastTarget = null; + assertThrows(RuntimeException.class, () -> client.complete("result")); + return driver.lastTarget; + } + + private static final class CapturingDriver implements StorageDriver { + volatile StorageDriverTargetInfo lastTarget; + + @Override + public String getName() { + return "capturing"; + } + + @Override + public String getType() { + return "test.capturing"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + lastTarget = context.getTarget(); + CompletableFuture> failed = new CompletableFuture<>(); + failed.completeExceptionally(new RuntimeException("storage failed")); + return failed; + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientImplTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientImplTest.java new file mode 100644 index 0000000000..31ac1a6046 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/external/ManualActivityCompletionClientImplTest.java @@ -0,0 +1,146 @@ +package io.temporal.internal.client.external; + +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.uber.m3.tally.NoopScope; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.workflowservice.v1.GetSystemInfoResponse; +import io.temporal.client.ActivityCompletionFailureException; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; +import io.temporal.internal.payload.storage.ExternalStorageRunner; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.Before; +import org.junit.Test; + +public class ManualActivityCompletionClientImplTest { + private final RuntimeException storageFailure = new RuntimeException("storage failed"); + private WorkflowServiceStubs service; + private ExternalStorageRunner externalStorage; + + @Before + public void setUp() { + service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn(() -> GetSystemInfoResponse.Capabilities.getDefaultInstance()); + when(service.getOptions()).thenReturn(WorkflowServiceStubsOptions.getDefaultInstance()); + externalStorage = + ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(new FailingDriver()) + .setPayloadSizeThreshold(0) + .setMaxConcurrentPayloadVisits(1) + .build()); + } + + @Test + public void taskTokenCompletionWrapsStorageFailure() { + ManualActivityCompletionClientImpl client = taskTokenClient(); + + ActivityCompletionFailureException failure = + assertThrows(ActivityCompletionFailureException.class, () -> client.complete("result")); + + assertSame(storageFailure, failure.getCause()); + verify(service, never()).blockingStub(); + } + + @Test + public void byIdFailureWrapsStorageFailure() { + ManualActivityCompletionClientImpl client = byIdClient(); + + ActivityCompletionFailureException failure = + assertThrows( + ActivityCompletionFailureException.class, + () -> client.fail(ApplicationFailure.newFailure("activity failed", "test", "details"))); + + assertSame(storageFailure, failure.getCause()); + verify(service, never()).blockingStub(); + } + + @Test + public void taskTokenCancellationIgnoresStorageFailure() { + taskTokenClient().reportCancellation("details"); + + verify(service, never()).blockingStub(); + } + + @Test + public void byIdCancellationIgnoresStorageFailure() { + byIdClient().reportCancellation("details"); + + verify(service, never()).blockingStub(); + } + + private ManualActivityCompletionClientImpl taskTokenClient() { + return new ManualActivityCompletionClientImpl( + service, + "test-namespace", + "test-identity", + DefaultDataConverter.newDefaultInstance(), + new NoopScope(), + new byte[] {1, 2, 3}, + null, + null, + null, + new StorageDriverActivityInfo( + "test-namespace", "activity-id", "activity-run-id", "activity-type"), + externalStorage); + } + + private ManualActivityCompletionClientImpl byIdClient() { + return new ManualActivityCompletionClientImpl( + service, + "test-namespace", + "test-identity", + DefaultDataConverter.newDefaultInstance(), + new NoopScope(), + null, + WorkflowExecution.newBuilder().setRunId("activity-run-id").build(), + "activity-id", + null, + new StorageDriverActivityInfo( + "test-namespace", "activity-id", "activity-run-id", "activity-type"), + externalStorage); + } + + private final class FailingDriver implements StorageDriver { + @Override + public String getName() { + return "test"; + } + + @Override + public String getType() { + return "test"; + } + + @Override + public CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + CompletableFuture> result = new CompletableFuture<>(); + result.completeExceptionally(storageFailure); + return result; + } + + @Override + public CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java index 116880aef5..2c80ecc1d7 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java @@ -4,6 +4,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import com.google.protobuf.ByteString; @@ -205,6 +206,46 @@ private static ExternalStorageRunner runner(StorageDriver driver, int threshold) } /** Obscures payload bytes so plaintext reaching a driver is detectable. */ + @Test + public void withoutStorageAReferenceRaisesTheNotConfiguredError() { + Payload reference = storeAndTakeReference("offloaded"); + DataConverter unconfigured = new ExternalStorageDataConverter(plain, null); + + ExternalStorageNotConfiguredException thrown = + assertThrows( + ExternalStorageNotConfiguredException.class, + () -> unconfigured.fromPayload(reference, String.class, String.class)); + + assertTrue( + "the error should point at the option that fixes it", + thrown.getMessage().contains("TMPRL1105") + && thrown.getMessage().contains("setExternalStorage")); + } + + @Test + public void withoutStorageInlinePayloadsStillRoundTrip() { + DataConverter unconfigured = new ExternalStorageDataConverter(plain, null); + + Payload inline = unconfigured.toPayload("plain").get(); + + assertEquals("plain", unconfigured.fromPayload(inline, String.class, String.class)); + assertNull( + "nothing should be offloaded when storage is not configured", + ExternalStorageReferences.tryParseReference(inline)); + } + + private Payload storeAndTakeReference(String value) { + ExternalStorageDataConverter configured = + new ExternalStorageDataConverter( + plain, + ExternalStorageRunner.create( + ExternalStorage.newBuilder() + .setDriver(new RecordingDriver()) + .setPayloadSizeThreshold(0) + .build())); + return configured.toPayload(value).get(); + } + private static final class CountingCodec implements PayloadCodec { private static final byte KEY = 0x5A; diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java index 73d5010256..d8c3ebb6fb 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java @@ -14,11 +14,13 @@ import io.temporal.api.command.v1.ScheduleActivityTaskCommandAttributesOrBuilder; import io.temporal.api.command.v1.StartChildWorkflowExecutionCommandAttributes; import io.temporal.api.common.v1.ActivityType; +import io.temporal.api.common.v1.Header; import io.temporal.api.common.v1.Payload; import io.temporal.api.common.v1.Payloads; import io.temporal.api.common.v1.SearchAttributes; import io.temporal.api.sdk.v1.UserMetadata; import io.temporal.api.workflowservice.v1.RespondWorkflowTaskCompletedRequest; +import io.temporal.api.workflowservice.v1.SignalWorkflowExecutionRequest; import io.temporal.common.CancellationToken; import io.temporal.internal.concurrent.structured.CancelSource; import io.temporal.internal.payload.visitor.MessageVisitor; @@ -52,6 +54,49 @@ public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { assertEquals(message, retrieved); } + @Test + public void storeOffloadsHeaders() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 0); + SignalWorkflowExecutionRequest request = + SignalWorkflowExecutionRequest.newBuilder() + .setHeader(Header.newBuilder().putFields("trace", payload("ctx"))) + .setInput(Payloads.newBuilder().addPayloads(payload("arg"))) + .build(); + + SignalWorkflowExecutionRequest.Builder builder = request.toBuilder(); + transformer.store(builder, null, null, CancellationToken.none()); + SignalWorkflowExecutionRequest stored = builder.build(); + + assertNotNull( + "headers must be offloaded like any other payload", + ExternalStorageReferences.tryParseReference(stored.getHeader().getFieldsOrThrow("trace"))); + assertNotNull( + "input must be offloaded", + ExternalStorageReferences.tryParseReference(stored.getInput().getPayloads(0))); + } + + @Test + public void retrieveStillResolvesAHeaderStoredElsewhere() throws Exception { + InMemoryDriver driver = new InMemoryDriver("d1"); + ExternalStorageRunner transformer = transformer(driver, 0); + + Payloads.Builder headerValue = Payloads.newBuilder().addPayloads(payload("ctx")); + transformer.store(headerValue, null, null, CancellationToken.none()); + Payload storedHeader = headerValue.build().getPayloads(0); + assertNotNull(ExternalStorageReferences.tryParseReference(storedHeader)); + + SignalWorkflowExecutionRequest request = + SignalWorkflowExecutionRequest.newBuilder() + .setHeader(Header.newBuilder().putFields("trace", storedHeader)) + .build(); + + SignalWorkflowExecutionRequest retrieved = + transformer.retrieve(request, CancellationToken.none()); + + assertEquals(payload("ctx"), retrieved.getHeader().getFieldsOrThrow("trace")); + } + @Test public void walksNestedPayloads() throws Exception { TestStorageDriver driver = TestStorageDriver.named("d1"); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/testing/ActivityTestingExternalStorageTest.java b/temporal-sdk/src/test/java/io/temporal/internal/testing/ActivityTestingExternalStorageTest.java new file mode 100644 index 0000000000..b4a323710f --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/testing/ActivityTestingExternalStorageTest.java @@ -0,0 +1,125 @@ +package io.temporal.internal.testing; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import io.temporal.activity.Activity; +import io.temporal.activity.ActivityInterface; +import io.temporal.api.common.v1.Payload; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.testing.TestActivityEnvironment; +import io.temporal.testing.TestEnvironmentOptions; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.Timeout; + +public class ActivityTestingExternalStorageTest { + + private static final String DETAILS = "heartbeat-details"; + + public @Rule Timeout timeout = Timeout.seconds(10); + + private final InMemoryDriver driver = new InMemoryDriver(); + private TestActivityEnvironment testEnvironment; + + @Before + public void setUp() { + testEnvironment = + TestActivityEnvironment.newInstance( + TestEnvironmentOptions.newBuilder() + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setExternalStorage( + ExternalStorage.newBuilder() + .setDriver(driver) + .setPayloadSizeThreshold(0) + .build()) + .build()) + .build()); + } + + @After + public void tearDown() throws Exception { + testEnvironment.close(); + } + + @Test + public void theHeartbeatListenerSeesDetailsThatWereOffloaded() { + testEnvironment.registerActivitiesImplementations(new HeartbeatActivityImpl()); + AtomicReference observed = new AtomicReference<>(); + testEnvironment.setActivityHeartbeatListener(String.class, observed::set); + + String result = testEnvironment.newActivityStub(TestActivity.class).activity1("input"); + + assertEquals("input", result); + assertTrue("expected the heartbeat details to be offloaded", driver.stores.get() > 0); + assertEquals(DETAILS, observed.get()); + } + + @ActivityInterface + public interface TestActivity { + String activity1(String input); + } + + public static class HeartbeatActivityImpl implements TestActivity { + @Override + public String activity1(String input) { + Activity.getExecutionContext().heartbeat(DETAILS); + return input; + } + } + + private static final class InMemoryDriver implements StorageDriver { + private final Map objects = new HashMap<>(); + final AtomicInteger stores = new AtomicInteger(); + private int counter = 0; + + @Override + public String getName() { + return "test-heartbeat"; + } + + @Override + public String getType() { + return "test.inmemory"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + stores.incrementAndGet(); + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = "obj-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerExternalStorageFailureTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerExternalStorageFailureTest.java new file mode 100644 index 0000000000..f23893b4c6 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerExternalStorageFailureTest.java @@ -0,0 +1,186 @@ +package io.temporal.internal.worker; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.enums.v1.EventType; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowOptions; +import io.temporal.common.RetryOptions; +import io.temporal.payload.storage.ExternalStorage; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class ActivityWorkerExternalStorageFailureTest { + + private static final String LARGE_RESULT = String.join("", Collections.nCopies(60, "0123456789")); + + private static final FlakyDriver driver = new FlakyDriver("activity-flaky"); + + private static final ExternalStorage storage = + ExternalStorage.newBuilder().setDriver(driver).setPayloadSizeThreshold(100).build(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(LargeResultWorkflowImpl.class) + .setActivityImplementations(new LargeResultActivityImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder().setExternalStorage(storage).build()) + .build(); + + @Before + public void resetState() { + driver.reset(); + LargeResultActivityImpl.attempts.set(0); + } + + @Test + public void aFailedOutboundStoreRetriesWithoutWaitingForTheActivityTimeout() { + String workflowId = "extstore-activity-" + UUID.randomUUID(); + driver.failStoresContaining.set(LARGE_RESULT); + + LargeResultWorkflow workflow = + testWorkflowRule + .getWorkflowClient() + .newWorkflowStub( + LargeResultWorkflow.class, + WorkflowOptions.newBuilder() + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setWorkflowId(workflowId) + .build()); + + Assert.assertEquals("ok", workflow.execute()); + Assert.assertEquals( + "expected exactly one injected store failure", 1, driver.injectedStoreFailures.get()); + Assert.assertEquals( + "expected the activity to run twice", 2, LargeResultActivityImpl.attempts.get()); + Assert.assertTrue( + "a reported failure must not leave an activity timeout in history", + testWorkflowRule + .getHistoryEvents(workflowId, EventType.EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT) + .isEmpty()); + } + + @WorkflowInterface + public interface LargeResultWorkflow { + @WorkflowMethod + String execute(); + } + + @ActivityInterface + public interface LargeResultActivity { + @ActivityMethod + String run(); + } + + public static class LargeResultWorkflowImpl implements LargeResultWorkflow { + @Override + public String execute() { + LargeResultActivity activity = + Workflow.newActivityStub( + LargeResultActivity.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(100)) + .setMaximumAttempts(3) + .build()) + .build()); + return activity.run(); + } + } + + public static class LargeResultActivityImpl implements LargeResultActivity { + static final AtomicInteger attempts = new AtomicInteger(); + + @Override + public String run() { + return attempts.incrementAndGet() == 1 ? LARGE_RESULT : "ok"; + } + } + + private static final class FlakyDriver implements StorageDriver { + private final String name; + private final Map objects = new HashMap<>(); + final AtomicReference failStoresContaining = new AtomicReference<>(); + final AtomicInteger injectedStoreFailures = new AtomicInteger(); + private int counter = 0; + + FlakyDriver(String name) { + this.name = name; + } + + synchronized void reset() { + objects.clear(); + failStoresContaining.set(null); + injectedStoreFailures.set(0); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getType() { + return "test.activity.flaky"; + } + + @Override + public synchronized CompletableFuture> store( + StorageDriverStoreContext context, List payloads) { + String marker = failStoresContaining.get(); + if (marker != null) { + for (Payload payload : payloads) { + if (payload.getData().toStringUtf8().contains(marker)) { + failStoresContaining.set(null); + injectedStoreFailures.incrementAndGet(); + CompletableFuture> failed = new CompletableFuture<>(); + failed.completeExceptionally(new IllegalStateException("storage unavailable")); + return failed; + } + } + } + List claims = new ArrayList<>(); + for (Payload payload : payloads) { + String key = name + "-" + (counter++); + objects.put(key, payload); + claims.add(new StorageDriverClaim(Collections.singletonMap("key", key))); + } + return CompletableFuture.completedFuture(claims); + } + + @Override + public synchronized CompletableFuture> retrieve( + StorageDriverRetrieveContext context, List claims) { + List payloads = new ArrayList<>(); + for (StorageDriverClaim claim : claims) { + payloads.add(objects.get(claim.getClaimData().get("key"))); + } + return CompletableFuture.completedFuture(payloads); + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerTest.java new file mode 100644 index 0000000000..f82a04c65e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/ActivityWorkerTest.java @@ -0,0 +1,88 @@ +package io.temporal.internal.worker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.temporal.api.common.v1.ActivityType; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.common.v1.WorkflowType; +import io.temporal.api.workflowservice.v1.PollActivityTaskQueueResponse; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.worker.tuning.SlotSupplier; +import org.junit.Test; + +public class ActivityWorkerTest { + + @Test + public void standaloneActivityTargetsTheActivity() { + PollActivityTaskQueueResponse response = + PollActivityTaskQueueResponse.newBuilder() + .setActivityId("act-1") + .setActivityRunId("run-1") + .setActivityType(ActivityType.newBuilder().setName("MyActivity")) + .build(); + + StorageDriverTargetInfo target = ActivityWorker.storageTargetForActivityTask("ns", response); + + assertEquals(new StorageDriverActivityInfo("ns", "act-1", "run-1", "MyActivity"), target); + } + + @Test + public void workflowActivityTargetsTheWorkflow() { + PollActivityTaskQueueResponse response = + PollActivityTaskQueueResponse.newBuilder() + .setActivityId("act-1") + .setActivityType(ActivityType.newBuilder().setName("MyActivity")) + .setWorkflowType(WorkflowType.newBuilder().setName("MyWorkflow")) + .setWorkflowExecution( + WorkflowExecution.newBuilder().setWorkflowId("wf-1").setRunId("wf-run-1")) + .build(); + + StorageDriverTargetInfo target = ActivityWorker.storageTargetForActivityTask("ns", response); + + assertEquals(new StorageDriverWorkflowInfo("ns", "wf-1", "wf-run-1", "MyWorkflow"), target); + } + + @Test + public void interruptingShutdownCancelsInFlightStorage() throws Exception { + ActivityWorker worker = worker(); + + worker.shutdown(new ShutdownManager(), true).get(); + + assertTrue(worker.storageCancellation.token().isCancellationRequested()); + } + + @Test + public void gracefulShutdownLeavesStorageRunning() throws Exception { + ActivityWorker worker = worker(); + + worker.shutdown(new ShutdownManager(), false).get(); + + assertFalse(worker.storageCancellation.token().isCancellationRequested()); + } + + @SuppressWarnings("unchecked") + private static ActivityWorker worker() { + WorkflowServiceStubs service = mock(WorkflowServiceStubs.class); + when(service.getServerCapabilities()) + .thenReturn( + () -> + io.temporal.api.workflowservice.v1.GetSystemInfoResponse.Capabilities + .getDefaultInstance()); + return new ActivityWorker( + service, + "ns", + "tq", + 1.0, + SingleWorkerOptions.newBuilder().build(), + mock(ActivityTaskHandler.class), + mock(SlotSupplier.class), + mock(NamespaceCapabilities.class)); + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/StickyWorkflowDrainShutdownTest.java b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/StickyWorkflowDrainShutdownTest.java index b74fd28087..491cce3b94 100644 --- a/temporal-sdk/src/test/java/io/temporal/worker/shutdown/StickyWorkflowDrainShutdownTest.java +++ b/temporal-sdk/src/test/java/io/temporal/worker/shutdown/StickyWorkflowDrainShutdownTest.java @@ -81,12 +81,8 @@ public void testShutdown() throws InterruptedException { public void testShutdownNow() { TestWorkflow1 workflow = testWorkflowRule.newWorkflowStub(TestWorkflow1.class); WorkflowClient.start(workflow::execute, null); - long startTime = System.currentTimeMillis(); testWorkflowRule.getTestEnvironment().shutdownNow(); - long endTime = System.currentTimeMillis(); testWorkflowRule.getTestEnvironment().awaitTermination(10, TimeUnit.SECONDS); - assertTrue( - "Drain time does not need to be respected", endTime - startTime < DRAIN_TIME.toMillis()); assertTrue(testWorkflowRule.getTestEnvironment().getWorkerFactory().isTerminated()); // Cleanup workflow that will not finish WorkflowStub untyped = WorkflowStub.fromTyped(workflow); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java index 8353b40c37..e79243fab2 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/EagerActivityDispatchingTest.java @@ -251,7 +251,7 @@ public void execute(boolean enableEagerActivityDispatch) { Workflow.newActivityStub( TestActivities.VariousTestActivities.class, ActivityOptions.newBuilder() - .setScheduleToCloseTimeout(Duration.ofMillis(200)) + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) .setDisableEagerExecution(!enableEagerActivityDispatch) .build()); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/TryCancelActivityTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/TryCancelActivityTest.java index 0d5706fb25..82280f7a89 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/TryCancelActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/TryCancelActivityTest.java @@ -2,10 +2,14 @@ import io.temporal.activity.ActivityCancellationType; import io.temporal.activity.ActivityOptions; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.HistoryEvent; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowFailedException; import io.temporal.client.WorkflowStub; import io.temporal.failure.CanceledFailure; +import io.temporal.internal.Signal; import io.temporal.testing.internal.SDKTestOptions; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.Workflow; @@ -22,6 +26,7 @@ public class TryCancelActivityTest { private static final CompletionClientActivitiesImpl activitiesImpl = new CompletionClientActivitiesImpl(); + private final Signal activityStarted = new Signal(); @Rule public SDKTestWorkflowRule testWorkflowRule = @@ -39,22 +44,40 @@ public static void afterClass() throws Exception { public void testTryCancelActivity() throws InterruptedException { activitiesImpl.setCompletionClient( testWorkflowRule.getWorkflowClient().newActivityCompletionClient()); + activitiesImpl.setActivityWithDelayStartedCallback(activityStarted::signal); TestWorkflow1 client = testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow1.class); - WorkflowClient.start(client::execute, testWorkflowRule.getTaskQueue()); - Thread.sleep(500); + WorkflowExecution execution = + WorkflowClient.start(client::execute, testWorkflowRule.getTaskQueue()); + activityStarted.waitForSignal(); WorkflowStub stub = WorkflowStub.fromTyped(client); - testWorkflowRule.waitForOKQuery(stub); + SDKTestWorkflowRule.waitForOKQuery(stub); stub.cancel(); - long start = testWorkflowRule.getTestEnvironment().currentTimeMillis(); try { stub.getResult(String.class); Assert.fail("unreachable"); } catch (WorkflowFailedException e) { Assert.assertTrue(e.getCause() instanceof CanceledFailure); } - long elapsed = testWorkflowRule.getTestEnvironment().currentTimeMillis() - start; - Assert.assertTrue(String.valueOf(elapsed), elapsed < 500); activitiesImpl.assertInvocations("activityWithDelay"); + HistoryEvent activityCancellationRequestedEvent = + testWorkflowRule.getHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED); + HistoryEvent workflowCanceledEvent = + testWorkflowRule.getHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED); + Assert.assertEquals( + 1, + testWorkflowRule + .getHistoryEvents( + execution.getWorkflowId(), EventType.EVENT_TYPE_ACTIVITY_TASK_CANCEL_REQUESTED) + .size()); + Assert.assertTrue( + activityCancellationRequestedEvent.getEventId() < workflowCanceledEvent.getEventId()); + Assert.assertTrue( + testWorkflowRule + .getHistoryEvents( + execution.getWorkflowId(), EventType.EVENT_TYPE_ACTIVITY_TASK_CANCELED) + .isEmpty()); } public static class TestTryCancelActivity implements TestWorkflow1 { diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/AbandonOnCancelActivityTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/AbandonOnCancelActivityTest.java index 287bedbbd6..91d2eacd94 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/AbandonOnCancelActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/cancellation/AbandonOnCancelActivityTest.java @@ -11,6 +11,7 @@ import io.temporal.client.WorkflowFailedException; import io.temporal.client.WorkflowStub; import io.temporal.failure.CanceledFailure; +import io.temporal.internal.Signal; import io.temporal.testing.internal.SDKTestOptions; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.Workflow; @@ -26,6 +27,7 @@ public class AbandonOnCancelActivityTest { private static final CompletionClientActivitiesImpl activitiesImpl = new CompletionClientActivitiesImpl(); + private final Signal activityStarted = new Signal(); @Rule public SDKTestWorkflowRule testWorkflowRule = @@ -43,22 +45,20 @@ public static void afterClass() throws Exception { public void testAbandonOnCancelActivity() throws InterruptedException { activitiesImpl.setCompletionClient( testWorkflowRule.getWorkflowClient().newActivityCompletionClient()); + activitiesImpl.setActivityWithDelayStartedCallback(activityStarted::signal); TestWorkflow1 client = testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflow1.class); WorkflowExecution execution = WorkflowClient.start(client::execute, testWorkflowRule.getTaskQueue()); - Thread.sleep(500); // To let activityWithDelay start. + activityStarted.waitForSignal(); WorkflowStub stub = WorkflowStub.fromTyped(client); - testWorkflowRule.waitForOKQuery(stub); + SDKTestWorkflowRule.waitForOKQuery(stub); stub.cancel(); - long start = testWorkflowRule.getTestEnvironment().currentTimeMillis(); try { stub.getResult(String.class); fail("unreachable"); } catch (WorkflowFailedException e) { assertTrue(e.getCause() instanceof CanceledFailure); } - long elapsed = testWorkflowRule.getTestEnvironment().currentTimeMillis() - start; - assertTrue(String.valueOf(elapsed), elapsed < 500); activitiesImpl.assertInvocations("activityWithDelay"); assertTrue( "Activity with CancellationType=ABANDON should never have a requested cancellation in history", diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java index a8c139c670..6fb85e8d0e 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest.java @@ -1,24 +1,15 @@ package io.temporal.workflow.queryTests; import static org.junit.Assert.*; -import static org.junit.Assume.assumeTrue; import ch.qos.logback.classic.Logger; import ch.qos.logback.classic.spi.ILoggingEvent; import ch.qos.logback.core.read.ListAppender; -import io.temporal.activity.ActivityOptions; -import io.temporal.api.common.v1.WorkflowExecution; -import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowException; -import io.temporal.common.RetryOptions; -import io.temporal.failure.ActivityFailure; import io.temporal.failure.ApplicationFailure; import io.temporal.internal.Issue; import io.temporal.testing.internal.SDKTestWorkflowRule; -import io.temporal.workflow.Workflow; -import io.temporal.workflow.shared.TestActivities; import io.temporal.workflow.shared.TestWorkflows; -import java.time.Duration; import java.util.concurrent.atomic.AtomicInteger; import org.junit.Before; import org.junit.Rule; @@ -38,10 +29,7 @@ public class DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest { @Rule public SDKTestWorkflowRule testWorkflowRule = - SDKTestWorkflowRule.newBuilder() - .setWorkflowTypes(TestWorkflowNonRetryableFlag.class, LogAndKeepRunningWorkflow.class) - .setActivityImplementations(new TestActivities.TestActivitiesImpl()) - .build(); + SDKTestWorkflowRule.newBuilder().setWorkflowTypes(TestWorkflowNonRetryableFlag.class).build(); @Before public void setUp() throws Exception { @@ -71,75 +59,6 @@ public void queriedWorkflowFailureDoesntProduceAdditionalLogs() { workflowExecuteRunnableLoggerAppender.list.size()); } - @Test - public void queriedWorkflowFailureDoesntProduceAdditionalLogsWhenWorkflowIsNotCompleted() { - assumeTrue("This test is flaky on the Test Server", SDKTestWorkflowRule.useExternalService); - - TestWorkflows.QueryableWorkflow workflow = - testWorkflowRule.newWorkflowStub(TestWorkflows.QueryableWorkflow.class); - - WorkflowExecution execution = WorkflowClient.start(workflow::execute); - - assertEquals("my-state", workflow.getState()); - assertEquals("There was only one execution.", 1, workflowCodeExecutionCount.get()); - - testWorkflowRule.invalidateWorkflowCache(); - assertEquals("my-state", workflow.getState()); - assertEquals( - "There was two executions - one original and one full replay for query.", - 2, - workflowCodeExecutionCount.get()); - - workflow.mySignal("exit"); - assertEquals("exit", workflow.execute()); - assertEquals("my-state", workflow.getState()); - assertEquals( - "There was three executions - one original and two full replays for query.", - 3, - workflowCodeExecutionCount.get()); - assertEquals( - "Only the original exception should be logged.", - 1, - workflowExecuteRunnableLoggerAppender.list.size()); - } - - public static class LogAndKeepRunningWorkflow implements TestWorkflows.QueryableWorkflow { - private final org.slf4j.Logger logger = - Workflow.getLogger("io.temporal.internal.sync.WorkflowExecutionHandler"); - private final TestActivities.VariousTestActivities activities = - Workflow.newActivityStub( - TestActivities.VariousTestActivities.class, - ActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(10)) - .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) - .build()); - private boolean exit; - - @Override - public String execute() { - workflowCodeExecutionCount.incrementAndGet(); - while (true) { - try { - activities.throwIO(); - } catch (ActivityFailure e) { - logger.error("Unexpected error on activity", e); - Workflow.await(() -> exit); - return "exit"; - } - } - } - - @Override - public String getState() { - return "my-state"; - } - - @Override - public void mySignal(String value) { - exit = true; - } - } - public static class TestWorkflowNonRetryableFlag implements TestWorkflows.TestWorkflowWithQuery { @Override diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/RunningWorkflowQueryReplaysDontSpamLogTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/RunningWorkflowQueryReplaysDontSpamLogTest.java new file mode 100644 index 0000000000..d519f9968d --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/queryTests/RunningWorkflowQueryReplaysDontSpamLogTest.java @@ -0,0 +1,125 @@ +package io.temporal.workflow.queryTests; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assume.assumeTrue; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import io.temporal.activity.ActivityOptions; +import io.temporal.client.WorkflowClient; +import io.temporal.common.RetryOptions; +import io.temporal.failure.ActivityFailure; +import io.temporal.internal.Issue; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.shared.TestActivities; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.slf4j.LoggerFactory; + +/** + * Same guarantee as {@link DirectQueryReplaysDontSpamLogWithWorkflowExecutionExceptionsTest}, but + * for a workflow that is still running. + * + *

This lives in its own class because {@code workflowCodeExecutionCount} and the log appender + * are shared per class. Sharing them with a test whose workflow fails lets that workflow's trailing + * replay land in this test's counter and appender. + */ +@Issue("https://github.com/temporalio/sdk-java/issues/1348") +public class RunningWorkflowQueryReplaysDontSpamLogTest { + + private static final AtomicInteger workflowCodeExecutionCount = new AtomicInteger(); + private final ListAppender workflowExecuteRunnableLoggerAppender = + new ListAppender<>(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(LogAndKeepRunningWorkflow.class) + .setActivityImplementations(new TestActivities.TestActivitiesImpl()) + .build(); + + @Before + public void setUp() { + workflowCodeExecutionCount.set(0); + + Logger workflowExecuteRunnableLogger = + (Logger) LoggerFactory.getLogger("io.temporal.internal.sync.WorkflowExecutionHandler"); + workflowExecuteRunnableLoggerAppender.start(); + workflowExecuteRunnableLogger.addAppender(workflowExecuteRunnableLoggerAppender); + } + + @Test + public void queriedWorkflowFailureDoesntProduceAdditionalLogsWhenWorkflowIsNotCompleted() { + assumeTrue("This test is flaky on the Test Server", SDKTestWorkflowRule.useExternalService); + + TestWorkflows.QueryableWorkflow workflow = + testWorkflowRule.newWorkflowStub(TestWorkflows.QueryableWorkflow.class); + + WorkflowClient.start(workflow::execute); + + assertEquals("my-state", workflow.getState()); + assertEquals("There was only one execution.", 1, workflowCodeExecutionCount.get()); + + testWorkflowRule.invalidateWorkflowCache(); + assertEquals("my-state", workflow.getState()); + assertEquals( + "There was two executions - one original and one full replay for query.", + 2, + workflowCodeExecutionCount.get()); + + workflow.mySignal("exit"); + assertEquals("exit", workflow.execute()); + assertEquals("my-state", workflow.getState()); + assertEquals( + "There was three executions - one original and two full replays for query.", + 3, + workflowCodeExecutionCount.get()); + assertEquals( + "Only the original exception should be logged.", + 1, + workflowExecuteRunnableLoggerAppender.list.size()); + } + + public static class LogAndKeepRunningWorkflow implements TestWorkflows.QueryableWorkflow { + private final org.slf4j.Logger logger = + Workflow.getLogger("io.temporal.internal.sync.WorkflowExecutionHandler"); + private final TestActivities.VariousTestActivities activities = + Workflow.newActivityStub( + TestActivities.VariousTestActivities.class, + ActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build()); + private boolean exit; + + @Override + public String execute() { + workflowCodeExecutionCount.incrementAndGet(); + while (true) { + try { + activities.throwIO(); + } catch (ActivityFailure e) { + logger.error("Unexpected error on activity", e); + Workflow.await(() -> exit); + return "exit"; + } + } + } + + @Override + public String getState() { + return "my-state"; + } + + @Override + public void mySignal(String value) { + exit = true; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java b/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java index 0c71210516..92d409367d 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/shared/TestActivities.java @@ -413,11 +413,16 @@ public static class CompletionClientActivitiesImpl private final ThreadPoolExecutor executor = new ThreadPoolExecutor(0, 100, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<>()); public ActivityCompletionClient completionClient; + private Runnable activityWithDelayStartedCallback = () -> {}; public void setCompletionClient(ActivityCompletionClient completionClient) { this.completionClient = completionClient; } + public void setActivityWithDelayStartedCallback(Runnable activityWithDelayStartedCallback) { + this.activityWithDelayStartedCallback = activityWithDelayStartedCallback; + } + public void assertInvocations(String... expected) { assertEquals(Arrays.asList(expected), invocations); } @@ -462,6 +467,7 @@ public String activityWithDelay(long delay, boolean heartbeatMoreThanOnce) { executor.execute( () -> { invocations.add("activityWithDelay"); + activityWithDelayStartedCallback.run(); long start = System.currentTimeMillis(); try { int count = 0; diff --git a/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java b/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java index 1d6ebb92de..dc2a0d3d3c 100644 --- a/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java +++ b/temporal-testing/src/main/java/io/temporal/testing/TestActivityEnvironmentInternal.java @@ -30,7 +30,10 @@ import io.temporal.internal.activity.ActivityExecutionContextFactory; import io.temporal.internal.activity.ActivityExecutionContextFactoryImpl; import io.temporal.internal.activity.ActivityTaskHandlerImpl; +import io.temporal.internal.client.WorkflowClientInternal; import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.payload.storage.ExternalStorageDataConverter; +import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.sync.*; import io.temporal.internal.testservice.InProcessGRPCServer; import io.temporal.internal.worker.ActivityTask; @@ -77,6 +80,7 @@ public final class TestActivityEnvironmentInternal implements TestActivityEnviro private final TestEnvironmentOptions testEnvironmentOptions; private final WorkflowServiceStubs workflowServiceStubs; private final AtomicReference heartbeatDetails = new AtomicReference<>(); + private final DataConverter heartbeatDetailsConverter; private ClassConsumerPair activityHeartbeatListener; public TestActivityEnvironmentInternal(@Nullable TestEnvironmentOptions options) { @@ -100,16 +104,27 @@ public TestActivityEnvironmentInternal(@Nullable TestEnvironmentOptions options) this.workflowServiceStubs = WorkflowServiceStubs.newServiceStubs(serviceStubsOptionsBuilder.build()); + WorkflowClient client = + WorkflowClient.newInstance( + this.workflowServiceStubs, testEnvironmentOptions.getWorkflowClientOptions()); + ExternalStorageRunner externalStorageRunner = + ((WorkflowClientInternal) client.getInternal()).getExternalStorageRunner(); + DataConverter clientDataConverter = + testEnvironmentOptions.getWorkflowClientOptions().getDataConverter(); + this.heartbeatDetailsConverter = + externalStorageRunner == null + ? clientDataConverter + : new ExternalStorageDataConverter(clientDataConverter, externalStorageRunner); ActivityExecutionContextFactory activityExecutionContextFactory = new ActivityExecutionContextFactoryImpl( - WorkflowClient.newInstance( - this.workflowServiceStubs, testEnvironmentOptions.getWorkflowClientOptions()), + client, testEnvironmentOptions.getWorkflowClientOptions().getIdentity(), testEnvironmentOptions.getWorkflowClientOptions().getNamespace(), WorkerOptions.getDefaultInstance().getMaxHeartbeatThrottleInterval(), WorkerOptions.getDefaultInstance().getDefaultHeartbeatThrottleInterval(), - testEnvironmentOptions.getWorkflowClientOptions().getDataConverter(), - heartbeatExecutor); + clientDataConverter, + heartbeatExecutor, + externalStorageRunner); activityTaskHandler = new ActivityTaskHandlerImpl( testEnvironmentOptions.getWorkflowClientOptions().getNamespace(), @@ -138,14 +153,11 @@ public void recordActivityTaskHeartbeat( request.hasDetails() ? Optional.of(request.getDetails()) : Optional.empty(); Object details = - testEnvironmentOptions - .getWorkflowClientOptions() - .getDataConverter() - .fromPayloads( - 0, - requestDetails, - activityHeartbeatListener.valueClass, - activityHeartbeatListener.valueType); + heartbeatDetailsConverter.fromPayloads( + 0, + requestDetails, + activityHeartbeatListener.valueClass, + activityHeartbeatListener.valueType); activityHeartbeatListener.consumer.apply(details); } responseObserver.onNext( From afdfac27148fe7c73782c59474da9c8b31531d0f Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Fri, 11 Sep 2026 14:37:36 -0400 Subject: [PATCH 093/107] fix broken test that referenced InMemoryDriver (#3065) --- .../ExternalStorageDataConverterTest.java | 2 +- .../storage/ExternalStorageRunnerTest.java | 4 +- ...orkflowRunTaskHandlerTaskHandlerTests.java | 77 ++++++++++++++++ .../internal/worker/AsyncPollerTest.java | 25 +++-- ...dRightBeforeWorkflowTaskHeartbeatTest.java | 92 ------------------- 5 files changed, 96 insertions(+), 104 deletions(-) delete mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityGettingScheduledRightBeforeWorkflowTaskHeartbeatTest.java diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java index 2c80ecc1d7..3cc35284bf 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageDataConverterTest.java @@ -240,7 +240,7 @@ private Payload storeAndTakeReference(String value) { plain, ExternalStorageRunner.create( ExternalStorage.newBuilder() - .setDriver(new RecordingDriver()) + .setDriver(TestStorageDriver.create()) .setPayloadSizeThreshold(0) .build())); return configured.toPayload(value).get(); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java index d8c3ebb6fb..323ef817fe 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/payload/storage/ExternalStorageRunnerTest.java @@ -56,7 +56,7 @@ public void storeAndRetrieveRoundTripsOverAMessage() throws Exception { @Test public void storeOffloadsHeaders() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 0); SignalWorkflowExecutionRequest request = SignalWorkflowExecutionRequest.newBuilder() @@ -78,7 +78,7 @@ public void storeOffloadsHeaders() throws Exception { @Test public void retrieveStillResolvesAHeaderStoredElsewhere() throws Exception { - InMemoryDriver driver = new InMemoryDriver("d1"); + TestStorageDriver driver = TestStorageDriver.named("d1"); ExternalStorageRunner transformer = transformer(driver, 0); Payloads.Builder headerValue = Payloads.newBuilder().addPayloads(payload("ctx")); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java b/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java index 046255218d..8505cc7916 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/replay/ReplayWorkflowRunTaskHandlerTaskHandlerTests.java @@ -15,6 +15,7 @@ import com.google.protobuf.ByteString; import com.google.protobuf.util.Durations; import com.uber.m3.tally.NoopScope; +import io.temporal.api.common.v1.ActivityType; import io.temporal.api.common.v1.Payload; import io.temporal.api.common.v1.Payloads; import io.temporal.api.enums.v1.EventType; @@ -28,6 +29,9 @@ import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.payload.storage.TestStorageDriver; import io.temporal.internal.statemachines.ExecuteLocalActivityParameters; +import io.temporal.internal.statemachines.WorkflowStateMachines; +import io.temporal.internal.worker.LocalActivityDispatcher; +import io.temporal.internal.worker.LocalActivityResult; import io.temporal.internal.worker.SingleWorkerOptions; import io.temporal.internal.worker.WorkflowExecutorCache; import io.temporal.internal.worker.WorkflowRunLockManager; @@ -37,11 +41,14 @@ import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.testUtils.HistoryUtils; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Functions; import java.time.Duration; import java.util.HashMap; import java.util.List; import java.util.Optional; import java.util.concurrent.CancellationException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import org.junit.Rule; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -50,6 +57,76 @@ public class ReplayWorkflowRunTaskHandlerTaskHandlerTests { @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder().build(); + @Test + public void outstandingLocalActivityForcesANewWorkflowTask() throws Throwable { + PollWorkflowTaskQueueResponse initialWorkflowTask = + HistoryUtils.generateWorkflowTaskWithInitialHistory(); + HistoryEvent startedEvent = initialWorkflowTask.getHistory().getEvents(0); + PollWorkflowTaskQueueResponse workflowTask = + initialWorkflowTask.toBuilder() + .setHistory( + initialWorkflowTask.getHistory().toBuilder() + .setEvents( + 0, + startedEvent.toBuilder() + .setWorkflowExecutionStartedEventAttributes( + startedEvent + .getWorkflowExecutionStartedEventAttributes() + .toBuilder() + .setWorkflowTaskTimeout(Durations.ZERO)))) + .build(); + AtomicReference stateMachines = new AtomicReference<>(); + AtomicReference> completion = new AtomicReference<>(); + AtomicBoolean scheduled = new AtomicBoolean(); + ReplayWorkflow workflow = mock(ReplayWorkflow.class); + WorkflowContext workflowContext = mock(WorkflowContext.class); + when(workflow.getWorkflowContext()).thenReturn(workflowContext); + when(workflowContext.getRunningUpdateHandlers()).thenReturn(new HashMap<>()); + when(workflow.eventLoop()) + .thenAnswer( + ignored -> { + if (scheduled.compareAndSet(false, true)) { + stateMachines + .get() + .scheduleLocalActivityTask( + new ExecuteLocalActivityParameters( + PollActivityTaskQueueResponse.newBuilder() + .setActivityId("local-activity") + .setActivityType(ActivityType.newBuilder().setName("activity")), + null, + 0, + null, + false, + Duration.ZERO, + null), + (result, failure) -> {}); + } + return false; + }); + LocalActivityDispatcher dispatcher = + (parameters, callback, acceptanceDeadline) -> { + completion.set(callback); + return true; + }; + ReplayWorkflowRunTaskHandler handler = + new ReplayWorkflowRunTaskHandler( + "namespace", + workflow, + workflowTask, + SingleWorkerOptions.newBuilder().build(), + new NoopScope(), + dispatcher, + GetSystemInfoResponse.Capabilities.newBuilder().build()); + stateMachines.set(handler.getWorkflowStateMachines()); + + WorkflowTaskResult result = + handler.handleWorkflowTask( + workflowTask, new FullHistoryIterator(workflowTask.getHistory().getEventsList())); + + assertNotNull(completion.get()); + assertTrue(result.isForceWorkflowTask()); + } + @Test public void ifStickyExecutionAttributesAreNotSetThenWorkflowsAreNotCached() throws Throwable { assumeFalse("skipping for docker tests", SDKTestWorkflowRule.useExternalService); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java index 9d641fab0d..34bc8083dd 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/worker/AsyncPollerTest.java @@ -336,7 +336,16 @@ public void testAsyncPollFailed() @Test public void testSuspendPolling() throws InterruptedException, ExecutionException, AsyncPoller.PollTaskAsyncAbort { - CountingSlotSupplier slotSupplierInner = new CountingSlotSupplier<>(1); + AtomicInteger reserveCalls = new AtomicInteger(); + CountingSlotSupplier slotSupplierInner = + new CountingSlotSupplier(1) { + @Override + public SlotSupplierFuture reserveSlot(SlotReserveContext context) + throws Exception { + reserveCalls.incrementAndGet(); + return super.reserveSlot(context); + } + }; TrackingSlotSupplier slotSupplier = new TrackingSlotSupplier<>(slotSupplierInner, new NoopScope()); DummyTaskExecutor executor = new DummyTaskExecutor(slotSupplier); @@ -370,14 +379,12 @@ public void testSuspendPolling() poller.resumePolling(); assertFalse(poller.isSuspended()); pollLatch.await(); - assertEventually( - Duration.ofSeconds(5), - () -> { - assertEquals(0, executor.processed.get()); - assertEquals(1, slotSupplierInner.reservedCount.get()); - assertEquals(0, slotSupplier.getUsedSlots().size()); - }); - // Suspend polling again, this will not affect the already issued poll request + assertEquals(0, executor.processed.get()); + assertEquals(1, slotSupplierInner.reservedCount.get()); + assertEquals(0, slotSupplier.getUsedSlots().size()); + assertEventually(Duration.ofSeconds(5), () -> assertEquals(2, reserveCalls.get())); + // Suspend polling again, this will not affect the already issued poll request or the + // second reserveSlot call, which is waiting for the first slot to be released. poller.suspendPolling(); completePoll.get().apply(); assertEventually( diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityGettingScheduledRightBeforeWorkflowTaskHeartbeatTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityGettingScheduledRightBeforeWorkflowTaskHeartbeatTest.java deleted file mode 100644 index 96c1743540..0000000000 --- a/temporal-sdk/src/test/java/io/temporal/workflow/activityTests/LocalActivityGettingScheduledRightBeforeWorkflowTaskHeartbeatTest.java +++ /dev/null @@ -1,92 +0,0 @@ -package io.temporal.workflow.activityTests; - -import static org.junit.Assert.assertEquals; - -import io.temporal.client.WorkflowOptions; -import io.temporal.client.WorkflowStub; -import io.temporal.internal.Config; -import io.temporal.internal.Issue; -import io.temporal.testing.internal.SDKTestOptions; -import io.temporal.testing.internal.SDKTestWorkflowRule; -import io.temporal.workflow.Workflow; -import io.temporal.workflow.shared.TestActivities.TestActivitiesImpl; -import io.temporal.workflow.shared.TestActivities.VariousTestActivities; -import io.temporal.workflow.shared.TestWorkflows.TestWorkflow1; -import java.time.Duration; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; -import org.junit.Rule; -import org.junit.Test; - -@Issue("https://github.com/temporalio/sdk-java/issues/1262") -public class LocalActivityGettingScheduledRightBeforeWorkflowTaskHeartbeatTest { - private static final Duration WORKFLOW_TASK_TIMEOUT = Duration.ofSeconds(5); - private static final Duration SLEEP_DURATION = - Duration.ofMillis(800); // << 1000 to avoid deadlock detection - - private final TestActivitiesImpl activitiesImpl = new TestActivitiesImpl(); - - @Rule - public SDKTestWorkflowRule testWorkflowRule = - SDKTestWorkflowRule.newBuilder() - .setWorkflowTypes(HeartbeatingWorkflowImpl.class) - .setActivityImplementations(activitiesImpl) - .build(); - - @Test(timeout = 15_000) - public void testLocalActivitiesWorkflowTaskHeartbeat() { - WorkflowOptions options = - WorkflowOptions.newBuilder() - .setWorkflowRunTimeout(WORKFLOW_TASK_TIMEOUT.multipliedBy(2)) - .setWorkflowTaskTimeout(WORKFLOW_TASK_TIMEOUT) - .setTaskQueue(testWorkflowRule.getTaskQueue()) - .build(); - - List stubs = new ArrayList<>(); - - // this test is actually pretty stable, - // but run several instances to increase the chances of the right timing being hit - for (int i = 0; i < 5; i++) { - TestWorkflow1 workflow = - testWorkflowRule.getWorkflowClient().newWorkflowStub(TestWorkflow1.class, options); - WorkflowStub stub = WorkflowStub.fromTyped(workflow); - stub.start(testWorkflowRule.getTaskQueue()); - stubs.add(stub); - } - - for (WorkflowStub stub : stubs) { - assertEquals("done", stub.getResult(String.class)); - } - } - - public static class HeartbeatingWorkflowImpl implements TestWorkflow1 { - @Override - public String execute(String taskQueue) { - VariousTestActivities localActivities = - Workflow.newLocalActivityStub( - VariousTestActivities.class, SDKTestOptions.newLocalActivityOptions()); - - long firstLocalActivityDurationMs = - (long) (WORKFLOW_TASK_TIMEOUT.toMillis() * Config.WORKFLOW_TASK_HEARTBEAT_COEFFICIENT) - - SLEEP_DURATION.toMillis() / 2; - localActivities.sleepActivity(firstLocalActivityDurationMs, 0); - - // It is very important for reproduction that the workflow heartbeat timeout is reached DURING - // this sleep / workflow code execution. - // So the first local activity is done, eventLoop is triggered and heartbeat timeout is - // reached at the end of - // this workflow code event loop call with the next activity scheduled. - try { - Thread.sleep(SLEEP_DURATION.toMillis()); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - - localActivities.sleepActivity(TimeUnit.SECONDS.toMillis(1), 0); - - return "done"; - } - } -} From dd28759b03134c0a9d43ffe960061d07f1759c3d Mon Sep 17 00:00:00 2001 From: Gregory Michael Travis Date: Fri, 11 Sep 2026 15:39:07 -0400 Subject: [PATCH 094/107] Reject duplicates in SAA update options (#3061) --- .../client/UntypedActivityHandle.java | 7 ++- .../ActivityClientCallsInterceptor.java | 2 +- .../internal/client/ActivityHandleImpl.java | 13 +++++ .../client/RootActivityClientInvoker.java | 21 ++++---- .../ActivityHandleOperatorCommandsTest.java | 52 ++++++++++++++----- 5 files changed, 69 insertions(+), 26 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java index c3ab2488b6..06cc2c689b 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -188,8 +188,13 @@ CompletableFuture getResultAsync( * ActivityOptionsUpdate.ActivityOptionsKey#set} to set an option or {@link * ActivityOptionsUpdate.ActivityOptionsKey#unset} to clear it. * - * @param updates the option updates to apply; at least one is required + *

Each option may be named at most once; naming the same option twice throws {@link + * IllegalArgumentException}. + * + * @param updates the option updates to apply; at least one is required, and no option may be + * named more than once * @return the activity options as resolved by the server after the update + * @throws IllegalArgumentException if {@code updates} is empty or names an option twice */ ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates); diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java index 3dbd25fa6e..0d51c9a09d 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java @@ -465,7 +465,7 @@ public String getRunId() { /** * The option updates to apply, in the order the caller supplied them. Empty when {@link - * #isRestoreOriginal()} is true. For a repeated key, the later update wins. + * #isRestoreOriginal()} is true. Each option is named at most once. */ public List> getUpdates() { return updates; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java index 0736547d7a..39ff540615 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/ActivityHandleImpl.java @@ -11,7 +11,9 @@ import java.lang.reflect.Type; import java.util.Arrays; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -179,6 +181,17 @@ public ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... update throw new IllegalArgumentException("updateOptions requires at least one option update"); } + // Each option may be named at most once. Silently resolving a repeat would hide a caller + // mistake behind whichever update happened to come last. + Set seen = new HashSet<>(); + for (ActivityOptionsUpdate update : list) { + String path = update.getKey().getPath(); + if (!seen.add(path)) { + throw new IllegalArgumentException( + "updateOptions received more than one update for " + path); + } + } + ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = clientCallsInterceptor.updateActivityOptions( new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index 077ad34246..a47a402de2 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -35,8 +35,6 @@ import io.temporal.serviceclient.StatusUtils; import java.lang.reflect.Type; import java.util.*; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeoutException; @@ -450,17 +448,20 @@ public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsIn if (input.isRestoreOriginal()) { req.setRestoreOriginal(true); } else { - // For repeated keys, later values override previous ones. - Map> byPath = new LinkedHashMap<>(); - for (ActivityOptionsUpdate update : input.getUpdates()) { - byPath.put(update.getKey().getPath(), update); - } + // The handle rejects a repeated option, but an interceptor could still add one. ActivityOptions.Builder activityOptions = ActivityOptions.newBuilder(); - for (ActivityOptionsUpdate update : byPath.values()) { + FieldMask.Builder updateMask = FieldMask.newBuilder(); + Set seen = new HashSet<>(); + for (ActivityOptionsUpdate update : input.getUpdates()) { + String path = update.getKey().getPath(); + if (!seen.add(path)) { + throw new IllegalArgumentException( + "updateActivityOptions received more than one update for " + path); + } + updateMask.addPaths(path); update.applyTo(activityOptions); } - req.setActivityOptions(activityOptions.build()) - .setUpdateMask(FieldMask.newBuilder().addAllPaths(byPath.keySet()).build()); + req.setActivityOptions(activityOptions.build()).setUpdateMask(updateMask.build()); } UpdateActivityExecutionOptionsResponse response = genericClient.updateActivityOptions(req.build()); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java index 4f6b295bb2..594a8472d8 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java @@ -6,6 +6,7 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; @@ -19,8 +20,10 @@ import io.temporal.client.PauseActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; import java.time.Duration; +import java.util.Arrays; import org.junit.Test; import org.mockito.ArgumentCaptor; @@ -156,23 +159,44 @@ public void omittedJitterIsLeftOffTheWire() { assertFalse("unpause should not send jitter", captureUnpause().hasJitter()); } - /** A repeated key resolves to its last update: a later valueUnset overrides an earlier set. */ + /** + * The handle rejects a repeat, so only a hand-built interceptor input can carry one. Asserts the + * invoker rejects it too rather than sending a mask with a duplicate path. + */ @Test - public void aRepeatedKeyResolvesToItsLastUpdate() { - when(genericClient.updateActivityOptions(any())) - .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + public void aRepeatedKeyFromAnInterceptorIsRejected() { + RootActivityClientInvoker invoker = new RootActivityClientInvoker(genericClient, clientOptions); - newHandle() - .updateOptions( - ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.set(Duration.ofSeconds(5)), - ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.unset()); + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + invoker.updateActivityOptions( + new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( + "act-1", + "run-1", + Arrays.asList( + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.set(Duration.ofSeconds(5)), + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.unset()), + false))); + assertTrue(e.getMessage().contains("more than one update for heartbeat_timeout")); + verify(genericClient, never()).updateActivityOptions(any()); + } - UpdateActivityExecutionOptionsRequest req = captureUpdate(); - // The later unset wins, and the path is named once. - assertEquals( - java.util.Collections.singleton("heartbeat_timeout"), - new java.util.HashSet<>(req.getUpdateMask().getPathsList())); - assertFalse(req.getActivityOptions().hasHeartbeatTimeout()); + /** Naming the same option twice is rejected rather than silently resolved. */ + @Test + public void aRepeatedKeyIsRejected() { + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> + newHandle() + .updateOptions( + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.set(Duration.ofSeconds(5)), + ActivityOptionsUpdate.HEARTBEAT_TIMEOUT.unset())); + assertTrue(e.getMessage().contains("more than one update for heartbeat_timeout")); + // Rejected before any request is sent. + verify(genericClient, never()).updateActivityOptions(any()); } /** The mask names exactly the options that were updated, and nothing else. */ From c7f47b99b0f1dc881474752b8666e77b352e09d6 Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Mon, 14 Sep 2026 10:55:55 -0400 Subject: [PATCH 095/107] Rename Standalone Activity StaticSummary to Summary (#3062) --- .../client/ActivityExecutionDescription.java | 2 +- .../temporal/client/StartActivityOptions.java | 22 +++++++++---------- .../client/RootActivityClientInvoker.java | 4 ++-- .../client/StartActivityOptionsTest.java | 4 ++-- .../functional/StandaloneActivityTest.java | 5 ++--- 5 files changed, 18 insertions(+), 19 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java index 485f6ce7b2..fa4d4f77cc 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -355,7 +355,7 @@ public Priority getPriority() { * the result if called multiple times. */ @Nullable - public String getStaticSummary() { + public String getSummary() { if (!response.getInfo().getUserMetadata().hasSummary()) { return null; } diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java index 12e86b0522..6b416a6625 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java @@ -42,7 +42,7 @@ public static final class Builder { ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_UNSPECIFIED; private @Nullable RetryOptions retryOptions; private @Nullable SearchAttributes typedSearchAttributes; - private @Nullable String staticSummary; + private @Nullable String summary; private @Nullable String staticDetails; private @Nullable Priority priority; private @Nullable Duration startDelay; @@ -63,7 +63,7 @@ private Builder(StartActivityOptions options) { this.idConflictPolicy = options.idConflictPolicy; this.retryOptions = options.retryOptions; this.typedSearchAttributes = options.typedSearchAttributes; - this.staticSummary = options.staticSummary; + this.summary = options.summary; this.staticDetails = options.staticDetails; this.priority = options.priority; this.startDelay = options.startDelay; @@ -148,8 +148,8 @@ public Builder setTypedSearchAttributes(SearchAttributes typedSearchAttributes) } /** Short static summary for UI display; encoded as a payload in UserMetadata. */ - public Builder setStaticSummary(String staticSummary) { - this.staticSummary = staticSummary; + public Builder setSummary(String summary) { + this.summary = summary; return this; } @@ -200,7 +200,7 @@ public StartActivityOptions build() { private final ActivityIdConflictPolicy idConflictPolicy; private final @Nullable RetryOptions retryOptions; private final @Nullable SearchAttributes typedSearchAttributes; - private final @Nullable String staticSummary; + private final @Nullable String summary; private final @Nullable String staticDetails; private final @Nullable Priority priority; private final @Nullable Duration startDelay; @@ -216,7 +216,7 @@ private StartActivityOptions(Builder builder) { this.idConflictPolicy = builder.idConflictPolicy; this.retryOptions = builder.retryOptions; this.typedSearchAttributes = builder.typedSearchAttributes; - this.staticSummary = builder.staticSummary; + this.summary = builder.summary; this.staticDetails = builder.staticDetails; this.priority = builder.priority; this.startDelay = builder.startDelay; @@ -273,8 +273,8 @@ public SearchAttributes getTypedSearchAttributes() { } @Nullable - public String getStaticSummary() { - return staticSummary; + public String getSummary() { + return summary; } @Nullable @@ -307,7 +307,7 @@ public boolean equals(Object o) { && idConflictPolicy == that.idConflictPolicy && Objects.equals(retryOptions, that.retryOptions) && Objects.equals(typedSearchAttributes, that.typedSearchAttributes) - && Objects.equals(staticSummary, that.staticSummary) + && Objects.equals(summary, that.summary) && Objects.equals(staticDetails, that.staticDetails) && Objects.equals(priority, that.priority) && Objects.equals(startDelay, that.startDelay); @@ -326,7 +326,7 @@ public int hashCode() { idConflictPolicy, retryOptions, typedSearchAttributes, - staticSummary, + summary, staticDetails, priority, startDelay); @@ -356,7 +356,7 @@ public String toString() { + ", typedSearchAttributes=" + typedSearchAttributes + ", staticSummary='" - + staticSummary + + summary + "', staticDetails='" + staticDetails + "', priority=" diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index a47a402de2..3bf753a832 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -107,9 +107,9 @@ public StartActivityOutput startActivity(StartActivityInput input) { request.setSearchAttributes( SearchAttributesUtil.encodeTyped(options.getTypedSearchAttributes())); } - if (options.getStaticSummary() != null || options.getStaticDetails() != null) { + if (options.getSummary() != null || options.getStaticDetails() != null) { UserMetadata userMetadata = - makeUserMetaData(options.getStaticSummary(), options.getStaticDetails(), dc); + makeUserMetaData(options.getSummary(), options.getStaticDetails(), dc); if (userMetadata != null) { request.setUserMetadata(userMetadata); } diff --git a/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java b/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java index 92ac00cea3..2fd67405af 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/StartActivityOptionsTest.java @@ -90,7 +90,7 @@ public void testToBuilderPreservesAllFields() { .setIdReusePolicy(ActivityIdReusePolicy.ACTIVITY_ID_REUSE_POLICY_REJECT_DUPLICATE) .setIdConflictPolicy(ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_FAIL) .setRetryOptions(retry) - .setStaticSummary("summary") + .setSummary("summary") .setStaticDetails("details") .setPriority(priority) .setStartDelay(Duration.ofSeconds(7)) @@ -107,7 +107,7 @@ public void testToBuilderPreservesAllFields() { ActivityIdReusePolicy.ACTIVITY_ID_REUSE_POLICY_REJECT_DUPLICATE, copy.getIdReusePolicy()); assertEquals( ActivityIdConflictPolicy.ACTIVITY_ID_CONFLICT_POLICY_FAIL, copy.getIdConflictPolicy()); - assertEquals("summary", copy.getStaticSummary()); + assertEquals("summary", copy.getSummary()); assertEquals("details", copy.getStaticDetails()); assertEquals(priority, copy.getPriority()); assertEquals(Duration.ofSeconds(7), copy.getStartDelay()); diff --git a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java index 2a7c87c694..49c971d433 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java @@ -18,7 +18,6 @@ import io.temporal.client.*; import io.temporal.common.RetryOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; -import io.temporal.common.interceptors.ActivityClientCallsInterceptor.*; import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; import io.temporal.common.interceptors.ActivityClientInterceptorBase; import io.temporal.failure.ApplicationFailure; @@ -417,7 +416,7 @@ public void testDescribeUserMetadataIsAccurate() { .setId(uniqueId()) .setTaskQueue(testWorkflowRule.getTaskQueue()) .setScheduleToCloseTimeout(Duration.ofMinutes(5)) - .setStaticSummary("Test summary") + .setSummary("Test summary") .setStaticDetails("Test details\nLine 2") .build(); @@ -426,7 +425,7 @@ public void testDescribeUserMetadataIsAccurate() { handle.getResult(); ActivityExecutionDescription desc = handle.describe(); - assertEquals("Test summary", desc.getStaticSummary()); + assertEquals("Test summary", desc.getSummary()); assertEquals("Test details\nLine 2", desc.getStaticDetails()); } From 0eade1627c34ea11eab0cbed6eedc5e3adf9dbd8 Mon Sep 17 00:00:00 2001 From: Christopher Constable Date: Mon, 14 Sep 2026 13:06:56 -0400 Subject: [PATCH 096/107] Add extstore s3 driver (#2907) * feat(extstore): add s3 driver. * update comments and readme after rebase --- .../build.gradle | 19 + .../awssdkv2/S3AsyncClientAdapter.java | 110 ++++ .../awssdkv2/S3AsyncClientAdapterTest.java | 38 ++ .../build.gradle | 11 + .../storage/s3driver/BucketResolver.java | 18 + .../storage/s3driver/CompletableFutures.java | 56 ++ .../storage/s3driver/PayloadHasher.java | 25 + .../payload/storage/s3driver/README.md | 112 ++++ .../storage/s3driver/S3StorageDriver.java | 313 ++++++++++ .../s3driver/S3StorageDriverClient.java | 47 ++ .../storage/s3driver/S3StorageException.java | 15 + .../storage/s3driver/S3StorageKey.java | 74 +++ .../storage/s3driver/S3StorageDriverTest.java | 573 ++++++++++++++++++ .../storage/s3driver/S3StorageKeyTest.java | 37 ++ settings.gradle | 4 + 15 files changed, 1452 insertions(+) create mode 100644 contrib/temporal-payload-storage-s3driver-awssdkv2/build.gradle create mode 100644 contrib/temporal-payload-storage-s3driver-awssdkv2/src/main/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapter.java create mode 100644 contrib/temporal-payload-storage-s3driver-awssdkv2/src/test/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapterTest.java create mode 100644 contrib/temporal-payload-storage-s3driver/build.gradle create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/BucketResolver.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/CompletableFutures.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/PayloadHasher.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/README.md create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriver.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriverClient.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageException.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageKey.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageDriverTest.java create mode 100644 contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageKeyTest.java diff --git a/contrib/temporal-payload-storage-s3driver-awssdkv2/build.gradle b/contrib/temporal-payload-storage-s3driver-awssdkv2/build.gradle new file mode 100644 index 0000000000..a6e57058cc --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver-awssdkv2/build.gradle @@ -0,0 +1,19 @@ +description = '''Temporal Java SDK External Storage S3 Driver - AWS SDK v2 Client''' + +ext { + awsSdkVersion = '2.31.0' +} + +dependencies { + api project(':temporal-payload-storage-s3driver') + api platform("software.amazon.awssdk:bom:$awsSdkVersion") + api "software.amazon.awssdk:s3" + + // For the @Experimental annotation only. + compileOnly project(':temporal-sdk') + + testImplementation project(':temporal-payload-storage-s3driver') + testImplementation "junit:junit:${junitVersion}" + testImplementation "org.mockito:mockito-core:${mockitoVersion}" + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-payload-storage-s3driver-awssdkv2/src/main/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapter.java b/contrib/temporal-payload-storage-s3driver-awssdkv2/src/main/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapter.java new file mode 100644 index 0000000000..8b96d80331 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver-awssdkv2/src/main/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapter.java @@ -0,0 +1,110 @@ +package io.temporal.payload.storage.s3driver.awssdkv2; + +import io.temporal.common.Experimental; +import io.temporal.payload.storage.s3driver.S3StorageDriverClient; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import javax.annotation.Nonnull; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.core.async.AsyncResponseTransformer; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.HeadObjectRequest; +import software.amazon.awssdk.services.s3.model.HeadObjectResponse; +import software.amazon.awssdk.services.s3.model.NoSuchKeyException; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; +import software.amazon.awssdk.services.s3.model.S3Exception; + +/** + * {@link S3StorageDriverClient} backed by the AWS SDK for Java v2 {@link S3AsyncClient}. The + * wrapped client must be configured with credentials and a region by the caller. + */ +@Experimental +public final class S3AsyncClientAdapter implements S3StorageDriverClient { + private final S3AsyncClient client; + + public S3AsyncClientAdapter(@Nonnull S3AsyncClient client) { + this.client = Objects.requireNonNull(client, "client"); + } + + @Nonnull + @Override + public CompletableFuture putObject( + @Nonnull String bucket, @Nonnull String key, @Nonnull byte[] data) { + CompletableFuture request = + client.putObject( + PutObjectRequest.builder().bucket(bucket).key(key).build(), + AsyncRequestBody.fromBytesUnsafe(data)); // avoids a defensive copy + return abortRequestOnCancel(request, request.thenApply(response -> (Void) null)); + } + + @Nonnull + @Override + public CompletableFuture objectExists(@Nonnull String bucket, @Nonnull String key) { + CompletableFuture request = + client.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build()); + return abortRequestOnCancel( + request, + request.handle( + (response, ex) -> { + if (ex == null) { + return true; + } + Throwable cause = + (ex instanceof CompletionException && ex.getCause() != null) ? ex.getCause() : ex; + if (cause instanceof NoSuchKeyException) { + return false; + } + if (cause instanceof S3Exception && ((S3Exception) cause).statusCode() == 404) { + return false; + } + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new RuntimeException(cause); + })); + } + + @Nonnull + @Override + public CompletableFuture getObject(@Nonnull String bucket, @Nonnull String key) { + CompletableFuture> request = + client.getObject( + GetObjectRequest.builder().bucket(bucket).key(key).build(), + AsyncResponseTransformer.toBytes()); + return abortRequestOnCancel(request, request.thenApply(ResponseBytes::asByteArrayUnsafe)); + } + + /** + * Returns {@code result}, wired so that cancelling it cancels the underlying {@code request}. The + * AWS SDK aborts an async request when the future it returns is cancelled. Cancellation does not + * otherwise propagate across the {@code thenApply}/{@code handle} boundary. + */ + private static CompletableFuture abortRequestOnCancel( + CompletableFuture request, CompletableFuture result) { + result.whenComplete( + (value, ex) -> { + if (result.isCancelled()) { + request.cancel(true); + } + }); + return result; + } + + @Nonnull + @Override + public Map describe() { + Region region = client.serviceClientConfiguration().region(); + if (region == null) { + return Collections.emptyMap(); + } + return Collections.singletonMap("client_region", region.id()); + } +} diff --git a/contrib/temporal-payload-storage-s3driver-awssdkv2/src/test/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapterTest.java b/contrib/temporal-payload-storage-s3driver-awssdkv2/src/test/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapterTest.java new file mode 100644 index 0000000000..719d3f2454 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver-awssdkv2/src/test/java/io/temporal/payload/storage/s3driver/awssdkv2/S3AsyncClientAdapterTest.java @@ -0,0 +1,38 @@ +package io.temporal.payload.storage.s3driver.awssdkv2; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import org.junit.Test; +import software.amazon.awssdk.core.async.AsyncRequestBody; +import software.amazon.awssdk.services.s3.S3AsyncClient; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +public class S3AsyncClientAdapterTest { + + /** + * Cancelling the future the adapter returns must abort the underlying AWS request. The adapter + * wraps the AWS future with {@code thenApply}, which does not propagate cancellation upstream, so + * this verifies the explicit forwarding does its job. + */ + @Test + public void cancellingReturnedFutureAbortsTheUnderlyingRequest() { + S3AsyncClient s3 = mock(S3AsyncClient.class); + CompletableFuture awsRequest = new CompletableFuture<>(); + when(s3.putObject(any(PutObjectRequest.class), any(AsyncRequestBody.class))) + .thenReturn(awsRequest); + + CompletableFuture result = + new S3AsyncClientAdapter(s3).putObject("bucket", "key", new byte[] {1, 2, 3}); + + assertFalse(awsRequest.isCancelled()); + result.cancel(true); + assertTrue( + "cancelling the adapter's future should abort the AWS request", awsRequest.isCancelled()); + } +} diff --git a/contrib/temporal-payload-storage-s3driver/build.gradle b/contrib/temporal-payload-storage-s3driver/build.gradle new file mode 100644 index 0000000000..5aef744303 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/build.gradle @@ -0,0 +1,11 @@ +description = '''Temporal Java SDK External Storage S3 Driver''' + +dependencies { + compileOnly project(':temporal-serviceclient') + compileOnly project(':temporal-sdk') + + testImplementation project(':temporal-serviceclient') + testImplementation project(':temporal-sdk') + testImplementation "junit:junit:${junitVersion}" + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/BucketResolver.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/BucketResolver.java new file mode 100644 index 0000000000..211b1e78c4 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/BucketResolver.java @@ -0,0 +1,18 @@ +package io.temporal.payload.storage.s3driver; + +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import io.temporal.payload.storage.StorageDriverStoreContext; +import javax.annotation.Nonnull; + +/** + * Resolves the target S3 bucket for a payload. Use {@link + * S3StorageDriver.Builder#setBucket(String)} for a fixed bucket, or supply a resolver via {@link + * S3StorageDriver.Builder#setBucketResolver(BucketResolver)} to choose a bucket per payload. + */ +@Experimental +@FunctionalInterface +public interface BucketResolver { + @Nonnull + String resolveBucket(@Nonnull StorageDriverStoreContext context, @Nonnull Payload payload); +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/CompletableFutures.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/CompletableFutures.java new file mode 100644 index 0000000000..dd7537db56 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/CompletableFutures.java @@ -0,0 +1,56 @@ +package io.temporal.payload.storage.s3driver; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicInteger; + +final class CompletableFutures { + private CompletableFutures() {} + + /** + * Completes with the results in input order once every future succeeds. Fails fast with the first + * failure's cause as soon as any future fails, without waiting for the rest. + */ + static CompletableFuture> allAsList(List> futures) { + CompletableFuture> result = new CompletableFuture<>(); + if (futures.isEmpty()) { + result.complete(new ArrayList<>()); + return result; + } + AtomicInteger remaining = new AtomicInteger(futures.size()); + for (CompletableFuture future : futures) { + future.whenComplete( + (value, ex) -> { + if (ex != null) { + result.completeExceptionally(unwrap(ex)); + } else if (remaining.decrementAndGet() == 0) { + List results = new ArrayList<>(futures.size()); + for (CompletableFuture completed : futures) { + results.add(completed.join()); + } + result.complete(results); + } + }); + } + result.whenComplete( + (value, ex) -> { + if (ex != null) { + for (CompletableFuture future : futures) { + future.cancel(true); + } + } + }); + return result; + } + + static Throwable unwrap(Throwable t) { + while ((t instanceof CompletionException || t instanceof ExecutionException) + && t.getCause() != null) { + t = t.getCause(); + } + return t; + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/PayloadHasher.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/PayloadHasher.java new file mode 100644 index 0000000000..8a4da97bb6 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/PayloadHasher.java @@ -0,0 +1,25 @@ +package io.temporal.payload.storage.s3driver; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +final class PayloadHasher { + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private PayloadHasher() {} + + /** Returns the lower-case SHA-256 hex digest of {@code data}. */ + static String sha256Hex(byte[] data) { + byte[] digest; + try { + digest = MessageDigest.getInstance("SHA-256").digest(data); + } catch (NoSuchAlgorithmException e) { + throw new AssertionError("SHA-256 MessageDigest cannot be found", e); + } + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(HEX[(b >> 4) & 0xF]).append(HEX[b & 0xF]); + } + return sb.toString(); + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/README.md b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/README.md new file mode 100644 index 0000000000..bc532938d8 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/README.md @@ -0,0 +1,112 @@ +# AWS S3 Driver + +Temporal's S3 Driver for External Storage. Uses the official [AWS S3 Java SDK](https://github.com/aws/aws-sdk-java-v2). + +## Usage + +Construct the S3 storage driver: + +```java +import io.temporal.payload.storage.s3driver.S3StorageDriver; +import io.temporal.payload.storage.s3driver.awssdkv2.S3AsyncClientAdapter; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3AsyncClient; + +S3AsyncClient s3Client = + S3AsyncClient.builder().region(Region.US_EAST_1).build(); + +S3StorageDriver driver = + S3StorageDriver.newBuilder() + .setClient(new S3AsyncClientAdapter(s3Client)) + .setBucket("temporal-payloads") + .build(); +``` + +Register the driver in external storage config: + +```java +import io.temporal.payload.storage.ExternalStorage; + +ExternalStorage externalStorage = + ExternalStorage.newBuilder() + .setDriver(driver) + .build(); +``` + +Use `setBucketResolver(...)` instead of `setBucket(...)` when bucket selection must vary per +payload. + +## S3 Storage Key Specification + +All Temporal S3 drivers generate S3 keys in a consistent manner. + +### Key format + +Workflow key: +```text +v0/ns/{namespace}/wt/{workflow-type}/wi/{workflow-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest} +``` + +Activity key: +```text +v0/ns/{namespace}/at/{activity-type}/ai/{activity-id}/ri/{run-id}/d/{hash-algorithm}/{hex-digest} +``` + +Fallback key (unknown target): +```text +v0/d/{hash-algorithm}/{hex-digest} +``` + +- If no namespace, workflow, or activity information is available, the fallback is used. +- Dynamic path segments are percent-encoded (rules below). +- Missing values (including a missing `run-id`) are encoded as `null`. +- `hex-digest` is lower-case SHA-256 hex (64 characters). + +### Percent-encoding rules + +Every byte of a dynamic path segment is percent-encoded as uppercase `%XX` except these, which are +left literal: + +```text +Alphanumeric characters + 0-9 + a-z + A-Z + +Special characters + Hyphen (-) + Underscore (_) + Period (.) +``` + +### Examples + +Workflow key example: + +```text +input: + namespace=payments prod + workflow-type=ChargeWorkflow + workflow-id=order+123=abc + run-id=3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31 + hash-algorithm=sha256 + hex-digest=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 + +output: + v0/ns/payments%20prod/wt/ChargeWorkflow/wi/order%2B123%3Dabc/ri/3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31/d/sha256/9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 +``` + +Activity key example: + +```text +input: + namespace=payments prod + activity-type=Capture/Charge + activity-id=activity id+42 + run-id=9e1d1fd9-2f8a-4c40-93e2-731f31b9268b + hash-algorithm=sha256 + hex-digest=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 + +output: + v0/ns/payments%20prod/at/Capture%2FCharge/ai/activity%20id%2B42/ri/9e1d1fd9-2f8a-4c40-93e2-731f31b9268b/d/sha256/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 +``` diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriver.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriver.java new file mode 100644 index 0000000000..65c7f522a7 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriver.java @@ -0,0 +1,313 @@ +package io.temporal.payload.storage.s3driver; + +import com.google.protobuf.InvalidProtocolBufferException; +import io.temporal.api.common.v1.Payload; +import io.temporal.common.Experimental; +import io.temporal.payload.storage.StorageDriver; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; +import javax.annotation.Nonnull; + +/** + * {@link StorageDriver} that stores payloads in Amazon S3 under content-addressable keys derived + * from the SHA-256 hash of the serialized payload. + * + *

Construct via {@link #newBuilder()}. + */ +@Experimental +public final class S3StorageDriver implements StorageDriver { + private static final String DRIVER_TYPE = "aws.s3driver"; + private static final String DEFAULT_DRIVER_NAME = "aws.s3driver"; + private static final int DEFAULT_MAX_PAYLOAD_SIZE = 50 * 1024 * 1024; + private static final String HASH_ALGORITHM = "sha256"; + + private static final String CLAIM_BUCKET = "bucket"; + private static final String CLAIM_KEY = "key"; + private static final String CLAIM_HASH_ALGORITHM = "hash_algorithm"; + private static final String CLAIM_HASH_VALUE = "hash_value"; + + public static Builder newBuilder() { + return new Builder(); + } + + private final @Nonnull S3StorageDriverClient client; + private final @Nonnull BucketResolver bucketResolver; + private final @Nonnull String name; + private final int maxPayloadSize; + + private S3StorageDriver( + @Nonnull S3StorageDriverClient client, + @Nonnull BucketResolver bucketResolver, + @Nonnull String name, + int maxPayloadSize) { + this.client = client; + this.bucketResolver = bucketResolver; + this.name = name; + this.maxPayloadSize = maxPayloadSize; + } + + @Nonnull + @Override + public String getName() { + return name; + } + + @Nonnull + @Override + public String getType() { + return DRIVER_TYPE; + } + + @Nonnull + @Override + public CompletableFuture> store( + @Nonnull StorageDriverStoreContext context, @Nonnull List payloads) { + for (Payload payload : payloads) { + int size = payload.getSerializedSize(); + if (size > maxPayloadSize) { + return failedFuture( + new S3StorageException("payload size " + size + " exceeds maximum " + maxPayloadSize)); + } + } + + StorageDriverTargetInfo target = context.getTarget(); + String describeSuffix = describeSuffix(); + List> claimFutures = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + byte[] data = payload.toByteArray(); + String hexDigest = PayloadHasher.sha256Hex(data); + String bucket = bucketResolver.resolveBucket(context, payload); + String key = S3StorageKey.forPayload(target, HASH_ALGORITHM, hexDigest); + String location = storageLocation(bucket, key, describeSuffix); + + CompletableFuture existsRequest = client.objectExists(bucket, key); + // We track current inflight request for cancellation + AtomicReference> inFlightRequest = new AtomicReference<>(existsRequest); + CompletableFuture claimFuture = + withFailureContext(existsRequest, "existence check failed " + location) + .thenCompose( + exists -> { + if (exists) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture uploadRequest = client.putObject(bucket, key, data); + inFlightRequest.set(uploadRequest); + return withFailureContext(uploadRequest, "upload failed " + location); + }) + .thenApply(ignored -> claimFor(bucket, key, hexDigest)); + cancelRequestWhenCancelled(claimFuture, inFlightRequest); + claimFutures.add(claimFuture); + } + return CompletableFutures.allAsList(claimFutures); + } + + @Nonnull + @Override + public CompletableFuture> retrieve( + @Nonnull StorageDriverRetrieveContext context, @Nonnull List claims) { + String describeSuffix = describeSuffix(); + List> payloadFutures = new ArrayList<>(claims.size()); + for (StorageDriverClaim claim : claims) { + Map claimData = claim.getClaimData(); + String bucket = claimData.get(CLAIM_BUCKET); + if (bucket == null) { + payloadFutures.add(failedFuture(missingField(CLAIM_BUCKET))); + continue; + } + String key = claimData.get(CLAIM_KEY); + if (key == null) { + payloadFutures.add(failedFuture(missingField(CLAIM_KEY))); + continue; + } + String location = storageLocation(bucket, key, describeSuffix); + CompletableFuture downloadRequest = client.getObject(bucket, key); + CompletableFuture payloadFuture = + withFailureContext(downloadRequest, "download failed " + location) + .thenApply(data -> verifyAndParse(claimData, bucket, key, data)); + cancelRequestWhenCancelled(payloadFuture, downloadRequest); + payloadFutures.add(payloadFuture); + } + return CompletableFutures.allAsList(payloadFutures); + } + + private StorageDriverClaim claimFor(String bucket, String key, String hexDigest) { + Map claimData = new HashMap<>(); + claimData.put(CLAIM_BUCKET, bucket); + claimData.put(CLAIM_KEY, key); + claimData.put(CLAIM_HASH_ALGORITHM, HASH_ALGORITHM); + claimData.put(CLAIM_HASH_VALUE, hexDigest); + return new StorageDriverClaim(claimData); + } + + private Payload verifyAndParse( + Map claimData, String bucket, String key, byte[] data) { + String algorithm = claimData.get(CLAIM_HASH_ALGORITHM); + if (algorithm == null) { + throw missingField(CLAIM_HASH_ALGORITHM); + } + if (!HASH_ALGORITHM.equals(algorithm)) { + throw new S3StorageException("unsupported hash algorithm \"" + algorithm + "\""); + } + String expectedHash = claimData.get(CLAIM_HASH_VALUE); + if (expectedHash == null) { + throw missingField(CLAIM_HASH_VALUE); + } + String actualHash = PayloadHasher.sha256Hex(data); + if (!actualHash.equals(expectedHash)) { + throw new S3StorageException( + "integrity check failed [bucket=" + + bucket + + ", key=" + + key + + "]: expected hash " + + expectedHash + + ", got " + + actualHash); + } + try { + return Payload.parseFrom(data); + } catch (InvalidProtocolBufferException e) { + throw new S3StorageException( + "failed to unmarshal payload [bucket=" + bucket + ", key=" + key + "]", e); + } + } + + private static String storageLocation(String bucket, String key, String describeSuffix) { + return "[bucket=" + bucket + ", key=" + key + describeSuffix + "]"; + } + + /** + * Renders {@link S3StorageDriverClient#describe()} as a {@code ", k=v"} suffix for failure + * messages. + */ + private String describeSuffix() { + Map describe = client.describe(); + if (describe == null || describe.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Map.Entry entry : describe.entrySet()) { + sb.append(", ").append(entry.getKey()).append("=").append(entry.getValue()); + } + return sb.toString(); + } + + private static S3StorageException missingField(String field) { + return new S3StorageException("claim missing field \"" + field + "\""); + } + + /** Cancels {@code request} when {@code pipeline} is cancelled */ + private static void cancelRequestWhenCancelled( + CompletableFuture pipeline, CompletableFuture request) { + pipeline.whenComplete( + (value, ex) -> { + if (pipeline.isCancelled()) { + request.cancel(true); + } + }); + } + + /** Cancels {@code request} when {@code pipeline} is cancelled */ + private static void cancelRequestWhenCancelled( + CompletableFuture pipeline, AtomicReference> inFlightRequest) { + pipeline.whenComplete( + (value, ex) -> { + if (pipeline.isCancelled()) { + inFlightRequest.get().cancel(true); + } + }); + } + + private static CompletableFuture withFailureContext( + CompletableFuture future, String failureMessage) { + return future.handle( + (value, ex) -> { + if (ex == null) { + return value; + } + Throwable cause = CompletableFutures.unwrap(ex); + String causeMessage = cause.getMessage() != null ? cause.getMessage() : cause.toString(); + throw new S3StorageException(failureMessage + ": " + causeMessage, cause); + }); + } + + private static CompletableFuture failedFuture(Throwable t) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(t); + return future; + } + + public static final class Builder { + private S3StorageDriverClient client; + private BucketResolver bucketResolver; + private String name = DEFAULT_DRIVER_NAME; + private int maxPayloadSize = DEFAULT_MAX_PAYLOAD_SIZE; + + private Builder() {} + + /** Required. The S3 client used for storage operations. */ + public Builder setClient(@Nonnull S3StorageDriverClient client) { + this.client = Objects.requireNonNull(client, "client"); + return this; + } + + /** + * Stores every payload in a fixed bucket. Convenience for a {@link BucketResolver} that always + * returns {@code bucket}. The last of {@code setBucket}/{@code setBucketResolver} wins. + */ + public Builder setBucket(@Nonnull String bucket) { + Objects.requireNonNull(bucket, "bucket"); + this.bucketResolver = (context, payload) -> bucket; + return this; + } + + /** + * Selects the bucket per payload. The last of {@code setBucket}/{@code setBucketResolver} wins. + */ + public Builder setBucketResolver(@Nonnull BucketResolver bucketResolver) { + this.bucketResolver = Objects.requireNonNull(bucketResolver, "bucketResolver"); + return this; + } + + /** + * Stable, unique identifier for this driver instance. Defaults to {@code "aws.s3driver"}; + * override it when registering multiple S3 drivers with distinct configurations. + */ + public Builder setName(@Nonnull String name) { + this.name = Objects.requireNonNull(name, "name"); + return this; + } + + /** + * Maximum serialized payload size in bytes the driver accepts. Must be positive. Defaults to 50 + * MiB. Storing a larger payload fails the {@code store} call. + */ + public Builder setMaxPayloadSize(int maxPayloadSize) { + if (maxPayloadSize <= 0) { + throw new IllegalArgumentException( + "maxPayloadSize must be positive, got " + maxPayloadSize); + } + this.maxPayloadSize = maxPayloadSize; + return this; + } + + public S3StorageDriver build() { + if (client == null) { + throw new IllegalStateException("client is required"); + } + if (bucketResolver == null) { + throw new IllegalStateException("a bucket or bucket resolver is required"); + } + return new S3StorageDriver(client, bucketResolver, name, maxPayloadSize); + } + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriverClient.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriverClient.java new file mode 100644 index 0000000000..beced766eb --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageDriverClient.java @@ -0,0 +1,47 @@ +package io.temporal.payload.storage.s3driver; + +import io.temporal.common.Experimental; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import javax.annotation.Nonnull; + +/** + * Interface for S3 {@link S3StorageDriver} operations: upload, existence check, and download. + * + *

Cancelling a returned future makes a best-effort attempt to abort the in-flight requests. + */ +@Experimental +public interface S3StorageDriverClient { + /** + * Uploads {@code data} to the given {@code bucket} and {@code key}, overwriting any existing + * object at that key. Implementations must be safe to call concurrently for different keys. + */ + @Nonnull + CompletableFuture putObject( + @Nonnull String bucket, @Nonnull String key, @Nonnull byte[] data); + + /** + * Reports whether an object exists at the given {@code bucket} and {@code key}. The future + * completes with {@code false} when the object is absent, and completes exceptionally when + * existence cannot be determined (e.g. a network or permission failure). + */ + @Nonnull + CompletableFuture objectExists(@Nonnull String bucket, @Nonnull String key); + + /** + * Downloads the bytes stored at the given {@code bucket} and {@code key}. The future completes + * exceptionally if the object does not exist. + */ + @Nonnull + CompletableFuture getObject(@Nonnull String bucket, @Nonnull String key); + + /** + * Diagnostic metadata about the client configuration, such as {@code {"client_region": + * "us-west-2"}}, that the driver appends to error messages. Returns an empty map by default. + */ + @Nonnull + default Map describe() { + return Collections.emptyMap(); + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageException.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageException.java new file mode 100644 index 0000000000..4a85e883e0 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageException.java @@ -0,0 +1,15 @@ +package io.temporal.payload.storage.s3driver; + +import io.temporal.common.Experimental; + +/** Thrown when an {@link S3StorageDriver} store or retrieve operation fails. */ +@Experimental +public class S3StorageException extends RuntimeException { + public S3StorageException(String message) { + super(message); + } + + public S3StorageException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageKey.java b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageKey.java new file mode 100644 index 0000000000..d347108e4e --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/main/java/io/temporal/payload/storage/s3driver/S3StorageKey.java @@ -0,0 +1,74 @@ +package io.temporal.payload.storage.s3driver; + +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import java.nio.charset.StandardCharsets; + +/** + * Builds the content-addressable S3 object key. The key format and percent-encoding rules are the + * cross-SDK specification documented in this package's {@code README.md}. + */ +final class S3StorageKey { + private static final String KEY_VERSION = "v0"; + + // Percent-encode every key byte except letters, digits, and these. This is Python's + // quote(safe="") scheme restricted to characters AWS documents as always-safe in S3 object keys. + // Notably '~' is excluded (AWS lists it under "characters to avoid"), so it is escaped here even + // though Python leaves it literal. + private static final String SAFE_PUNCTUATION = "-_."; + + private S3StorageKey() {} + + static String forPayload(StorageDriverTargetInfo target, String hashAlgorithm, String hexDigest) { + String digestSegment = "/d/" + hashAlgorithm + "/" + hexDigest; + if (target instanceof StorageDriverWorkflowInfo) { + StorageDriverWorkflowInfo wf = (StorageDriverWorkflowInfo) target; + return KEY_VERSION + + "/ns/" + + escapePathSegment(wf.getNamespace()) + + "/wt/" + + escapePathSegment(wf.getType()) + + "/wi/" + + escapePathSegment(wf.getId()) + + "/ri/" + + escapePathSegment(wf.getRunId()) + + digestSegment; + } + if (target instanceof StorageDriverActivityInfo) { + StorageDriverActivityInfo act = (StorageDriverActivityInfo) target; + return KEY_VERSION + + "/ns/" + + escapePathSegment(act.getNamespace()) + + "/at/" + + escapePathSegment(act.getType()) + + "/ai/" + + escapePathSegment(act.getId()) + + "/ri/" + + escapePathSegment(act.getRunId()) + + digestSegment; + } + return KEY_VERSION + digestSegment; + } + + static String escapePathSegment(String value) { + if (value == null || value.isEmpty()) { + return "null"; + } + StringBuilder sb = new StringBuilder(value.length()); + for (byte b : value.getBytes(StandardCharsets.UTF_8)) { + int c = b & 0xFF; + if ((c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || SAFE_PUNCTUATION.indexOf(c) >= 0) { + sb.append((char) c); + } else { + sb.append('%'); + sb.append(Character.toUpperCase(Character.forDigit((c >> 4) & 0xF, 16))); + sb.append(Character.toUpperCase(Character.forDigit(c & 0xF, 16))); + } + } + return sb.toString(); + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageDriverTest.java b/contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageDriverTest.java new file mode 100644 index 0000000000..eba9758e76 --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageDriverTest.java @@ -0,0 +1,573 @@ +package io.temporal.payload.storage.s3driver; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.payload.storage.StorageDriverActivityInfo; +import io.temporal.payload.storage.StorageDriverClaim; +import io.temporal.payload.storage.StorageDriverRetrieveContext; +import io.temporal.payload.storage.StorageDriverStoreContext; +import io.temporal.payload.storage.StorageDriverTargetInfo; +import io.temporal.payload.storage.StorageDriverWorkflowInfo; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.Test; + +public class S3StorageDriverTest { + + private static Payload payload(String data) { + return Payload.newBuilder() + .putMetadata("encoding", ByteString.copyFromUtf8("binary/plain")) + .setData(ByteString.copyFromUtf8(data)) + .build(); + } + + private static S3StorageDriver driver(S3StorageDriverClient client) { + return S3StorageDriver.newBuilder().setClient(client).setBucket("test-bucket").build(); + } + + private static StorageDriverStoreContext storeContext() { + return () -> null; + } + + private static StorageDriverStoreContext storeContext(StorageDriverTargetInfo target) { + return () -> target; + } + + private static final StorageDriverRetrieveContext RETRIEVE_CONTEXT = + new StorageDriverRetrieveContext() {}; + + /** Joins a future expected to fail and returns the message of the underlying cause. */ + private static String failureMessage(CompletableFuture future) { + try { + future.join(); + } catch (CompletionException e) { + Throwable cause = e; + while (cause instanceof CompletionException && cause.getCause() != null) { + cause = cause.getCause(); + } + return cause.getMessage(); + } + fail("expected the future to fail"); + return null; + } + + // --- Builder --- + + @Test + public void builderDefaults() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + assertEquals("aws.s3driver", driver.getName()); + assertEquals("aws.s3driver", driver.getType()); + } + + @Test + public void builderCustomName() { + S3StorageDriver driver = + S3StorageDriver.newBuilder() + .setClient(new InMemoryS3StorageDriverClient()) + .setBucket("b") + .setName("custom-name") + .build(); + assertEquals("custom-name", driver.getName()); + } + + @Test(expected = IllegalStateException.class) + public void builderRequiresClient() { + S3StorageDriver.newBuilder().setBucket("b").build(); + } + + @Test(expected = IllegalStateException.class) + public void builderRequiresBucket() { + S3StorageDriver.newBuilder().setClient(new InMemoryS3StorageDriverClient()).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void builderRejectsNonPositiveMaxPayloadSize() { + S3StorageDriver.newBuilder().setMaxPayloadSize(0); + } + + // --- Store --- + + @Test + public void storeSinglePayloadProducesClaim() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + + List claims = + driver.store(storeContext(), Collections.singletonList(payload("hello"))).join(); + + assertEquals(1, claims.size()); + Map claimData = claims.get(0).getClaimData(); + assertEquals("test-bucket", claimData.get("bucket")); + assertEquals("sha256", claimData.get("hash_algorithm")); + assertFalse(claimData.get("hash_value").isEmpty()); + assertEquals("v0/d/sha256/" + claimData.get("hash_value"), claimData.get("key")); + } + + @Test + public void storeEmptyPayloadsProducesNoClaims() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + assertTrue(driver.store(storeContext(), Collections.emptyList()).join().isEmpty()); + } + + @Test + public void storeDeduplicatesIdenticalPayloads() { + InMemoryS3StorageDriverClient client = new InMemoryS3StorageDriverClient(); + S3StorageDriver driver = driver(client); + Payload p = payload("duplicate-me"); + + driver.store(storeContext(), Collections.singletonList(p)).join(); + assertEquals(1, client.putCount.get()); + + driver.store(storeContext(), Collections.singletonList(p)).join(); + assertEquals(1, client.putCount.get()); + } + + @Test + public void storeMultiplePayloadsProducesDistinctKeys() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + + List claims = + driver + .store(storeContext(), Arrays.asList(payload("a"), payload("b"), payload("c"))) + .join(); + + assertEquals(3, claims.size()); + assertEquals(3, claims.stream().map(c -> c.getClaimData().get("key")).distinct().count()); + } + + @Test + public void storeRejectsOversizedPayload() { + S3StorageDriver driver = + S3StorageDriver.newBuilder() + .setClient(new InMemoryS3StorageDriverClient()) + .setBucket("b") + .setMaxPayloadSize(10) + .build(); + + String message = + failureMessage( + driver.store( + storeContext(), + Collections.singletonList(payload("definitely longer than ten bytes")))); + assertTrue( + message, message.contains("payload size ") && message.contains("exceeds maximum 10")); + } + + @Test + public void storeUploadsNothingWhenAnyPayloadFailsValidation() { + InMemoryS3StorageDriverClient client = new InMemoryS3StorageDriverClient(); + Payload small = payload("small"); + Payload oversized = payload(String.join("", Collections.nCopies(1000, "x"))); + S3StorageDriver driver = + S3StorageDriver.newBuilder() + .setClient(client) + .setBucket("b") + .setMaxPayloadSize(small.getSerializedSize()) + .build(); + + // The valid payload precedes the oversized one; validation must reject the batch before any + // upload starts, leaving nothing written to S3. + failureMessage(driver.store(storeContext(), Arrays.asList(small, oversized))); + assertEquals(0, client.putCount.get()); + } + + @Test + public void storeResolvesBucketPerPayload() { + S3StorageDriver driver = + S3StorageDriver.newBuilder() + .setClient(new InMemoryS3StorageDriverClient()) + .setBucketResolver( + (context, payload) -> + "a".equals(payload.getData().toStringUtf8()) ? "bucket-a" : "bucket-b") + .build(); + + List claims = + driver.store(storeContext(), Arrays.asList(payload("a"), payload("b"))).join(); + + assertEquals("bucket-a", claims.get(0).getClaimData().get("bucket")); + assertEquals("bucket-b", claims.get(1).getClaimData().get("bucket")); + } + + @Test + public void storeWrapsUploadErrorWithContext() { + InMemoryS3StorageDriverClient client = new InMemoryS3StorageDriverClient(); + client.putError = new RuntimeException("access denied"); + S3StorageDriver driver = driver(client); + + String message = + failureMessage(driver.store(storeContext(), Collections.singletonList(payload("x")))); + assertTrue(message, message.startsWith("upload failed [bucket=test-bucket, key=")); + assertTrue(message, message.endsWith(", client_region=ap-southeast-2]: access denied")); + } + + @Test + public void storeWrapsExistenceCheckErrorWithContext() { + InMemoryS3StorageDriverClient client = new InMemoryS3StorageDriverClient(); + client.existsError = new RuntimeException("network timeout"); + S3StorageDriver driver = driver(client); + + String message = + failureMessage(driver.store(storeContext(), Collections.singletonList(payload("x")))); + assertTrue(message, message.startsWith("existence check failed [bucket=test-bucket, key=")); + assertTrue(message, message.endsWith(", client_region=ap-southeast-2]: network timeout")); + } + + // --- Store with target identity --- + + @Test + public void storeKeyIncludesWorkflowTarget() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + StorageDriverTargetInfo target = + new StorageDriverWorkflowInfo("default", "wf-123", "run-456", "MyWorkflow"); + + String key = + driver + .store(storeContext(target), Collections.singletonList(payload("p"))) + .join() + .get(0) + .getClaimData() + .get("key"); + assertTrue(key, key.startsWith("v0/ns/default/wt/MyWorkflow/wi/wf-123/ri/run-456/d/sha256/")); + } + + @Test + public void storeKeyIncludesActivityTarget() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + StorageDriverTargetInfo target = + new StorageDriverActivityInfo("default", "act-789", "run-abc", "MyActivity"); + + String key = + driver + .store(storeContext(target), Collections.singletonList(payload("p"))) + .join() + .get(0) + .getClaimData() + .get("key"); + assertTrue(key, key.startsWith("v0/ns/default/at/MyActivity/ai/act-789/ri/run-abc/d/sha256/")); + } + + @Test + public void storeKeyPercentEncodesSpecialChars() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + StorageDriverTargetInfo target = + new StorageDriverWorkflowInfo("my namespace", "wf id+1", "run=abc", "my/workflow"); + + String key = + driver + .store(storeContext(target), Collections.singletonList(payload("p"))) + .join() + .get(0) + .getClaimData() + .get("key"); + assertTrue( + key, + key.startsWith( + "v0/ns/my%20namespace/wt/my%2Fworkflow/wi/wf%20id%2B1/ri/run%3Dabc/d/sha256/")); + } + + @Test + public void storageKeyEscapesPathSegmentsByContract() { + assertEquals("null", S3StorageKey.escapePathSegment(null)); + assertEquals("null", S3StorageKey.escapePathSegment("")); + assertEquals( + "azAZ09-_.%7E%24%26%2B%3A%3D%40", S3StorageKey.escapePathSegment("azAZ09-_.~$&+:=@")); + assertEquals( + "space%20slash%2Fpercent%25snowman%E2%98%83", + S3StorageKey.escapePathSegment("space slash/percent%snowman\u2603")); + } + + @Test + public void storageKeyReadmeExamples() { + // Segment encoding examples. + assertEquals("my%20namespace", S3StorageKey.escapePathSegment("my namespace")); + assertEquals("my%2Fworkflow", S3StorageKey.escapePathSegment("my/workflow")); + assertEquals("wf%20id%2B1", S3StorageKey.escapePathSegment("wf id+1")); + assertEquals("attempt%3D1", S3StorageKey.escapePathSegment("attempt=1")); + + String workflowDigest = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; + String activityDigest = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"; + String fallbackDigest = "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"; + + // Workflow full-key example. + assertEquals( + "v0/ns/payments%20prod/wt/ChargeWorkflow/wi/order%2B123%3Dabc/ri/3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31/d/sha256/" + + workflowDigest, + S3StorageKey.forPayload( + new StorageDriverWorkflowInfo( + "payments prod", + "order+123=abc", + "3f1d6c7a-8b2e-4f7a-9d0a-87a6f95e4d31", + "ChargeWorkflow"), + "sha256", + workflowDigest)); + + // Activity full-key example. + assertEquals( + "v0/ns/payments%20prod/at/Capture%2FCharge/ai/activity%20id%2B42/ri/9e1d1fd9-2f8a-4c40-93e2-731f31b9268b/d/sha256/" + + activityDigest, + S3StorageKey.forPayload( + new StorageDriverActivityInfo( + "payments prod", + "activity id+42", + "9e1d1fd9-2f8a-4c40-93e2-731f31b9268b", + "Capture/Charge"), + "sha256", + activityDigest)); + + // Fallback full-key example. + assertEquals( + "v0/d/sha256/" + fallbackDigest, S3StorageKey.forPayload(null, "sha256", fallbackDigest)); + } + + @Test + public void storeSamePayloadDifferentTargetsProducesDifferentKeys() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + Payload p = payload("shared"); + + String wfKey = + driver + .store( + storeContext(new StorageDriverWorkflowInfo("ns", "wf-1", "run-1", "WF")), + Collections.singletonList(p)) + .join() + .get(0) + .getClaimData() + .get("key"); + String actKey = + driver + .store( + storeContext(new StorageDriverActivityInfo("ns", "act-1", "run-1", "ACT")), + Collections.singletonList(p)) + .join() + .get(0) + .getClaimData() + .get("key"); + assertNotEquals(wfKey, actKey); + } + + // --- Retrieve --- + + @Test + public void retrieveRoundTrip() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + Payload original = payload("round-trip data"); + + List claims = + driver.store(storeContext(), Collections.singletonList(original)).join(); + List restored = driver.retrieve(RETRIEVE_CONTEXT, claims).join(); + + assertEquals(1, restored.size()); + assertEquals(original, restored.get(0)); + } + + @Test + public void retrieveRoundTripMultiplePreservesOrder() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + List originals = Arrays.asList(payload("x"), payload("y"), payload("z")); + + List claims = driver.store(storeContext(), originals).join(); + List restored = driver.retrieve(RETRIEVE_CONTEXT, claims).join(); + + assertEquals(originals, restored); + } + + @Test + public void retrieveDetectsCorruptedData() { + InMemoryS3StorageDriverClient client = new InMemoryS3StorageDriverClient(); + S3StorageDriver driver = driver(client); + + List claims = + driver.store(storeContext(), Collections.singletonList(payload("legit"))).join(); + client.objects.replaceAll((k, v) -> "corrupted".getBytes()); + + String message = failureMessage(driver.retrieve(RETRIEVE_CONTEXT, claims)); + assertTrue(message, message.startsWith("integrity check failed [bucket=test-bucket, key=")); + } + + @Test + public void retrieveRejectsUnsupportedHashAlgorithm() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + List claims = + driver.store(storeContext(), Collections.singletonList(payload("data"))).join(); + + Map tampered = new HashMap<>(claims.get(0).getClaimData()); + tampered.put("hash_algorithm", "md5"); + + String message = + failureMessage( + driver.retrieve( + RETRIEVE_CONTEXT, Collections.singletonList(new StorageDriverClaim(tampered)))); + assertEquals("unsupported hash algorithm \"md5\"", message); + } + + @Test + public void retrieveRejectsClaimMissingBucket() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + StorageDriverClaim claim = + new StorageDriverClaim(Collections.singletonMap("key", "v0/d/sha256/abc")); + + assertEquals( + "claim missing field \"bucket\"", + failureMessage(driver.retrieve(RETRIEVE_CONTEXT, Collections.singletonList(claim)))); + } + + @Test + public void retrieveRejectsClaimMissingKey() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + StorageDriverClaim claim = + new StorageDriverClaim(Collections.singletonMap("bucket", "test-bucket")); + + assertEquals( + "claim missing field \"key\"", + failureMessage(driver.retrieve(RETRIEVE_CONTEXT, Collections.singletonList(claim)))); + } + + @Test + public void retrieveRejectsClaimMissingHashAlgorithm() { + S3StorageDriver driver = driver(new InMemoryS3StorageDriverClient()); + List claims = + driver.store(storeContext(), Collections.singletonList(payload("x"))).join(); + + Map tampered = new HashMap<>(claims.get(0).getClaimData()); + tampered.remove("hash_algorithm"); + + assertEquals( + "claim missing field \"hash_algorithm\"", + failureMessage( + driver.retrieve( + RETRIEVE_CONTEXT, Collections.singletonList(new StorageDriverClaim(tampered))))); + } + + @Test + public void retrieveWrapsDownloadErrorWithContext() { + InMemoryS3StorageDriverClient client = new InMemoryS3StorageDriverClient(); + S3StorageDriver driver = driver(client); + List claims = + driver.store(storeContext(), Collections.singletonList(payload("data"))).join(); + + client.getError = new RuntimeException("throttled"); + + String message = failureMessage(driver.retrieve(RETRIEVE_CONTEXT, claims)); + assertTrue(message, message.startsWith("download failed [bucket=test-bucket, key=")); + assertTrue(message, message.endsWith(", client_region=ap-southeast-2]: throttled")); + } + + @Test(timeout = 5000) + public void storeFailsFastAndCancelsInFlightUploads() { + // The first upload fails; the second stays pending. The batch must surface the failure promptly + // (rather than blocking on the pending upload), as an unwrapped S3StorageException, and must + // cancel the still-running upload. + HoldSecondUploadClient client = new HoldSecondUploadClient(); + S3StorageDriver driver = S3StorageDriver.newBuilder().setClient(client).setBucket("b").build(); + + CompletableFuture> future = + driver.store(storeContext(), Arrays.asList(payload("a"), payload("b"))); + + try { + future.join(); + fail("expected the future to fail"); + } catch (CompletionException e) { + assertTrue(String.valueOf(e.getCause()), e.getCause() instanceof S3StorageException); + assertTrue(e.getCause().getMessage(), e.getCause().getMessage().endsWith(": boom")); + } + assertTrue("the in-flight upload should be cancelled", client.secondUpload.isCancelled()); + } + + /** + * Fails the first upload and leaves the second pending (cancellable), to exercise fail-fast and + * in-flight cancellation. + */ + private static final class HoldSecondUploadClient implements S3StorageDriverClient { + private final AtomicInteger puts = new AtomicInteger(); + final CompletableFuture secondUpload = new CompletableFuture<>(); + + @Override + public CompletableFuture putObject(String bucket, String key, byte[] data) { + if (puts.incrementAndGet() == 1) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new RuntimeException("boom")); + return failed; + } + return secondUpload; + } + + @Override + public CompletableFuture objectExists(String bucket, String key) { + return CompletableFuture.completedFuture(false); + } + + @Override + public CompletableFuture getObject(String bucket, String key) { + CompletableFuture failed = new CompletableFuture<>(); + failed.completeExceptionally(new UnsupportedOperationException()); + return failed; + } + } + + /** In-memory {@link S3StorageDriverClient} with optional error injection, for unit tests. */ + private static final class InMemoryS3StorageDriverClient implements S3StorageDriverClient { + final Map objects = new ConcurrentHashMap<>(); + final AtomicInteger putCount = new AtomicInteger(); + RuntimeException putError; + RuntimeException getError; + RuntimeException existsError; + + private static String objectKey(String bucket, String key) { + return bucket + "/" + key; + } + + @Override + public CompletableFuture putObject(String bucket, String key, byte[] data) { + if (putError != null) { + return failed(putError); + } + putCount.incrementAndGet(); + objects.put(objectKey(bucket, key), data.clone()); + return CompletableFuture.completedFuture(null); + } + + @Override + public CompletableFuture objectExists(String bucket, String key) { + if (existsError != null) { + return failed(existsError); + } + return CompletableFuture.completedFuture(objects.containsKey(objectKey(bucket, key))); + } + + @Override + public CompletableFuture getObject(String bucket, String key) { + if (getError != null) { + return failed(getError); + } + byte[] data = objects.get(objectKey(bucket, key)); + if (data == null) { + return failed(new RuntimeException("not found: " + objectKey(bucket, key))); + } + return CompletableFuture.completedFuture(data.clone()); + } + + @Override + public Map describe() { + return Collections.singletonMap("client_region", "ap-southeast-2"); + } + + private static CompletableFuture failed(Throwable t) { + CompletableFuture future = new CompletableFuture<>(); + future.completeExceptionally(t); + return future; + } + } +} diff --git a/contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageKeyTest.java b/contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageKeyTest.java new file mode 100644 index 0000000000..acc3c4a1ff --- /dev/null +++ b/contrib/temporal-payload-storage-s3driver/src/test/java/io/temporal/payload/storage/s3driver/S3StorageKeyTest.java @@ -0,0 +1,37 @@ +package io.temporal.payload.storage.s3driver; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +public class S3StorageKeyTest { + + @Test + public void escapesEmptyAndNullAsNull() { + assertEquals("null", S3StorageKey.escapePathSegment("")); + assertEquals("null", S3StorageKey.escapePathSegment(null)); + } + + @Test + public void leavesOnlyAwsSafePunctuationUnescaped() { + assertEquals("AZaz09-_.", S3StorageKey.escapePathSegment("AZaz09-_.")); + } + + @Test + public void escapesPunctuationOutsideTheSafeSet() { + // '~' and these sub-delims are not in AWS's safe set, so (unlike Python/Go) they are escaped. + assertEquals("%7E%24%26%2B%3A%3D%40", S3StorageKey.escapePathSegment("~$&+:=@")); + } + + @Test + public void percentEncodesReservedCharactersAndSpace() { + assertEquals("a%2Fb%20c", S3StorageKey.escapePathSegment("a/b c")); + } + + @Test + public void percentEncodesMultibyteUtf8() { + // 'é' (U+00E9) is the two UTF-8 bytes C3 A9; '€' (U+20AC) is the three bytes E2 82 AC. + assertEquals("caf%C3%A9", S3StorageKey.escapePathSegment("café")); + assertEquals("%E2%82%AC", S3StorageKey.escapePathSegment("€")); + } +} diff --git a/settings.gradle b/settings.gradle index 6cbf879490..7ec1835ef7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,10 @@ include 'temporal-aws-lambda' project(':temporal-aws-lambda').projectDir = file('contrib/temporal-aws-lambda') include 'temporal-gcp-cloud-run' project(':temporal-gcp-cloud-run').projectDir = file('contrib/temporal-gcp-cloud-run') +include 'temporal-payload-storage-s3driver' +project(':temporal-payload-storage-s3driver').projectDir = file('contrib/temporal-payload-storage-s3driver') +include 'temporal-payload-storage-s3driver-awssdkv2' +project(':temporal-payload-storage-s3driver-awssdkv2').projectDir = file('contrib/temporal-payload-storage-s3driver-awssdkv2') include 'temporal-spring-boot-autoconfigure' include 'temporal-spring-boot-starter' include 'temporal-remote-data-encoder' From f6968a4e91db1e63dc7c806a9cfb9ee311c625cf Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Mon, 14 Sep 2026 16:14:52 -0400 Subject: [PATCH 097/107] Removed @Experimental annotations from Standalone Activities API (#3071) --- .../ActivityAlreadyStartedException.java | 2 - .../io/temporal/client/ActivityClient.java | 2 - .../client/ActivityClientOptions.java | 2 - .../client/ActivityCompletionClient.java | 72 ++++++++++--------- .../io/temporal/client/ActivityException.java | 2 - .../client/ActivityExecutionCount.java | 3 - .../client/ActivityExecutionDescription.java | 2 - .../client/ActivityExecutionMetadata.java | 2 - .../client/ActivityFailedException.java | 2 - .../io/temporal/client/ActivityHandle.java | 2 - .../client/ActivityPausedException.java | 2 - .../client/ActivityResetException.java | 2 - .../client/DescribeActivityOptions.java | 2 - .../temporal/client/PauseActivityOptions.java | 2 - .../temporal/client/StartActivityOptions.java | 2 - .../client/UnpauseActivityOptions.java | 2 - .../client/UntypedActivityHandle.java | 7 +- .../ActivityClientCallsInterceptor.java | 17 +---- .../ActivityClientCallsInterceptorBase.java | 2 - .../ActivityClientInterceptorBase.java | 3 - .../external/GenericWorkflowClient.java | 10 --- 21 files changed, 46 insertions(+), 96 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityAlreadyStartedException.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityAlreadyStartedException.java index 4c93480910..141bb3d0d6 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityAlreadyStartedException.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityAlreadyStartedException.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import javax.annotation.Nullable; /** @@ -9,7 +8,6 @@ * requested {@link StartActivityOptions#getIdReusePolicy()} / {@link * StartActivityOptions#getIdConflictPolicy()}). */ -@Experimental public final class ActivityAlreadyStartedException extends ActivityException { private final String activityType; diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityClient.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityClient.java index 96f3f7ae55..4ef07f96c2 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityClient.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.workflow.Functions; import java.lang.reflect.Type; @@ -33,7 +32,6 @@ * String result = client.execute("MyActivityType", String.class, options, 1, 2); * } */ -@Experimental public interface ActivityClient { /** Creates a new {@code ActivityClient} with default options. */ diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityClientOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityClientOptions.java index 05604f51cd..5298f81140 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityClientOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityClientOptions.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import io.temporal.common.context.ContextPropagator; import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.GlobalDataConverter; @@ -11,7 +10,6 @@ import java.util.Objects; /** Options for {@link ActivityClient} configuration. */ -@Experimental public final class ActivityClientOptions { public static ActivityClientOptions.Builder newBuilder() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityCompletionClient.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityCompletionClient.java index bc3033f58e..6e49690c20 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityCompletionClient.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityCompletionClient.java @@ -19,7 +19,9 @@ public interface ActivityCompletionClient { /** - * Completes the activity execution successfully. + * Completes an activity execution successfully using a task token. + * + *

This overload works with both workflow activities and standalone activities. * * @param taskToken token of the activity attempt to complete * @param result of the activity execution @@ -27,9 +29,10 @@ public interface ActivityCompletionClient { void complete(byte[] taskToken, R result) throws ActivityCompletionException; /** - * Completes the activity execution successfully. + * Completes a workflow activity execution successfully using workflow and activity IDs. * - *

This method is only for activities run within a workflow. + *

This overload is only for workflow activities. To complete a standalone activity by ID, use + * {@link #completeStandalone(String, Optional, Object)}. * * @param workflowId id of the workflow that started the activity * @param runId optional run id of the workflow that started the activity @@ -40,22 +43,22 @@ void complete(String workflowId, Optional runId, String activityId, throws ActivityCompletionException; /** - * Completes a standalone activity execution successfully. + * Completes a standalone activity execution successfully using activity ID. * - *

Use this overload for standalone activities (not started from a workflow). For - * workflow-scheduled activities, use {@link #complete(String, Optional, String, Object)}. To - * complete a standalone activity using a task token, use {@link #complete(byte[], Object)}. + *

This method is only for standalone activities. To complete a workflow activity by ID, use + * {@link #complete(String, Optional, String, Object)}. * * @param activityId id of the standalone activity * @param activityRunId optional run id of the standalone activity, or {@code Optional.empty()} * @param result of the activity execution */ - @Experimental void completeStandalone(String activityId, Optional activityRunId, R result) throws ActivityCompletionException; /** - * Completes the activity execution with failure. + * Completes an activity execution with failure using a task token. + * + *

This overload works with both workflow activities and standalone activities. * * @param taskToken token of the activity attempt to complete * @param result the exception to be used as a failure details object @@ -63,9 +66,10 @@ void completeStandalone(String activityId, Optional activityRunId, R void completeExceptionally(byte[] taskToken, Exception result) throws ActivityCompletionException; /** - * Completes the activity execution with failure. + * Completes a workflow activity execution with failure using workflow and activity IDs. * - *

This method is only for activities run within a workflow. + *

This overload is only for workflow activities. To complete a standalone activity by ID, use + * {@link #completeExceptionallyStandalone(String, Optional, Exception)}. * * @param workflowId id of the workflow that started the activity * @param runId optional run id of the workflow that started the activity @@ -77,24 +81,23 @@ void completeExceptionally( throws ActivityCompletionException; /** - * Completes a standalone activity execution with failure. + * Completes a standalone activity execution with failure using activity ID. * - *

Use this overload for standalone activities (not started from a workflow). For - * workflow-scheduled activities, use {@link #completeExceptionally(String, Optional, String, - * Exception)}. To complete a standalone activity with failure using a task token, use {@link - * #completeExceptionally(byte[], Exception)}. + *

This method is only for standalone activities. To complete a workflow activity by ID, use + * {@link #completeExceptionally(String, Optional, String, Exception)}. * * @param activityId id of the standalone activity * @param activityRunId optional run id of the standalone activity, or {@code Optional.empty()} * @param result the exception to be used as a failure details object */ - @Experimental void completeExceptionallyStandalone( String activityId, Optional activityRunId, Exception result) throws ActivityCompletionException; /** - * Confirms successful cancellation to the server. + * Confirms successful cancellation to the server using a task token. + * + *

This overload works with both workflow activities and standalone activities. * * @param taskToken token of the activity attempt * @param details details to record with the cancellation @@ -102,9 +105,11 @@ void completeExceptionallyStandalone( void reportCancellation(byte[] taskToken, V details) throws ActivityCompletionException; /** - * Confirms successful cancellation to the server. + * Confirms successful cancellation of a workflow activity to the server using workflow and + * activity IDs. * - *

This method is only for activities run within a workflow. + *

This overload is only for workflow activities. To cancel a standalone activity by ID, use + * {@link #reportCancellationStandalone(String, Optional, Object)}. * * @param workflowId id of the workflow that started the activity * @param runId optional run id of the workflow that started the activity @@ -116,24 +121,23 @@ void reportCancellation( throws ActivityCompletionException; /** - * Confirms successful cancellation of a standalone activity to the server. + * Confirms successful cancellation of a standalone activity to the server using activity ID. * - *

Use this overload for standalone activities (not started from a workflow). For - * workflow-scheduled activities, use {@link #reportCancellation(String, Optional, String, - * Object)}. To confirm a successful cancellation of a standalone activity using a task token, use - * {@link #reportCancellation(byte[], Object)}. + *

This method is only for standalone activities. To cancel a workflow activity by ID, use + * {@link #reportCancellation(String, Optional, String, Object)}. * * @param activityId id of the standalone activity * @param activityRunId optional run id of the standalone activity, or {@code Optional.empty()} * @param details details to record with the cancellation */ - @Experimental void reportCancellationStandalone( String activityId, Optional activityRunId, V details) throws ActivityCompletionException; /** - * Records a heartbeat for an activity. + * Records a heartbeat for an activity using a task token. + * + *

This overload works with both workflow activities and standalone activities. * * @param taskToken token of the activity attempt * @param details details to record with the heartbeat @@ -142,9 +146,10 @@ void reportCancellationStandalone( void heartbeat(byte[] taskToken, V details) throws ActivityCompletionException; /** - * Records a heartbeat for an activity. + * Records a heartbeat for a workflow activity using workflow and activity IDs. * - *

This method is only for activities run within a workflow. + *

This overload is only for workflow activities. To heartbeat a standalone activity by ID, use + * {@link #heartbeatStandalone(String, Optional, Object)}. * * @param workflowId id of the workflow that started the activity * @param runId optional run id of the workflow that started the activity @@ -156,19 +161,16 @@ void heartbeat(String workflowId, Optional runId, String activityId, throws ActivityCompletionException; /** - * Records a heartbeat for a standalone activity. + * Records a heartbeat for a standalone activity using activity ID. * - *

Use this overload for standalone activities (not started from a workflow). For - * workflow-scheduled activities, use {@link #heartbeat(String, Optional, String, Object)}. To - * record a heartbeeat for a standalone activity using a task token, use {@link #heartbeat(byte[], - * Object)}. + *

This method is only for standalone activities. To heartbeat a workflow activity by ID, use + * {@link #heartbeat(String, Optional, String, Object)}. * * @param activityId id of the standalone activity * @param activityRunId optional run id of the standalone activity, or {@code Optional.empty()} * @param details details to record with the heartbeat * @throws ActivityCompletionException if activity should stop executing */ - @Experimental void heartbeatStandalone(String activityId, Optional activityRunId, V details) throws ActivityCompletionException; diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityException.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityException.java index d472d7b9d0..8744459114 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityException.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityException.java @@ -1,11 +1,9 @@ package io.temporal.client; -import io.temporal.common.Experimental; import io.temporal.failure.TemporalException; import javax.annotation.Nullable; /** Base exception for standalone activity execution failures. */ -@Experimental public abstract class ActivityException extends TemporalException { private final String activityId; diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionCount.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionCount.java index 0af735bb28..4a724463b3 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionCount.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionCount.java @@ -1,7 +1,6 @@ package io.temporal.client; import io.temporal.api.workflowservice.v1.CountActivityExecutionsResponse; -import io.temporal.common.Experimental; import io.temporal.internal.common.SearchAttributesUtil; import java.util.List; import java.util.Objects; @@ -9,11 +8,9 @@ import javax.annotation.Nonnull; /** Result of counting standalone activity executions. */ -@Experimental public class ActivityExecutionCount { /** An individual aggregation group. */ - @Experimental public static class AggregationGroup { private final List> groupValues; private final long count; diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java index fa4d4f77cc..ee67f0c0c4 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -4,7 +4,6 @@ import io.temporal.api.enums.v1.ActivityExecutionStatus; import io.temporal.api.enums.v1.PendingActivityState; import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; -import io.temporal.common.Experimental; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; import io.temporal.common.WorkerDeploymentVersion; @@ -26,7 +25,6 @@ * Detailed information about a standalone activity execution, returned by {@link * ActivityHandle#describe()}. */ -@Experimental public final class ActivityExecutionDescription extends ActivityExecutionMetadata { private final DescribeActivityExecutionResponse response; diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java index b741fdc431..11f570f402 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java @@ -2,7 +2,6 @@ import io.temporal.api.activity.v1.ActivityExecutionListInfo; import io.temporal.api.enums.v1.ActivityExecutionStatus; -import io.temporal.common.Experimental; import io.temporal.common.SearchAttributes; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.SearchAttributesUtil; @@ -16,7 +15,6 @@ * Information about a standalone activity execution returned by {@link * ActivityClient#listExecutions}. */ -@Experimental public class ActivityExecutionMetadata { private final @Nullable ActivityExecutionListInfo rawListInfo; diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityFailedException.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityFailedException.java index 76e7c3d1ab..5bf0f15a3f 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityFailedException.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityFailedException.java @@ -1,13 +1,11 @@ package io.temporal.client; -import io.temporal.common.Experimental; import javax.annotation.Nullable; /** * Thrown by {@link ActivityHandle#getResult()} when the standalone activity was not successful. The * original cause can be retrieved via {@link #getCause()}. */ -@Experimental public final class ActivityFailedException extends ActivityException { public ActivityFailedException( diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java index b6f97febec..f23c898429 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandle.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import java.lang.reflect.Type; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -18,7 +17,6 @@ * @see UntypedActivityHandle * @see ActivityClient */ -@Experimental public interface ActivityHandle extends UntypedActivityHandle { /** diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityPausedException.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityPausedException.java index c3c3839d1f..a8b2b72ba4 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityPausedException.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityPausedException.java @@ -1,14 +1,12 @@ package io.temporal.client; import io.temporal.activity.ActivityInfo; -import io.temporal.common.Experimental; /*** * Indicates that the activity was paused by the user. * *

Catching this exception directly is discouraged and catching the parent class {@link ActivityCompletionException} is recommended instead.
*/ -@Experimental public final class ActivityPausedException extends ActivityCompletionException { public ActivityPausedException(ActivityInfo info) { super(info); diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityResetException.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityResetException.java index c2c51037ca..21c7f70e4c 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityResetException.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityResetException.java @@ -1,14 +1,12 @@ package io.temporal.client; import io.temporal.activity.ActivityInfo; -import io.temporal.common.Experimental; /*** * Indicates that the activity attempt was reset by the user. * *

Catching this exception directly is discouraged and catching the parent class {@link ActivityCompletionException} is recommended instead.
*/ -@Experimental public final class ActivityResetException extends ActivityCompletionException { public ActivityResetException(ActivityInfo info) { super(info); diff --git a/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java index 13e3093361..6fb4d4c5d2 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import java.util.Objects; /** @@ -10,7 +9,6 @@ * arbitrarily large, so none are returned unless explicitly requested. An instance with no fields * set describes the activity without any of them. */ -@Experimental public final class DescribeActivityOptions { public static Builder newBuilder() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java index e90c856236..65e54a1edf 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import java.util.Objects; import javax.annotation.Nullable; @@ -10,7 +9,6 @@ *

All fields are optional. An instance with no fields set pauses the activity with default * behavior. */ -@Experimental public final class PauseActivityOptions { public static Builder newBuilder() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java index 6b416a6625..476062ca65 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/StartActivityOptions.java @@ -4,7 +4,6 @@ import com.google.common.base.Strings; import io.temporal.api.enums.v1.ActivityIdConflictPolicy; import io.temporal.api.enums.v1.ActivityIdReusePolicy; -import io.temporal.common.Experimental; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; import io.temporal.common.SearchAttributes; @@ -18,7 +17,6 @@ *

At least one of {@link #getScheduleToCloseTimeout()} or {@link #getStartToCloseTimeout()} must * be set. */ -@Experimental public final class StartActivityOptions { public static Builder newBuilder() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java index c60da6a698..b9397597ca 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java @@ -1,6 +1,5 @@ package io.temporal.client; -import io.temporal.common.Experimental; import java.time.Duration; import java.util.Objects; import javax.annotation.Nullable; @@ -11,7 +10,6 @@ *

All fields are optional. An instance with no fields set unpauses the activity with default * behavior. */ -@Experimental public final class UnpauseActivityOptions { public static Builder newBuilder() { diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java index 06cc2c689b..86c42b149d 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -17,7 +17,6 @@ * @see ActivityHandle * @see ActivityClient */ -@Experimental public interface UntypedActivityHandle { /** The user-assigned activity ID. */ @@ -160,6 +159,7 @@ CompletableFuture getResultAsync( /** * Pauses the activity. A paused activity stops being dispatched to workers until it is unpaused. */ + @Experimental void pause(); /** @@ -167,9 +167,11 @@ CompletableFuture getResultAsync( * * @param options pause options (reason) */ + @Experimental void pause(PauseActivityOptions options); /** Unpauses the activity with default options, allowing it to be dispatched again. */ + @Experimental void unpause(); /** @@ -177,6 +179,7 @@ CompletableFuture getResultAsync( * * @param options unpause options (reason, jitter) */ + @Experimental void unpause(UnpauseActivityOptions options); /** @@ -196,6 +199,7 @@ CompletableFuture getResultAsync( * @return the activity options as resolved by the server after the update * @throws IllegalArgumentException if {@code updates} is empty or names an option twice */ + @Experimental ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates); /** @@ -203,5 +207,6 @@ CompletableFuture getResultAsync( * * @return the activity options as resolved by the server after the restore */ + @Experimental ActivityExecutionOptions restoreOriginalOptions(); } diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java index 0d51c9a09d..e2e5174c55 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptor.java @@ -92,6 +92,7 @@ GetActivityResultOutput getActivityResult(GetActivityResultInput input * @param input activity ID, optional run ID, and optional human-readable reason * @return an empty output object (reserved for future use) */ + @Experimental PauseActivityOutput pauseActivity(PauseActivityInput input); /** @@ -100,6 +101,7 @@ GetActivityResultOutput getActivityResult(GetActivityResultInput input * @param input activity ID, optional run ID, and unpause options (reason, jitter) * @return an empty output object (reserved for future use) */ + @Experimental UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input); /** @@ -110,6 +112,7 @@ GetActivityResultOutput getActivityResult(GetActivityResultInput input * @param input activity ID, optional run ID, options, update mask, and restore flag * @return output carrying the activity options as resolved by the server after the update */ + @Experimental UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input); /** @@ -148,7 +151,6 @@ GetActivityResultOutput getActivityResult(GetActivityResultInput input CompletableFuture> getActivityResultAsync( GetActivityResultInput input); - @Experimental final class StartActivityInput { private final String activityType; private final List args; @@ -180,7 +182,6 @@ public Header getHeader() { } } - @Experimental final class StartActivityOutput { private final String activityId; private final @Nullable String activityRunId; @@ -200,7 +201,6 @@ public String getActivityRunId() { } } - @Experimental final class GetActivityResultInput { private final String activityId; private final @Nullable String runId; @@ -265,7 +265,6 @@ public TimeUnit getTimeoutUnit() { } } - @Experimental final class GetActivityResultOutput { private final R result; @@ -278,7 +277,6 @@ public R getResult() { } } - @Experimental final class DescribeActivityInput { private final String id; private final @Nullable String runId; @@ -305,7 +303,6 @@ public DescribeActivityOptions getOptions() { } } - @Experimental final class DescribeActivityOutput { private final ActivityExecutionDescription description; @@ -318,7 +315,6 @@ public ActivityExecutionDescription getDescription() { } } - @Experimental final class CancelActivityInput { private final String id; private final @Nullable String runId; @@ -345,10 +341,8 @@ public String getReason() { } } - @Experimental final class CancelActivityOutput {} - @Experimental final class TerminateActivityInput { private final String id; private final @Nullable String runId; @@ -375,7 +369,6 @@ public String getReason() { } } - @Experimental final class TerminateActivityOutput {} @Experimental @@ -490,7 +483,6 @@ public ActivityExecutionOptions getOptions() { } } - @Experimental final class ListActivitiesInput { private final String query; @@ -503,7 +495,6 @@ public String getQuery() { } } - @Experimental final class ListActivitiesOutput { private final Stream stream; @@ -516,7 +507,6 @@ public Stream getStream() { } } - @Experimental final class CountActivitiesInput { private final String query; @@ -529,7 +519,6 @@ public String getQuery() { } } - @Experimental final class CountActivitiesOutput { private final ActivityExecutionCount count; diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java index e1604fb87a..82c8809eab 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientCallsInterceptorBase.java @@ -1,11 +1,9 @@ package io.temporal.common.interceptors; -import io.temporal.common.Experimental; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeoutException; /** Convenience base class for {@link ActivityClientCallsInterceptor} implementations. */ -@Experimental public class ActivityClientCallsInterceptorBase implements ActivityClientCallsInterceptor { private final ActivityClientCallsInterceptor next; diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientInterceptorBase.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientInterceptorBase.java index 28c9c9bbf7..735bbfaa00 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientInterceptorBase.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/ActivityClientInterceptorBase.java @@ -1,13 +1,10 @@ package io.temporal.common.interceptors; -import io.temporal.common.Experimental; - /** * Convenience no-op base class for {@link ActivityClientInterceptor} implementations. Override * {@link #activityClientCallsInterceptor} to install a custom {@link * ActivityClientCallsInterceptor} into the chain. */ -@Experimental public class ActivityClientInterceptorBase implements ActivityClientInterceptor { @Override 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 b83ce177a2..018d29de8b 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 @@ -99,27 +99,20 @@ ExecuteMultiOperationResponse executeMultiOperation( // ---- Standalone Activity RPCs ---- - @Experimental StartActivityExecutionResponse startActivity(StartActivityExecutionRequest request); - @Experimental PollActivityExecutionResponse pollActivity(PollActivityExecutionRequest request); - @Experimental PollActivityExecutionResponse pollActivity( PollActivityExecutionRequest request, @Nonnull Deadline deadline); - @Experimental CompletableFuture pollActivityAsync( PollActivityExecutionRequest request, @Nonnull Deadline deadline); - @Experimental DescribeActivityExecutionResponse describeActivity(DescribeActivityExecutionRequest request); - @Experimental void cancelActivity(RequestCancelActivityExecutionRequest request); - @Experimental void terminateActivity(TerminateActivityExecutionRequest request); @Experimental @@ -132,14 +125,11 @@ CompletableFuture pollActivityAsync( UpdateActivityExecutionOptionsResponse updateActivityOptions( UpdateActivityExecutionOptionsRequest request); - @Experimental ListActivityExecutionsResponse listActivities(ListActivityExecutionsRequest request); - @Experimental CompletableFuture listActivitiesAsync( ListActivityExecutionsRequest request); - @Experimental CountActivityExecutionsResponse countActivities(CountActivityExecutionsRequest request); @Experimental From 43fa0fa164d3186a0af5976dea113d084775a98f Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Mon, 14 Sep 2026 17:34:34 -0400 Subject: [PATCH 098/107] Release Java SDK v1.39.0 (#3074) --- releases/v1.39.0 | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 releases/v1.39.0 diff --git a/releases/v1.39.0 b/releases/v1.39.0 new file mode 100644 index 0000000000..518dacefd7 --- /dev/null +++ b/releases/v1.39.0 @@ -0,0 +1,65 @@ +# **Breaking changes** + +- `client.StartActivityOptions.Builder.setStaticSummary` was renamed to `setSummary` to align it with + `activity.ActivityOptions`. + +# **Highlights** + +## Standalone Activities are stable + +Standalone Activities feature is Generally Available. See feature guide at +https://docs.temporal.io/develop/java/activities/standalone-activities + +## Static Summary and Details are stable + +`setSummary`/`setStaticSummary` and `setStaticDetails` for workflows and activities are stable. See feature guide at +https://docs.temporal.io/develop/java/platform/enriching-ui + +## External Storage (Public Preview) + +This version adds experimental support for External Storage feature, allowing storing large payloads out-of-bounds, +effectively lifting usual payload size restrictions. Includes a storage driver for AWS S3. See feature guide at +https://docs.temporal.io/external-storage + +# What's Changed + +2026-08-12 - cd2b543e - Upgrade temporal-api to v1.63.5 (#3003) +2026-08-12 - f9732502 - Add @TemporalOperation annotation for Nexus operations (#2928) +2026-08-13 - 28619b84 - Add test verifying worker command polls omit versioning metadata (#3004) +2026-08-13 - 7cf35351 - 💥 Report Nexus input deserialization failures as non-retryable BAD_REQUEST (#3002) +2026-08-13 - 87a3c568 - Fix exception ser. in update validator (#3001) +2026-08-13 - ade44bc4 - 💥 Fix unbounded timeout failure chain in local activity (#3006) +2026-08-17 - af2f8537 - Report non-retryable PayloadValidationError as BAD_REQUEST (#3009) +2026-08-20 - a0021f84 - Add payload validation failure factory (#3021) +2026-08-21 - ee9abf08 - Omit null payload validation details (#3027) +2026-08-24 - 12c21142 - Add SDK Sentinel PR responder (#3034) +2026-08-25 - 1b2ffb18 - [SDK Sentinel] Allow activity retry delay test to complete (#3010) +2026-08-26 - 37627549 - Default Nexus client activity task queue to the worker task queue (#3039) +2026-08-26 - 809e57cf - Add failure_reason tag to activity execution failed metrics (#3036) +2026-08-26 - 867ecf07 - Remove Experimental annotation for user metadata from a few APIs (#3038) +2026-08-26 - c5f93b78 - Fix inconsistency in javadoc for WorkflowInterface (#2232) (#2233) +2026-08-26 - e10c8e62 - Fix NPE when a workflow implementation uses a composed @WorkflowImpl annotation (#3024) +2026-08-27 - 144ddea1 - Stabilize sticky cache metrics test (#3037) +2026-08-27 - 394d8eec - Fix setRetryOptions merging initialInterval into congestionInitialInterval (#2956) +2026-08-27 - 6d5ba591 - feat(#2790): Add ChildWorkflowOptions support to WorkflowImplementationOptions (#2887) +2026-08-27 - b7dd8c8c - Include WorkflowType in wrapFailure error message (#2960) +2026-08-27 - d730cff5 - Add GCP Cloud Run OpenTelemetry support (#3022) +2026-08-28 - 6c60097d - External Storage Integration: Lazy resolving references, general refactoring (#3016) +2026-08-31 - 4f5a1079 - Fix custom slot supplier SlotInfo fields (#3014) +2026-09-02 - 496ddc0e - Add envconfig support to test harness (#2998) +2026-09-03 - 8d6c936e - Provision isolated Cloud namespaces for SDK tests (#3032) +2026-09-03 - dc0ad21c - Add workflow task completion pagination (#3051) +2026-09-04 - 015fdc12 - Pass a dedicated context to the driver selector (#3047) +2026-09-04 - 08171629 - External Storage Integration: NexusWorker (#3018) +2026-09-04 - 108462ea - Bumping test CLI version (#3054) +2026-09-04 - 479ea76c - Fix WorkerFactory command-worker cleanup race (#2952) +2026-09-04 - b319d85a - Adding SDK Ergonomics Query link (#2988) +2026-09-08 - c73480ff - Report runtime environment information in worker heartbeats (#3052) +2026-09-09 - a49045eb - Implement operator commands for Standalone Activities (#3013) +2026-09-10 - 77b2db51 - External Storage Integration: Activity worker, client (#3020) +2026-09-10 - 895a65a2 - External Storage Integration: WorkflowWorker, replay, history (#3017) +2026-09-11 - afdfac27 - fix broken test that referenced InMemoryDriver (#3065) +2026-09-11 - dd28759b - Reject duplicates in SAA update options (#3061) +2026-09-14 - 0eade162 - Add extstore s3 driver (#2907) +2026-09-14 - c7f47b99 - Rename Standalone Activity StaticSummary to Summary (#3062) +2026-09-14 - f6968a4e - Removed @Experimental annotations from Standalone Activities API (#3071) From a16ffa9ea731fee091091a26edccb30f58eb1a42 Mon Sep 17 00:00:00 2001 From: Gokhan Tekkaya <86028633+tekkaya@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:03:26 -0700 Subject: [PATCH 099/107] Propagate Nexus links and request IDs to all activity starts in Nexus handlers (#3048) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What was changed `RootActivityClientInvoker.startActivity` now propagates the inbound Nexus task’s links and request ID to every activity start made on the operation-handler thread, including starts made through a raw `ActivityClient`. `NexusOperationMetadata` remains narrowly scoped to the guarded backing start: it is the only mechanism that can attach a completion callback, because only that start is allowed to complete the Nexus operation. When present, its request ID takes precedence over the ambient inbound request ID. ## Why? `TemporalNexusClient` permits only one guarded primitive activity start per operation invocation. A synchronous handler that starts another activity must use a raw `ActivityClient`; previously, those additional activity starts received neither inbound links nor redelivery-safe request-ID reuse. Sharing the inbound request ID across all starts is deliberate. A handler that reuses an activity ID within one invocation can resolve a later start to the earlier run; deriving per-start IDs would require assuming the handler repeats starts in the same order after redelivery. Restricting request-ID reuse to only the guarded start preserves the previous behavior, but does not address additional starts made through a raw `ActivityClient`. This approach aligns with the approved [sdk-go fix #2633](https://github.com/temporalio/sdk-go/pull/2633). ## Checklist 1. Closes the multiple-activity-start gap identified during Java SDK testing of Nexus + Standalone Activities. 2. Tested with: - `RootActivityClientInvokerTest` (unit) - `ActivityOperationLinkingTest` (functional; requires a real server and is gated by `SDKTestWorkflowRule.useExternalService`) 3. Documentation updates needed: No. --- .../client/RootActivityClientInvoker.java | 38 +++- .../nexus/InternalNexusOperationContext.java | 16 ++ .../internal/nexus/NexusTaskHandlerImpl.java | 3 + .../client/RootActivityClientInvokerTest.java | 81 +++++++- .../nexus/ActivityOperationLinkingTest.java | 191 ++++++++++++++++++ 5 files changed, 316 insertions(+), 13 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityOperationLinkingTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index 3bf753a832..fc85f0039f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -67,14 +67,16 @@ public StartActivityOutput startActivity(StartActivityInput input) { NexusOperationMetadata nexusOperationMetadata = nexusContext == null ? null : nexusContext.getNexusOperationMetadata(); + String requestId = + nexusContext != null && !Strings.isNullOrEmpty(nexusContext.getRequestId()) + ? nexusContext.getRequestId() + : UUID.randomUUID().toString(); + StartActivityExecutionRequest.Builder request = StartActivityExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) .setIdentity(clientOptions.getIdentity()) - .setRequestId( - nexusOperationMetadata == null - ? UUID.randomUUID().toString() - : nexusOperationMetadata.requestId) + .setRequestId(requestId) .setActivityId(options.getId()) .setActivityType(ActivityType.newBuilder().setName(input.getActivityType()).build()) .setTaskQueue(TaskQueue.newBuilder().setName(options.getTaskQueue()).build()) @@ -124,14 +126,30 @@ public StartActivityOutput startActivity(StartActivityInput input) { io.temporal.api.common.v1.Header grpcHeader = HeaderUtils.toHeaderGrpc(input.getHeader(), null); request.setHeader(grpcHeader); - if (nexusOperationMetadata != null) { - List protoLinks = nexusContext.getRequestLinks(); + List protoLinks = Collections.emptyList(); + if (nexusContext != null) { + // Propagate the inbound Nexus request ID and links to every activity start on the + // operation-handler thread, including starts through a raw ActivityClient. + // Completion callbacks remain limited to the metadata-backed start because only it + // completes the Nexus operation. + protoLinks = nexusContext.getRequestLinks(); request.addAllLinks(protoLinks); + } + + boolean willAttachCompletionCallback = + nexusOperationMetadata != null + && !Strings.isNullOrEmpty(nexusOperationMetadata.callbackUrl); + if (!protoLinks.isEmpty() || willAttachCompletionCallback) { + // The server rejects attach_request_id unless the request also carries at least one link + // or completion callback to attach on conflict. request.setOnConflictOptions( io.temporal.api.common.v1.OnConflictOptions.newBuilder() .setAttachRequestId(true) - .setAttachLinks(true) - .setAttachCompletionCallbacks(true)); + .setAttachLinks(!protoLinks.isEmpty()) + .setAttachCompletionCallbacks(willAttachCompletionCallback)); + } + + if (nexusOperationMetadata != null) { // Generate the operation token from the user-supplied activity ID and namespace so the // dual OPERATION_ID + OPERATION_TOKEN headers can be injected before the start RPC fires. try { @@ -144,7 +162,7 @@ public StartActivityOutput startActivity(StartActivityInput input) { "failed to generate activity operation token", e); } - if (!Strings.isNullOrEmpty(nexusOperationMetadata.callbackUrl)) { + if (willAttachCompletionCallback) { Callback cb = InternalUtils.buildNexusCallback( nexusOperationMetadata.callbackUrl, @@ -171,7 +189,7 @@ public StartActivityOutput startActivity(StartActivityInput input) { throw e; } - if (nexusOperationMetadata != null && response.hasLink()) { + if (nexusContext != null && response.hasLink()) { nexusContext.addResponseLink(response.getLink()); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java index 97ab4f5ed2..3c5a6b0af8 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java @@ -27,6 +27,12 @@ public class InternalNexusOperationContext { // workflow client can attach them to the outgoing requests it issues (e.g. signal, // signalWithStart) via the request's links field. private List requestLinks = Collections.emptyList(); + // The inbound Nexus task's request ID, captured at the task-handler boundary and available to + // clients executing on the operation-handler thread. RootActivityClientInvoker reuses it for + // redelivery-safe activity-start deduplication. It is deliberately independent of + // nexusOperationMetadata, which is scoped to the single backing start because it carries + // completion-callback semantics. + private String requestId; // Links returned by outbound RPCs the operation handler issues (such as // SignalWorkflowExecutionResponse.link or SignalWithStartWorkflowExecutionResponse.signal_link). // One entry per outbound RPC that returned a link. Drained @@ -106,6 +112,16 @@ public void setRequestLinks(List links) { return Collections.unmodifiableList(requestLinks); } + /** Set the request ID of the inbound Nexus task, ambient for the whole invocation. */ + public void setRequestId(String requestId) { + this.requestId = requestId; + } + + /** The inbound Nexus task's request ID; {@code null} if not set. */ + public String getRequestId() { + return requestId; + } + public void setStartWorkflowResponseLink(Link link) { this.startWorkflowResponseLink = link; } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index 4d40183c27..5ef945ec79 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -313,6 +313,9 @@ private StartOperationResponse handleStartOperation( } }); CurrentNexusOperationContext.get().setRequestLinks(inboundCommonLinks); + // Ambient for the whole operation-handler invocation, independent of NexusOperationMetadata. + // see InternalNexusOperationContext.requestId. + CurrentNexusOperationContext.get().setRequestId(task.getRequestId()); HandlerInputContent.Builder input = HandlerInputContent.newBuilder().setDataStream(task.getPayload().toByteString().newInput()); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java index 16ba8b7f36..577286df4f 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootActivityClientInvokerTest.java @@ -2,6 +2,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -22,6 +23,7 @@ import java.time.Duration; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import org.junit.After; import org.junit.Assert; @@ -77,6 +79,7 @@ public void nexusMetadataAddsCallbackLinksAndRequestId() { new NexusOperationMetadata( "nexus-request-id", "http://localhost/callback", callbackHeaders); nexusContext.setNexusOperationMetadata(metadata); + nexusContext.setRequestId("nexus-request-id"); Link link = workflowEventLink(); nexusContext.setRequestLinks(Collections.singletonList(link)); @@ -112,6 +115,7 @@ public void nexusMetadataWithEmptyCallbackUrlOmitsCompletionCallback() { new NexusOperationMetadata( "nexus-request-id", "", Collections.singletonMap("Custom-Header", "value")); nexusContext.setNexusOperationMetadata(metadata); + nexusContext.setRequestId("nexus-request-id"); Link link = workflowEventLink(); nexusContext.setRequestLinks(Collections.singletonList(link)); @@ -126,14 +130,85 @@ public void nexusMetadataWithEmptyCallbackUrlOmitsCompletionCallback() { Assert.assertEquals(0, request.getCompletionCallbacksCount()); Assert.assertTrue(request.getOnConflictOptions().getAttachRequestId()); Assert.assertTrue(request.getOnConflictOptions().getAttachLinks()); - Assert.assertTrue(request.getOnConflictOptions().getAttachCompletionCallbacks()); + Assert.assertFalse(request.getOnConflictOptions().getAttachCompletionCallbacks()); Assert.assertNotNull(metadata.operationToken); Assert.assertEquals(Collections.singletonList(activityLink()), nexusContext.getResponseLinks()); } @Test - public void nexusContextWithoutMetadataStartsOrdinaryActivity() { - nexusContext.setRequestLinks(Collections.singletonList(workflowEventLink())); + public void metadataWithEmptyCallbackUrlAndNoLinksOmitsOnConflictOptions() { + NexusOperationMetadata metadata = + new NexusOperationMetadata( + "nexus-request-id", "", Collections.singletonMap("Custom-Header", "value")); + nexusContext.setNexusOperationMetadata(metadata); + nexusContext.setRequestId("nexus-request-id"); + + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient).startActivity(captor.capture()); + StartActivityExecutionRequest request = captor.getValue(); + Assert.assertEquals("nexus-request-id", request.getRequestId()); + Assert.assertEquals(0, request.getLinksCount()); + Assert.assertEquals(0, request.getCompletionCallbacksCount()); + Assert.assertFalse(request.hasOnConflictOptions()); + } + + @Test + public void nexusContextWithoutMetadataGetsAmbientLinksAndAmbientRequestIdButNoCallback() { + Link link = workflowEventLink(); + nexusContext.setRequestLinks(Collections.singletonList(link)); + nexusContext.setRequestId("ambient-nexus-request-id"); + + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient).startActivity(captor.capture()); + StartActivityExecutionRequest request = captor.getValue(); + Assert.assertEquals("ambient-nexus-request-id", request.getRequestId()); + Assert.assertEquals(Collections.singletonList(link), request.getLinksList()); + Assert.assertEquals(0, request.getCompletionCallbacksCount()); + Assert.assertTrue(request.getOnConflictOptions().getAttachRequestId()); + Assert.assertTrue(request.getOnConflictOptions().getAttachLinks()); + Assert.assertFalse(request.getOnConflictOptions().getAttachCompletionCallbacks()); + Assert.assertEquals(Collections.singletonList(activityLink()), nexusContext.getResponseLinks()); + } + + @Test + public void twoStartsInTheSameInvocationShareTheAmbientRequestId() { + nexusContext.setRequestId("ambient-nexus-request-id"); + + invoker.startActivity(newStartActivityInput()); + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient, times(2)).startActivity(captor.capture()); + List requests = captor.getAllValues(); + Assert.assertEquals("ambient-nexus-request-id", requests.get(0).getRequestId()); + Assert.assertEquals("ambient-nexus-request-id", requests.get(1).getRequestId()); + } + + @Test + public void nexusContextWithoutAmbientStateStartsOrdinaryActivity() { + invoker.startActivity(newStartActivityInput()); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(StartActivityExecutionRequest.class); + verify(genericClient).startActivity(captor.capture()); + StartActivityExecutionRequest request = captor.getValue(); + Assert.assertFalse(request.getRequestId().isEmpty()); + Assert.assertEquals(0, request.getLinksCount()); + Assert.assertEquals(0, request.getCompletionCallbacksCount()); + Assert.assertFalse(request.hasOnConflictOptions()); + Assert.assertEquals(Collections.singletonList(activityLink()), nexusContext.getResponseLinks()); + } + + @Test + public void outsideNexusContextStartsOrdinaryActivity() { + CurrentNexusOperationContext.unset(); invoker.startActivity(newStartActivityInput()); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityOperationLinkingTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityOperationLinkingTest.java new file mode 100644 index 0000000000..aa4edba0a5 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/ActivityOperationLinkingTest.java @@ -0,0 +1,191 @@ +package io.temporal.workflow.nexus; + +import static io.temporal.internal.common.WorkflowExecutionUtils.getEventOfType; +import static org.junit.Assume.assumeTrue; + +import io.nexusrpc.handler.OperationHandler; +import io.nexusrpc.handler.OperationImpl; +import io.nexusrpc.handler.ServiceImpl; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.common.v1.Link; +import io.temporal.api.enums.v1.EventType; +import io.temporal.api.history.v1.History; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityExecutionDescription; +import io.temporal.client.ActivityHandle; +import io.temporal.client.StartActivityOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.nexus.Nexus; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.shared.TestNexusServices; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies link propagation with activities when a synchronous Nexus operation handler starts more + * than one activity via a raw {@link ActivityClient} obtained from {@link + * Nexus#getOperationContext()}. + * + *
    + *
  • Forward direction: each activity's own record links back to the caller's {@code + * NexusOperationScheduled} event. + *
  • Backward direction: both activities' completions land as response links on the caller's + * single {@code NexusOperationCompleted} event. + *
+ * + *

Requires a real server; the in-process test server does not implement {@code + * StartActivityExecution} (see {@link AsyncActivityOperationTest}, which has the same gate). + */ +public class ActivityOperationLinkingTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestNexus.class) + .setActivityImplementations(new TestActivityImpl()) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .build(); + + @BeforeClass + public static void requireExternalService() { + assumeTrue( + "standalone-activity Nexus links require a real server", + SDKTestWorkflowRule.useExternalService); + } + + @Test + public void testTwoActivitiesBothLinkToOperation() { + String input = "world-" + UUID.randomUUID(); + TestWorkflows.TestWorkflow1 workflowStub = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflows.TestWorkflow1.class); + String result = workflowStub.execute(input); + Assert.assertEquals("hello " + input + "-a|hello " + input + "-b", result); + + String callerWorkflowId = WorkflowStub.fromTyped(workflowStub).getExecution().getWorkflowId(); + History callerHistory = + testWorkflowRule.getWorkflowClient().fetchHistory(callerWorkflowId).getHistory(); + + // Backward direction: both activities' completions must land on the caller's single + // NexusOperationCompleted event as response links, not just the guarded/first one. + HistoryEvent completed = + getEventOfType(callerHistory, EventType.EVENT_TYPE_NEXUS_OPERATION_COMPLETED); + Assert.assertNotNull("expected a NexusOperationCompleted event", completed); + Assert.assertEquals("expected one response link per activity", 2, completed.getLinksCount()); + Set linkedActivityIds = new HashSet<>(); + for (int i = 0; i < completed.getLinksCount(); i++) { + Link.Activity activityLink = completed.getLinks(i).getActivity(); + Assert.assertNotNull("expected an Activity-typed response link", activityLink); + linkedActivityIds.add(activityLink.getActivityId()); + } + Assert.assertTrue(linkedActivityIds.contains("act-" + input + "-a")); + Assert.assertTrue(linkedActivityIds.contains("act-" + input + "-b")); + + // Forward direction: each activity's own record links back to the caller's + // NexusOperationScheduled event, not just the guarded/first one. + ActivityClient activityClient = + ActivityClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder().setNamespace(SDKTestWorkflowRule.NAMESPACE).build()); + for (String suffix : new String[] {"a", "b"}) { + String activityId = "act-" + input + "-" + suffix; + ActivityExecutionDescription description = + activityClient.getHandle(activityId, null).describe(); + Assert.assertTrue( + "expected at least one link on activity " + activityId, + description.getRawInfo().getLinksCount() >= 1); + Link.WorkflowEvent forwardLink = description.getRawInfo().getLinks(0).getWorkflowEvent(); + Assert.assertNotNull( + "expected a WorkflowEvent-typed forward link on activity " + activityId, forwardLink); + Assert.assertEquals(callerWorkflowId, forwardLink.getWorkflowId()); + Assert.assertEquals( + EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED, forwardLink.getEventRef().getEventType()); + } + } + + public static class TestNexus implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + NexusServiceOptions serviceOptions = + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build()) + .build(); + TestNexusServices.TestNexusService1 stub = + Workflow.newNexusServiceStub(TestNexusServices.TestNexusService1.class, serviceOptions); + return stub.operation(input); + } + } + + @ActivityInterface + public interface TestActivity { + @ActivityMethod + String process(String input); + } + + public static class TestActivityImpl implements TestActivity { + @Override + public String process(String input) { + return "hello " + input; + } + } + + /** + * Starts two activities inline via a raw {@link ActivityClient} obtained from {@link + * Nexus#getOperationContext()} instead of {@code TemporalOperationHandler}'s single-guarded-call + * {@code TemporalNexusClient} -- the only way to start more than one activity synchronously in + * one Nexus operation invocation. + */ + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, input) -> { + ActivityClient activityClient = + ActivityClient.newInstance( + Nexus.getOperationContext().getWorkflowClient().getWorkflowServiceStubs(), + ActivityClientOptions.newBuilder() + .setNamespace(Nexus.getOperationContext().getInfo().getNamespace()) + .build()); + String taskQueue = Nexus.getOperationContext().getInfo().getTaskQueue(); + + ActivityHandle first = + activityClient.start( + TestActivity.class, + TestActivity::process, + StartActivityOptions.newBuilder() + .setId("act-" + input + "-a") + .setTaskQueue(taskQueue) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(), + input + "-a"); + ActivityHandle second = + activityClient.start( + TestActivity.class, + TestActivity::process, + StartActivityOptions.newBuilder() + .setId("act-" + input + "-b") + .setTaskQueue(taskQueue) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(), + input + "-b"); + return first.getResult() + "|" + second.getResult(); + }); + } + } +} From 8cbe003e8c677624f175b2c6b6147707d0a161ff Mon Sep 17 00:00:00 2001 From: Sangkyoon Nam Date: Wed, 16 Sep 2026 06:06:42 +0900 Subject: [PATCH 100/107] Propagate headers on signals in the test server (#3066) --- .../signalTests/SignalHeaderTest.java | 128 ++++++++++++++++++ .../TestWorkflowMutableStateImpl.java | 3 + 2 files changed, 131 insertions(+) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/signalTests/SignalHeaderTest.java diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/signalTests/SignalHeaderTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/signalTests/SignalHeaderTest.java new file mode 100644 index 0000000000..9f3fc16ed0 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/signalTests/SignalHeaderTest.java @@ -0,0 +1,128 @@ +package io.temporal.workflow.signalTests; + +import static org.junit.Assert.assertEquals; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.common.interceptors.Header; +import io.temporal.common.interceptors.WorkerInterceptorBase; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalInput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptor.WorkflowSignalOutput; +import io.temporal.common.interceptors.WorkflowClientCallsInterceptorBase; +import io.temporal.common.interceptors.WorkflowClientInterceptorBase; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor.SignalInput; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptorBase; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Rule; +import org.junit.Test; + +/** + * Verifies that headers attached to a signal by a client interceptor reach the inbound workflow + * interceptor when running against the (time-skipping) test server. Regression test for the test + * server dropping the header while building the WorkflowExecutionSignaled event. + */ +public class SignalHeaderTest { + + private static final String HEADER_KEY = "signal-header-key"; + private static final String HEADER_VALUE = "signal-header-value"; + private static final AtomicReference RECEIVED_HEADER = new AtomicReference<>(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TestSignalWorkflowImpl.class) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setInterceptors(new SignalHeaderClientInterceptor()) + .validateAndBuildWithDefaults()) + .setWorkerFactoryOptions( + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new SignalHeaderWorkerInterceptor()) + .validateAndBuildWithDefaults()) + .build(); + + @Test + public void headerIsPropagatedToInboundSignalHandler() { + TestSignalWorkflow workflow = testWorkflowRule.newWorkflowStub(TestSignalWorkflow.class); + WorkflowStub stub = WorkflowStub.fromTyped(workflow); + stub.start(); + workflow.unblock(); + stub.getResult(Void.class); + assertEquals(HEADER_VALUE, RECEIVED_HEADER.get()); + } + + @WorkflowInterface + public interface TestSignalWorkflow { + @WorkflowMethod + void execute(); + + @SignalMethod + void unblock(); + } + + public static class TestSignalWorkflowImpl implements TestSignalWorkflow { + private boolean unblocked = false; + + @Override + public void execute() { + Workflow.await(() -> unblocked); + } + + @Override + public void unblock() { + unblocked = true; + } + } + + /** Adds a header to the outbound signal. */ + private static class SignalHeaderClientInterceptor extends WorkflowClientInterceptorBase { + @Override + public WorkflowClientCallsInterceptor workflowClientCallsInterceptor( + WorkflowClientCallsInterceptor next) { + return new WorkflowClientCallsInterceptorBase(next) { + @Override + public WorkflowSignalOutput signal(WorkflowSignalInput input) { + Map values = new HashMap<>(input.getHeader().getValues()); + values.put( + HEADER_KEY, + Payload.newBuilder().setData(ByteString.copyFromUtf8(HEADER_VALUE)).build()); + return super.signal( + new WorkflowSignalInput( + input.getWorkflowExecution(), + input.getSignalName(), + new Header(values), + input.getArguments())); + } + }; + } + } + + /** Captures the header seen by the inbound signal handler. */ + private static class SignalHeaderWorkerInterceptor extends WorkerInterceptorBase { + @Override + public WorkflowInboundCallsInterceptor interceptWorkflow(WorkflowInboundCallsInterceptor next) { + return new WorkflowInboundCallsInterceptorBase(next) { + @Override + public void handleSignal(SignalInput input) { + Payload payload = input.getHeader().getValues().get(HEADER_KEY); + if (payload != null) { + RECEIVED_HEADER.set(payload.getData().toStringUtf8()); + } + super.handleSignal(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 19c7376ee7..804cf7906c 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 @@ -3625,6 +3625,9 @@ private void addExecutionSignaledEvent( .setIdentity(signalRequest.getIdentity()) .setInput(signalRequest.getInput()) .setSignalName(signalRequest.getSignalName()); + if (signalRequest.hasHeader()) { + a.setHeader(signalRequest.getHeader()); + } HistoryEvent.Builder event = HistoryEvent.newBuilder() From 664cf09a165f66057ec863c14fbbd9aafa273ad3 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 15 Sep 2026 14:19:08 -0700 Subject: [PATCH 101/107] Unified link converter methods (#3068) There are multiple encoding and decoding methods for links, all pretty close but not quite. That had me worried as it is easy for them to get out of sync. This PR should be no functional changes, but merges those methods. --- .../internal/common/LinkConverter.java | 804 ++++++----- .../internal/common/LinkConverterTest.java | 1238 ++++++----------- 2 files changed, 847 insertions(+), 1195 deletions(-) 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 ae56af47d1..bbdc0b926e 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 @@ -1,6 +1,8 @@ package io.temporal.internal.common; -import static io.temporal.internal.common.ProtoEnumNameUtils.*; +import static io.temporal.internal.common.ProtoEnumNameUtils.EVENT_TYPE_PREFIX; +import static io.temporal.internal.common.ProtoEnumNameUtils.simplifiedToUniqueName; +import static io.temporal.internal.common.ProtoEnumNameUtils.uniqueToSimplifiedName; import io.temporal.api.common.v1.Link; import io.temporal.api.enums.v1.EventType; @@ -9,263 +11,94 @@ import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.*; -import java.util.AbstractMap.SimpleImmutableEntry; -import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Converts between {@link Link} (used on history events and RPCs) and {@link + * io.temporal.api.nexus.v1.Link} (the Nexus wire form: a URL plus a type string). + * + *

Four link types are supported, each with a fixed URL shape: + * + *

+ *   WorkflowEvent    temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}/history
+ *   Workflow         temporal:///namespaces/{ns}/workflows/{workflowId}/{runId}
+ *   NexusOperation   temporal:///namespaces/{ns}/nexus-operations/{operationId}/{runId}/details
+ *   Activity         temporal:///namespaces/{ns}/activities/{activityId}/{runId}/details
+ * 
+ * + *

Whoever decodes one of these URLs — the receiving server, or this class when a link arrives + * here — applies standard URL semantics: path segments are percent-decoded and query values are + * form-decoded. The two rules differ on {@code +}, so the codecs here are deliberately asymmetric — + * see {@link #encodePathSegment} and {@link #decodeQuery}. + * + *

Every method returns {@code null} rather than throwing when a link is malformed. Links are + * decorative metadata attached to a Nexus call, so a bad one must never fail the call carrying it. + */ public class LinkConverter { private static final Logger log = LoggerFactory.getLogger(LinkConverter.class); - private static final String temporalUrlScheme = "temporal"; - private static final String linkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s/history"; - private static final String nexusOperationLinkPathFormat = - "temporal:///namespaces/%s/nexus-operations/%s/%s/details"; - private static final String activityLinkPathFormat = - "temporal:///namespaces/%s/activities/%s/%s/details"; - private static final String workflowLinkPathFormat = "temporal:///namespaces/%s/workflows/%s/%s"; - private static final String linkReferenceTypeKey = "referenceType"; - private static final String linkEventIDKey = "eventID"; - private static final String linkEventTypeKey = "eventType"; - private static final String linkRequestIDKey = "requestID"; - private static final String linkReasonKey = "reason"; - - private static final String eventReferenceType = - Link.WorkflowEvent.EventReference.getDescriptor().getName(); - private static final String requestIDReferenceType = - Link.WorkflowEvent.RequestIdReference.getDescriptor().getName(); - private static final String workflowEventLinkType = - Link.WorkflowEvent.getDescriptor().getFullName(); - private static final String nexusOperationLinkType = - Link.NexusOperation.getDescriptor().getFullName(); - private static final String workflowLinkType = Link.Workflow.getDescriptor().getFullName(); - private static final String activityLinkType = Link.Activity.getDescriptor().getFullName(); - - public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { - try { + private static final String SCHEME = "temporal"; + private static final String UTF_8 = StandardCharsets.UTF_8.name(); + private static final String NAMESPACES_SEGMENT = "namespaces"; - String url = - String.format( - linkPathFormat, - URLEncoder.encode(we.getNamespace(), 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<>(); - if (we.hasEventRef()) { - queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, eventReferenceType)); - Link.WorkflowEvent.EventReference eventRef = we.getEventRef(); - if (eventRef.getEventId() > 0) { - queryParams.add( - new SimpleImmutableEntry<>(linkEventIDKey, String.valueOf(eventRef.getEventId()))); - } - final String eventType = - URLEncoder.encode( - encodeEventType(eventRef.getEventType()), StandardCharsets.UTF_8.toString()); - queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType)); - } else if (we.hasRequestIdRef()) { - queryParams.add(new SimpleImmutableEntry<>(linkReferenceTypeKey, requestIDReferenceType)); - Link.WorkflowEvent.RequestIdReference requestIDRef = we.getRequestIdRef(); - final String requestID = - URLEncoder.encode(requestIDRef.getRequestId(), StandardCharsets.UTF_8.toString()); - queryParams.add(new SimpleImmutableEntry<>(linkRequestIDKey, requestID)); - final String eventType = - URLEncoder.encode( - encodeEventType(requestIDRef.getEventType()), StandardCharsets.UTF_8.toString()); - queryParams.add(new SimpleImmutableEntry<>(linkEventTypeKey, eventType)); - } + private static final String REFERENCE_TYPE_KEY = "referenceType"; + private static final String EVENT_ID_KEY = "eventID"; + private static final String EVENT_TYPE_KEY = "eventType"; + private static final String REQUEST_ID_KEY = "requestID"; + private static final String REASON_KEY = "reason"; - url += - "?" - + queryParams.stream() - .map((item) -> item.getKey() + "=" + item.getValue()) - .collect(Collectors.joining("&")); - - return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(we.getDescriptorForType().getFullName()) - .build(); - } catch (Exception e) { - log.error("Failed to encode Nexus link URL", e); - } - return null; - } + private static final String EVENT_REFERENCE_TYPE = + Link.WorkflowEvent.EventReference.getDescriptor().getName(); + private static final String REQUEST_ID_REFERENCE_TYPE = + Link.WorkflowEvent.RequestIdReference.getDescriptor().getName(); /** - * Converts a {@link Link.Workflow} to a Nexus link. A workflow link addresses a workflow - * execution as a whole rather than one event within it, so the URL uses the workflow path and - * carries no event path suffix and no reference query params. It is used when there is no history - * event to point at, for example a Query or a rejected Update. The optional {@code reason} - * explaining why the link exists is carried as a query param. + * The four link types, as a table of (path keyword, path tail, proto type name). + * + *

The path is always {@code /namespaces/{ns}/{keyword}/{id}/{runId}[/{tail}]}, so a link has 5 + * segments when {@link #tail} is null and 6 otherwise. Matching the count exactly is what keeps a + * Workflow link distinguishable from a WorkflowEvent link, since they share a keyword. */ - public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) { - try { - String url = - String.format( - workflowLinkPathFormat, - encodePathSegment(w.getNamespace()), - encodePathSegment(w.getWorkflowId()), - encodePathSegment(w.getRunId())); - if (!w.getReason().isEmpty()) { - url += - "?" - + linkReasonKey - + "=" - + URLEncoder.encode(w.getReason(), StandardCharsets.UTF_8.toString()); - } - return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(workflowLinkType) - .build(); - } catch (Exception e) { - log.error("Failed to convert WorkflowLink {} to NexusLink", w, e); - return null; - } - } + private enum LinkType { + WORKFLOW_EVENT("workflows", "history", Link.WorkflowEvent.getDescriptor().getFullName()), - public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) { - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); + /** A workflow execution as a whole, for when there is no history event to point at. */ + WORKFLOW("workflows", null, Link.Workflow.getDescriptor().getFullName()), - if (!uri.getScheme().equals("temporal")) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); - return null; - } + NEXUS_OPERATION( + "nexus-operations", "details", Link.NexusOperation.getDescriptor().getFullName()), - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String namespace = decodePathSegment(st.nextToken()); - if (!st.nextToken().equals("workflows")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String workflowID = decodePathSegment(st.nextToken()); - String runID = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("history")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } + ACTIVITY("activities", "details", Link.Activity.getDescriptor().getFullName()); - Link.WorkflowEvent.Builder we = - Link.WorkflowEvent.newBuilder() - .setNamespace(namespace) - .setWorkflowId(workflowID) - .setRunId(runID); - - Map queryParams = parseQueryParams(uri); - String referenceType = queryParams.get(linkReferenceTypeKey); - if (referenceType.equals(eventReferenceType)) { - Link.WorkflowEvent.EventReference.Builder eventRef = - Link.WorkflowEvent.EventReference.newBuilder(); - String eventID = queryParams.get(linkEventIDKey); - if (eventID != null && !eventID.isEmpty()) { - eventRef.setEventId(Long.parseLong(eventID)); - } - String eventType = queryParams.get(linkEventTypeKey); - if (eventType != null && !eventType.isEmpty()) { - eventRef.setEventType(decodeEventType(eventType)); - } - we.setEventRef(eventRef); - } else if (referenceType.equals(requestIDReferenceType)) { - Link.WorkflowEvent.RequestIdReference.Builder requestIDRef = - Link.WorkflowEvent.RequestIdReference.newBuilder(); - String requestID = queryParams.get(linkRequestIDKey); - if (requestID != null && !requestID.isEmpty()) { - requestIDRef.setRequestId(requestID); - } - String eventType = queryParams.get(linkEventTypeKey); - if (eventType != null && !eventType.isEmpty()) { - requestIDRef.setEventType(decodeEventType(eventType)); - } - we.setRequestIdRef(requestIDRef); - } else { - log.error("Failed to parse Nexus link URL: invalid reference type: {}", referenceType); - return null; - } + private final String keyword; + @Nullable private final String tail; + private final String type; - link.setWorkflowEvent(we); - } catch (Exception e) { - // Swallow un-parsable links since they are not critical to processing - log.error("Failed to parse Nexus link URL", e); - return null; + LinkType(String keyword, @Nullable String tail, String type) { + this.keyword = keyword; + this.tail = tail; + this.type = type; } - return link.build(); - } - public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) { - if (!workflowLinkType.equals(nexusLink.getType())) { - log.error( - "Failed to parse Nexus link URL: cannot parse link type {} to {}", - nexusLink.getType(), - workflowLinkType); - return null; - } - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); - - // Compared in this order so a URL with no scheme at all reports the invalid scheme rather - // than throwing. - if (!temporalUrlScheme.equals(uri.getScheme())) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); - return null; - } - - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String namespace = decodePathSegment(st.nextToken()); - if (!st.nextToken().equals("workflows")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String workflowID = decodePathSegment(st.nextToken()); - String runID = decodePathSegment(st.nextToken()); - // The run ID ends a workflow link, so anything trailing means this is a different link - // shape. In particular this rejects the workflow-event form, which ends in "/history". - if (st.hasMoreTokens()) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - - Link.Workflow.Builder w = - Link.Workflow.newBuilder() - .setNamespace(namespace) - .setWorkflowId(workflowID) - .setRunId(runID); - String reason = rawQueryParam(uri, linkReasonKey); - if (reason != null) { - w.setReason(reason); - } - - link.setWorkflow(w); - } catch (Exception e) { - // Swallow un-parsable links since they are not critical to processing. - log.error("Failed to parse Nexus link URL", e); - return null; + int segmentCount() { + return tail == null ? 5 : 6; } - return link.build(); } - /** - * Dispatches on the oneof variant of {@code commonLink} and converts to the matching {@link - * io.temporal.api.nexus.v1.Link}. Returns {@code null} if no variant is set or encoding fails. - */ + // =============================================================================================== + // Encode: Link -> nexus.v1.Link. + // =============================================================================================== + + /** Dispatches on the oneof variant of {@code commonLink}. Returns null if no variant is set. */ + @Nullable public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { if (commonLink.hasWorkflowEvent()) { return workflowEventToNexusLink(commonLink.getWorkflowEvent()); @@ -282,229 +115,440 @@ public static io.temporal.api.nexus.v1.Link linkToNexusLink(Link commonLink) { return null; } - /** - * Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()} and converts to the matching - * {@link Link} variant. Returns {@code null} for unknown or unparseable types. - */ + public static io.temporal.api.nexus.v1.Link workflowEventToNexusLink(Link.WorkflowEvent we) { + List> reference; + try { + reference = encodeReference(we); + } catch (Exception e) { + // Guarded separately because this runs before encode() is entered, so encode()'s own catch + // does not cover it. encodeEventType rejects an event type these protos do not know + // (EventType.UNRECOGNIZED), which a server newer than this SDK can send. + log.error("Failed to encode Nexus link reference", e); + return null; + } + return encode( + LinkType.WORKFLOW_EVENT, we.getNamespace(), we.getWorkflowId(), we.getRunId(), reference); + } + + public static io.temporal.api.nexus.v1.Link workflowLinkToNexusLink(Link.Workflow w) { + List> query = new ArrayList<>(); + if (!w.getReason().isEmpty()) { + query.add(param(REASON_KEY, w.getReason())); + } + return encode(LinkType.WORKFLOW, w.getNamespace(), w.getWorkflowId(), w.getRunId(), query); + } + + public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) { + return encode( + LinkType.NEXUS_OPERATION, + no.getNamespace(), + no.getOperationId(), + no.getRunId(), + Collections.emptyList()); + } + + public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity activity) { + return encode( + LinkType.ACTIVITY, + activity.getNamespace(), + activity.getActivityId(), + activity.getRunId(), + Collections.emptyList()); + } + + // =============================================================================================== + // Decode: nexus.v1.Link -> Link. + // =============================================================================================== + + /** Dispatches on {@link io.temporal.api.nexus.v1.Link#getType()}. */ + @Nullable public static Link nexusLinkToLink(io.temporal.api.nexus.v1.Link nexusLink) { String type = nexusLink.getType(); - if (workflowEventLinkType.equals(type)) { + if (LinkType.WORKFLOW_EVENT.type.equals(type)) { return nexusLinkToWorkflowEvent(nexusLink); } - if (nexusOperationLinkType.equals(type)) { + if (LinkType.NEXUS_OPERATION.type.equals(type)) { return nexusLinkToNexusOperation(nexusLink); } - if (workflowLinkType.equals(type)) { + if (LinkType.WORKFLOW.type.equals(type)) { return nexusLinkToWorkflowLink(nexusLink); } - if (activityLinkType.equals(type)) { + if (LinkType.ACTIVITY.type.equals(type)) { return nexusLinkToActivity(nexusLink); } log.warn("ignoring unsupported nexus link type: {}", type); return null; } - public static io.temporal.api.nexus.v1.Link activityToNexusLink(Link.Activity activity) { - try { - String url = - String.format( - activityLinkPathFormat, - URLEncoder.encode(activity.getNamespace(), StandardCharsets.UTF_8.toString()), - URLEncoder.encode(activity.getActivityId(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"), - URLEncoder.encode(activity.getRunId(), StandardCharsets.UTF_8.toString())); - return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(activityLinkType) - .build(); - } catch (Exception e) { - log.error("Failed to encode activity Nexus link URL", e); + @Nullable + public static Link nexusLinkToWorkflowEvent(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.WORKFLOW_EVENT, nexusLink); + if (decoded == null) { + return null; } - return null; + Link.WorkflowEvent.Builder we = + Link.WorkflowEvent.newBuilder() + .setNamespace(decoded.namespace) + .setWorkflowId(decoded.id) + .setRunId(decoded.runId); + if (!decodeReference(we, decoded.query)) { + return null; + } + return Link.newBuilder().setWorkflowEvent(we).build(); } - public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) { - if (!activityLinkType.equals(nexusLink.getType())) { - log.error( - "Failed to parse Nexus link URL: cannot parse link type {} to {}", - nexusLink.getType(), - activityLinkType); + @Nullable + public static Link nexusLinkToWorkflowLink(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.WORKFLOW, nexusLink); + if (decoded == null) { return null; } - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); - if (!"temporal".equals(uri.getScheme())) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); - return null; - } - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String namespace = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("activities")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String activityId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens()) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - String runId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); - return null; - } - link.setActivity( - Link.Activity.newBuilder() - .setNamespace(namespace) - .setActivityId(activityId) - .setRunId(runId)); - } catch (Exception e) { - log.error("Failed to parse activity Nexus link URL", e); + Link.Workflow.Builder w = + Link.Workflow.newBuilder() + .setNamespace(decoded.namespace) + .setWorkflowId(decoded.id) + .setRunId(decoded.runId); + String reason = decoded.query.get(REASON_KEY); + if (reason != null) { + w.setReason(reason); + } + return Link.newBuilder().setWorkflow(w).build(); + } + + @Nullable + public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.NEXUS_OPERATION, nexusLink); + if (decoded == null) { return null; } - return link.build(); + return Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder() + .setNamespace(decoded.namespace) + .setOperationId(decoded.id) + .setRunId(decoded.runId)) + .build(); } - public static io.temporal.api.nexus.v1.Link nexusOperationToNexusLink(Link.NexusOperation no) { + @Nullable + public static Link nexusLinkToActivity(io.temporal.api.nexus.v1.Link nexusLink) { + Decoded decoded = decode(LinkType.ACTIVITY, nexusLink); + if (decoded == null) { + return null; + } + return Link.newBuilder() + .setActivity( + Link.Activity.newBuilder() + .setNamespace(decoded.namespace) + .setActivityId(decoded.id) + .setRunId(decoded.runId)) + .build(); + } + + // =============================================================================================== + // Shared encode/decode. + // =============================================================================================== + + /** + * Builds a Nexus link URL for {@code linkType}. + * + *

Concatenated rather than built with {@link URI}, because the segments are already + * percent-encoded and {@link URI} would escape the escapes. + */ + @Nullable + private static io.temporal.api.nexus.v1.Link encode( + LinkType linkType, + String namespace, + String id, + String runId, + List> queryParams) { try { - String url = - String.format( - nexusOperationLinkPathFormat, - URLEncoder.encode(no.getNamespace(), StandardCharsets.UTF_8.toString()), - // See the WorkflowId comment in workflowEventToNexusLink for why '+' is rewritten to - // '%20'. OperationId is user-supplied and can legally contain spaces. - URLEncoder.encode(no.getOperationId(), StandardCharsets.UTF_8.toString()) - .replace("+", "%20"), - URLEncoder.encode(no.getRunId(), StandardCharsets.UTF_8.toString())); + StringBuilder url = + new StringBuilder(SCHEME) + .append(":///") + .append(NAMESPACES_SEGMENT) + .append('/') + .append(encodePathSegment(namespace)) + .append('/') + .append(linkType.keyword) + .append('/') + .append(encodePathSegment(id)) + .append('/') + .append(encodePathSegment(runId)); + if (linkType.tail != null) { + url.append('/').append(linkType.tail); + } + if (!queryParams.isEmpty()) { + url.append('?').append(encodeQuery(queryParams)); + } return io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl(url) - .setType(nexusOperationLinkType) + .setUrl(url.toString()) + .setType(linkType.type) .build(); } catch (Exception e) { - log.error("Failed to encode Nexus operation link URL", e); + log.error("Failed to encode {} Nexus link URL", linkType, e); + return null; } - return null; } - public static Link nexusLinkToNexusOperation(io.temporal.api.nexus.v1.Link nexusLink) { - if (!nexusOperationLinkType.equals(nexusLink.getType())) { - log.error( - "Failed to parse Nexus link URL: cannot parse link type {} to {}", - nexusLink.getType(), - nexusOperationLinkType); - return null; + /** The three path IDs plus the decoded query. */ + private static final class Decoded { + final String namespace; + final String id; + final String runId; + final Map query; + + Decoded(String namespace, String id, String runId, Map query) { + this.namespace = namespace; + this.id = id; + this.runId = runId; + this.query = query; } - Link.Builder link = Link.newBuilder(); - try { - URI uri = new URI(nexusLink.getUrl()); + } - if (!"temporal".equals(uri.getScheme())) { - log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); + /** + * Validates a Nexus link against {@code linkType} and splits out its path IDs and query. + * + *

The declared type must match, the path must have exactly the expected segments, and no + * segment may be empty. + */ + @Nullable + private static Decoded decode(LinkType linkType, io.temporal.api.nexus.v1.Link nexusLink) { + try { + if (!linkType.type.equals(nexusLink.getType())) { + log.error( + "Failed to parse Nexus link URL: cannot parse link type {} to {}", + nexusLink.getType(), + linkType.type); return null; } - StringTokenizer st = new StringTokenizer(uri.getRawPath(), "/"); - if (!st.hasMoreTokens() || !st.nextToken().equals("namespaces")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + URI uri = new URI(nexusLink.getUrl()); + if (!SCHEME.equals(uri.getScheme())) { + log.error("Failed to parse Nexus link URL: invalid scheme: {}", uri.getScheme()); return null; } - String namespace = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("nexus-operations")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + String rawPath = uri.getRawPath(); + if (rawPath == null) { + log.error("Failed to parse Nexus link URL: no path: {}", nexusLink.getUrl()); return null; } - String operationId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens()) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + // Split the raw path: a segment may legally contain an encoded slash. + String[] segments = + rawPath.startsWith("/") ? rawPath.substring(1).split("/", -1) : rawPath.split("/", -1); + + if (segments.length != linkType.segmentCount() + || !NAMESPACES_SEGMENT.equals(segments[0]) + || !linkType.keyword.equals(segments[2]) + || (linkType.tail != null && !linkType.tail.equals(segments[5]))) { + log.error("Failed to parse Nexus link URL: invalid path: {}", rawPath); return null; } - String runId = decodePathSegment(st.nextToken()); - if (!st.hasMoreTokens() || !st.nextToken().equals("details")) { - log.error("Failed to parse Nexus link URL: invalid path: {}", uri.getRawPath()); + if (segments[1].isEmpty() || segments[3].isEmpty() || segments[4].isEmpty()) { + log.error("Failed to parse Nexus link URL: empty path segment: {}", rawPath); return null; } - link.setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace(namespace) - .setOperationId(operationId) - .setRunId(runId)); + return new Decoded( + decodePathSegment(segments[1]), + decodePathSegment(segments[3]), + decodePathSegment(segments[4]), + decodeQuery(uri.getRawQuery())); } catch (Exception e) { + // Swallow un-parsable links since they are not critical to processing. log.error("Failed to parse Nexus link URL", e); return null; } - return link.build(); } /** - * Percent-encodes a single URL path segment. {@link URLEncoder} targets form encoding, where a - * space becomes '+', so rewrite it to "%20" as required for a path. + * Percent-encodes one path segment, encoding a space as {@code %20}. + * + *

{@code java.net}'s URL codecs target HTML form data rather than general URIs, so {@link + * URLEncoder} emits {@code +} for a space. In a path a {@code +} is a literal plus to the + * decoding server, so in that edge case the path is incorrect and ends up with a + instead of a + * space. Since the encoder writes a {@code +} as {@code %2B}, we can adjust for this by replacing + * any {@code +} signs we find with {@code %2B} as we know they are encoded spaces. */ - private static String encodePathSegment(String value) throws UnsupportedEncodingException { - return URLEncoder.encode(value, StandardCharsets.UTF_8.toString()).replace("+", "%20"); + private static String encodePathSegment(String segment) throws UnsupportedEncodingException { + return URLEncoder.encode(segment, UTF_8).replace("+", "%20"); } /** - * Percent-decodes a single URL path segment. {@link URLDecoder} targets form decoding, where '+' - * means a space, but in a path a '+' is a literal character. Pre-escaping '+' as "%2B" keeps it - * literal while leaving genuine percent escapes such as "%20" for the decoder to handle. + * Percent-decodes one path segment, leaving {@code +} alone. + * + *

The same form-data quirk in reverse: {@link URLDecoder} reads {@code +} as a space, which + * would corrupt an identifier containing a literal plus. Java 8 has no percent-only decoder, so + * pre-escape plus signs to {@code %2B} and let the form decoder hand them back unchanged. */ - private static String decodePathSegment(String value) throws UnsupportedEncodingException { - return URLDecoder.decode(value.replace("+", "%2B"), StandardCharsets.UTF_8.toString()); + private static String decodePathSegment(String segment) throws UnsupportedEncodingException { + return URLDecoder.decode(segment.replace("+", "%2B"), UTF_8); + } + + /** Form-encodes query parameters in the order given. */ + private static String encodeQuery(List> params) + throws UnsupportedEncodingException { + StringBuilder sb = new StringBuilder(); + for (Map.Entry p : params) { + if (sb.length() > 0) { + sb.append('&'); + } + sb.append(URLEncoder.encode(p.getKey(), UTF_8)) + .append('=') + .append(URLEncoder.encode(p.getValue(), UTF_8)); + } + return sb.toString(); } /** - * Reads a single param out of the raw, still-encoded query string, or returns null when the param - * is absent. Unlike {@link #parseQueryParams} the value is decoded exactly once, so values that - * themselves contain '=' or '&' survive the round trip. + * Form-decodes a raw query string. + * + *

Takes {@link URI#getRawQuery()} rather than {@link URI#getQuery()}: the latter is already + * percent-decoded, so decoding it again throws on any value containing a bare {@code %} and + * mis-splits values containing {@code &} or {@code =}. + * + *

Unlike a path segment, a query value is form-decoded, so {@code +} means a space. {@link + * URLDecoder} does that before percent-decoding, which is the required order — form encoding + * writes a literal {@code +} as {@code %2B}. */ - private static String rawQueryParam(URI uri, String key) throws UnsupportedEncodingException { - final String rawQuery = uri.getRawQuery(); + private static Map decodeQuery(@Nullable String rawQuery) + throws UnsupportedEncodingException { if (rawQuery == null || rawQuery.isEmpty()) { - return null; + return Collections.emptyMap(); } + Map params = new LinkedHashMap<>(); for (String pair : rawQuery.split("&")) { - final String[] kv = pair.split("=", 2); - if (kv[0].equals(key)) { - return kv.length == 2 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString()) : ""; + if (pair.isEmpty()) { + continue; } + String[] kv = pair.split("=", 2); + String key = URLDecoder.decode(kv[0], UTF_8); + // First occurrence wins on a repeated key, matching api-go's Query().Get. No encoder emits + // one, but silently preferring the last would make Java the odd SDK out. + if (params.containsKey(key)) { + continue; + } + // A key with no usable value maps to null, whether written "?k" or "?k="; callers null-check. + String value = kv.length == 2 && !kv[1].isEmpty() ? URLDecoder.decode(kv[1], UTF_8) : null; + params.put(key, value); } - return null; + return params; } - private static Map parseQueryParams(URI uri) throws UnsupportedEncodingException { - final String query = uri.getQuery(); - if (query == null || query.isEmpty()) { - return Collections.emptyMap(); + // =============================================================================================== + // The WorkflowEvent "reference": WHICH event in the workflow's history the link points at. + // + // A Link.WorkflowEvent points at one specific history event. The path names the workflow + // (namespace / workflowId / runId); the reference names the event inside it. It travels in the + // query string rather than the path because it is optional and comes in two shapes. + // + // An event can be named two ways, which is why the proto models this as a oneof: + // + // EventReference by event ID, the event's position in history. Only usable once the + // event exists and the caller knows its ID. + // RequestIdReference by the request ID of the RPC that produced the event. Used when the + // caller holds a request ID but no event ID -- a link built at the moment + // a workflow is started or an update is accepted, where the event either + // does not exist yet or its ID was never returned. The server resolves + // the request ID to the event later. + // + // Both arms also carry the event type, which can be enough on its own: a link to + // WorkflowExecutionStarted needs no event ID because that is always event 1, and the UI + // resolves it that way (temporalio/ui, src/lib/utilities/event-link.ts). That is why eventID is + // omitted rather than sent as 0. + // + // The whole link has to cross the wire as one URL string, so encodeReference flattens the oneof + // into referenceType + eventID/requestID + eventType, and decodeReference reads those params + // back and rebuilds it. + // =============================================================================================== + + /** An unset oneof yields no params, so a workflow-event link can legally have an empty query. */ + private static List> encodeReference(Link.WorkflowEvent we) { + List> query = new ArrayList<>(); + if (we.hasEventRef()) { + Link.WorkflowEvent.EventReference ref = we.getEventRef(); + query.add(param(REFERENCE_TYPE_KEY, EVENT_REFERENCE_TYPE)); + // An unset event ID is 0, which is not a valid event ID, so omit it rather than send a zero. + if (ref.getEventId() > 0) { + query.add(param(EVENT_ID_KEY, String.valueOf(ref.getEventId()))); + } + query.add(param(EVENT_TYPE_KEY, encodeEventType(ref.getEventType()))); + } else if (we.hasRequestIdRef()) { + Link.WorkflowEvent.RequestIdReference ref = we.getRequestIdRef(); + query.add(param(REFERENCE_TYPE_KEY, REQUEST_ID_REFERENCE_TYPE)); + query.add(param(REQUEST_ID_KEY, ref.getRequestId())); + query.add(param(EVENT_TYPE_KEY, encodeEventType(ref.getEventType()))); } - Map queryParams = new HashMap<>(); - for (String pair : query.split("&")) { - final String[] kv = pair.split("=", 2); - final String key = URLDecoder.decode(kv[0], StandardCharsets.UTF_8.toString()); - final String value = - kv.length == 2 && !kv[1].isEmpty() - ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8.toString()) - : null; - queryParams.put(key, value); + return query; + } + + /** + * Selects the arm by {@code referenceType}. Returns false if the query names no recognized + * reference, which makes the link unusable — a workflow event link must say which event it means. + * + *

The catch is load-bearing: this runs outside {@link #decode}'s try block, and both {@link + * Long#parseLong} and {@link EventType#valueOf} throw on malformed input. + */ + private static boolean decodeReference(Link.WorkflowEvent.Builder we, Map query) { + try { + return decodeReferenceOrThrow(we, query); + } catch (Exception e) { + log.error("Failed to parse Nexus link URL reference", e); + return false; } - return queryParams; } + private static boolean decodeReferenceOrThrow( + Link.WorkflowEvent.Builder we, Map query) { + String referenceType = query.get(REFERENCE_TYPE_KEY); + if (EVENT_REFERENCE_TYPE.equals(referenceType)) { + Link.WorkflowEvent.EventReference.Builder ref = + Link.WorkflowEvent.EventReference.newBuilder(); + String eventId = query.get(EVENT_ID_KEY); + if (eventId != null && !eventId.isEmpty()) { + ref.setEventId(Long.parseLong(eventId)); + } + String eventType = query.get(EVENT_TYPE_KEY); + if (eventType != null && !eventType.isEmpty()) { + ref.setEventType(decodeEventType(eventType)); + } + we.setEventRef(ref); + return true; + } + if (REQUEST_ID_REFERENCE_TYPE.equals(referenceType)) { + Link.WorkflowEvent.RequestIdReference.Builder ref = + Link.WorkflowEvent.RequestIdReference.newBuilder(); + String requestId = query.get(REQUEST_ID_KEY); + if (requestId != null && !requestId.isEmpty()) { + ref.setRequestId(requestId); + } + String eventType = query.get(EVENT_TYPE_KEY); + if (eventType != null && !eventType.isEmpty()) { + ref.setEventType(decodeEventType(eventType)); + } + we.setRequestIdRef(ref); + return true; + } + log.error("Failed to parse Nexus link URL: invalid reference type: {}", referenceType); + return false; + } + + /** Emits the short PascalCase event type name, e.g. {@code WorkflowExecutionStarted}. */ private static String encodeEventType(EventType eventType) { return uniqueToSimplifiedName(eventType.name(), EVENT_TYPE_PREFIX); } + /** Accepts either the {@code EVENT_TYPE_}-prefixed proto name or the short PascalCase form. */ private static EventType decodeEventType(String eventType) { - // Have to handle the SCREAMING_CASE enum or the traditional temporal PascalCase enum to - // EventType if (eventType.startsWith(EVENT_TYPE_PREFIX)) { return EventType.valueOf(eventType); } return EventType.valueOf(simplifiedToUniqueName(eventType, EVENT_TYPE_PREFIX)); } + + private static Map.Entry param(String key, String value) { + return new java.util.AbstractMap.SimpleImmutableEntry<>(key, value); + } + + private LinkConverter() {} } 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 2434f12db2..8086b5c9a5 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 @@ -1,948 +1,556 @@ package io.temporal.internal.common; -import static io.temporal.internal.common.LinkConverter.activityToNexusLink; import static io.temporal.internal.common.LinkConverter.linkToNexusLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToActivity; import static io.temporal.internal.common.LinkConverter.nexusLinkToLink; import static io.temporal.internal.common.LinkConverter.nexusLinkToNexusOperation; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowEvent; import static io.temporal.internal.common.LinkConverter.nexusLinkToWorkflowLink; -import static io.temporal.internal.common.LinkConverter.nexusOperationToNexusLink; -import static io.temporal.internal.common.LinkConverter.workflowEventToNexusLink; -import static io.temporal.internal.common.LinkConverter.workflowLinkToNexusLink; -import static org.junit.Assert.*; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.fail; 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; +/** + * Tests for {@link LinkConverter}. + * + *

The URL shapes and the encoding rules asserted here are a wire contract shared with the Go, + * Python, TypeScript and .NET SDKs, so changing an expected URL means changing it everywhere. + * + *

Three things are deliberately not pinned as contract, because no decoder can observe them: + * query parameter order, whether a space in a query value is {@code +} or {@code %20}, and whether + * a literal {@code +} in a path is bare or {@code %2B}. Tests that assert Java's concrete choice + * for those say so. + */ public class LinkConverterTest { - @Test - public void testConvertWorkflowEventToNexus_Valid() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .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-id/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); + private static final String WORKFLOW_EVENT = Link.WorkflowEvent.getDescriptor().getFullName(); + private static final String WORKFLOW = Link.Workflow.getDescriptor().getFullName(); + private static final String NEXUS_OPERATION = Link.NexusOperation.getDescriptor().getFullName(); + private static final String ACTIVITY = Link.Activity.getDescriptor().getFullName(); - input = - input.toBuilder() - .setRequestIdRef( - Link.WorkflowEvent.RequestIdReference.newBuilder() - .setRequestId("random-request-id") - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED)) - .build(); - expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?referenceType=RequestIdReference&requestID=random-request-id&eventType=WorkflowExecutionOptionsUpdated") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); - } + // =============================================================================================== + // Encode. + // =============================================================================================== @Test - public void testConvertWorkflowEventToNexus_ValidAngle() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id>") - .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-id%3E/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); + public void encodesWorkflowEventWithEventReference() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } + /** An unset event ID is 0, which is not a valid event ID, so the param is omitted. */ @Test - public void testConvertWorkflowEventToNexus_ValidSlash() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id/") - .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-id%2F/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); + public void omitsEventIdWhenUnset() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id", "run-id", 0, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } @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); + public void encodesWorkflowEventWithRequestIdReference() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=RequestIdReference&requestID=req-id" + + "&eventType=WorkflowExecutionOptionsUpdated", + WORKFLOW_EVENT, + requestIdRef( + "ns", + "wf-id", + "run-id", + "req-id", + EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED)); } + /** + * A space in a path segment must be {@code %20}, never {@code +}: the decoding server treats a + * {@code +} in a path as a literal plus, so the space would be lost and the link would point at a + * workflow that does not exist. Regression guard for #2874. + */ @Test - public void testConvertWorkflowEventToNexus_ValidEventIDMissing() { - Link.WorkflowEvent input = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .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-id/run-id/history?referenceType=EventReference&eventType=WorkflowExecutionStarted") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowEventToNexusLink(input); - assertEquals(expected, actual); + public void encodesSpaceInPathAsPercent20() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf%20id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } + /** An encoded slash must stay encoded, or the path gains a segment and no longer parses. */ @Test - public void testConvertNexusToWorkflowEvent_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); - - input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?referenceType=RequestIdReference&requestID=random-request-id&eventType=WorkflowExecutionOptionsUpdated") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setRequestIdRef( - Link.WorkflowEvent.RequestIdReference.newBuilder() - .setRequestId("random-request-id") - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED))) - .build(); - - actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesSlashAndAngleInPathSegment() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id%2F/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id/", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id%3E/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + WORKFLOW_EVENT, + eventRef("ns", "wf-id>", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)); } @Test - public void testConvertNexusToWorkflowEvent_ValidLongEventType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventID=1&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesNonAsciiPathSegment() { + assertEncodes( + "temporal:///namespaces/ns%C3%A4/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=7&eventType=NexusOperationScheduled", + WORKFLOW_EVENT, + eventRef("nsä", "wf-id", "run-id", 7, EventType.EVENT_TYPE_NEXUS_OPERATION_SCHEDULED)); } + /** A workflow link addresses the execution as a whole, so it has no {@code /history} tail. */ @Test - public void testConvertNexusToWorkflowEvent_ValidAngle() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id%3E/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id>") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesWorkflowLink() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id", + WORKFLOW, + workflow("ns", "wf-id", "run-id", "")); } + /** The space encoding in the query value ({@code +}) is Java's choice, not contract. */ @Test - public void testConvertNexusToWorkflowEvent_ValidSlash() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id%2F/run-id/history?eventID=1&eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id/") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesWorkflowLinkReason() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update", + WORKFLOW, + workflow("ns", "wf-id", "run-id", "rejected update")); } @Test - public void testConvertNexusToWorkflowEvent_ValidEventIDMissing() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflowEvent( - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED))) - .build(); - - Link actual = nexusLinkToWorkflowEvent(input); - assertEquals(expected, actual); + public void encodesWorkflowLinkPathSegments() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf%20id/run-id", + WORKFLOW, workflow("ns", "wf id", "run-id", "")); + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id%2F/run-id", + WORKFLOW, workflow("ns", "wf-id/", "run-id", "")); } + /** A literal plus must survive; a bare {@code +} would form-decode back to a space. */ @Test - public void testConvertNexusToWorkflowEvent_InvalidScheme() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "test:///namespaces/ns/workflows/wf-id/run-id/history?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesLiteralPlusInReason() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=a%2Bb", + WORKFLOW, workflow("ns", "wf-id", "run-id", "a+b")); } + /** A reason may contain the query delimiters themselves; they must not split the parameter. */ @Test - public void testConvertNexusToWorkflowEvent_InvalidPathMissingHistory() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesReasonContainingDelimiters() { + Link in = workflow("ns", "wf-id", "run-id", "a&b=c"); + assertEquals(in, nexusLinkToLink(linkToNexusLink(in))); } @Test - public void testConvertNexusToWorkflowEvent_InvalidPathMissingNamespace() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces//workflows/wf-id/run-id/history?eventType=WorkflowExecutionStarted&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesNexusOperationLink() { + assertEncodes( + "temporal:///namespaces/ns/nexus-operations/op-id/run-id/details", + NEXUS_OPERATION, + nexusOperation("ns", "op-id", "run-id")); + assertEncodes( + "temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details", + NEXUS_OPERATION, nexusOperation("ns", "op/id", "run-id")); } @Test - public void testConvertNexusToWorkflowEvent_InvalidEventType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id/history?eventType=WorkflowExecution&referenceType=EventReference") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowEvent(input)); + public void encodesActivityLink() { + assertEncodes( + "temporal:///namespaces/ns/activities/act-id/run-id/details", + ACTIVITY, + activity("ns", "act-id", "run-id")); + assertEncodes( + "temporal:///namespaces/ns/activities/act%2Fid/run-id/details", + ACTIVITY, activity("ns", "act/id", "run-id")); } + /** The event type goes on the wire in the short PascalCase form. */ @Test - public void testConvertNexusOperationToNexus_Valid() { - Link.NexusOperation input = - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertEquals(expected, nexusOperationToNexusLink(input)); + public void encodesEventTypeInPascalCase() { + assertEncodes( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=2&eventType=NexusOperationCancelRequested", + WORKFLOW_EVENT, + eventRef( + "ns", "wf-id", "run-id", 2, EventType.EVENT_TYPE_NEXUS_OPERATION_CANCEL_REQUESTED)); } + /** Query parameter order is not contract, but Java's order is pinned so it stays deliberate. */ @Test - public void testConvertNexusOperationToNexus_ValidSlash() { - Link.NexusOperation input = - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op/id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertEquals(expected, nexusOperationToNexusLink(input)); + public void emitsQueryParametersInInsertionOrder() { + assertEquals( + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted", + linkToNexusLink( + eventRef( + "ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) + .getUrl()); } - @Test - public void testConvertNexusToNexusOperation_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - Link expected = - Link.newBuilder() - .setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToNexusOperation(input)); - } + // =============================================================================================== + // Decode. + // =============================================================================================== + /** Both event type spellings must decode; other SDKs emit the prefixed form. */ @Test - public void testConvertNexusToNexusOperation_ValidSlash() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - + public void decodesEitherEventTypeSpelling() { Link expected = - Link.newBuilder() - .setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op/id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToNexusOperation(input)); + eventRef("ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); + assertDecodes( + expected, + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted"); + assertDecodes( + expected, + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=1" + + "&eventType=EVENT_TYPE_WORKFLOW_EXECUTION_STARTED"); + } + + /** Parameters are read by key, so any order decodes. */ + @Test + public void decodesQueryParametersInAnyOrder() { + assertDecodes( + eventRef("ns", "wf-id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?eventType=WorkflowExecutionStarted&referenceType=EventReference&eventID=1"); + assertDecodes( + workflow("ns", "wf-id", "run-id", "why"), + WORKFLOW, + "temporal:///namespaces/ns/workflows/wf-id/run-id?other=x&reason=why"); } + /** + * Path segments are percent-decoded, not form-decoded, in both legal spellings of a plus. Other + * SDKs emit the bare form; neither may ever decode to a space. + */ @Test - public void testConvertNexusToNexusOperation_WrongType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToNexusOperation(input)); + public void decodesBothSpellingsOfPlusInPathAsPlus() { + Link expected = + eventRef("ns", "a+b", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED); + for (String segment : new String[] {"a+b", "a%2Bb"}) { + assertDecodes( + expected, + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/" + + segment + + "/run-id/history?referenceType=EventReference&eventID=1" + + "&eventType=WorkflowExecutionStarted"); + } + } + + @Test + public void decodesPercentEncodedPathSegments() { + assertDecodes( + eventRef("ns", "wf id", "run-id", 1, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf%20id/run-id/history" + + "?referenceType=EventReference&eventID=1&eventType=WorkflowExecutionStarted"); + assertDecodes( + activity("ns", "act id", "run-id"), + ACTIVITY, + "temporal:///namespaces/ns/activities/act%20id/run-id/details"); + assertDecodes( + nexusOperation("ns", "op/id", "run-id"), + NEXUS_OPERATION, + "temporal:///namespaces/ns/nexus-operations/op%2Fid/run-id/details"); } + /** + * Query values are form-decoded, the opposite of path segments, so both spellings of a space must + * decode to a space. .NET emits the percent form. + */ @Test - public void testConvertNexusToNexusOperation_InvalidScheme() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("random:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertNull(nexusLinkToNexusOperation(input)); + public void decodesBothSpellingsOfSpaceInQueryValue() { + Link expected = workflow("ns", "wf-id", "run-id", "rejected update"); + for (String value : new String[] {"rejected+update", "rejected%20update"}) { + assertDecodes( + expected, WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=" + value); + } } + /** Values are read from the raw query, so a percent sign does not discard the link. */ @Test - public void testConvertNexusToNexusOperation_InvalidPathMissingDetails() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - assertNull(nexusLinkToNexusOperation(input)); + public void decodesPercentSignInQueryValue() { + assertDecodes( + requestIdRef( + "ns", "wf-id", "run-id", "100%", EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED), + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=RequestIdReference&requestID=100%25" + + "&eventType=WorkflowExecutionStarted"); } + /** An absent, empty, bare or similarly-named reason parameter all leave the proto default. */ @Test - public void testConvertActivityToNexus_Valid() { - Link.Activity input = - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act id/with+characters") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/activities/act%20id%2Fwith%2Bcharacters/run-id/details") - .setType("temporal.api.common.v1.Link.Activity") - .build(); - - assertEquals(expected, activityToNexusLink(input)); + public void decodesWorkflowLinkWithoutUsableReason() { + Link expected = workflow("ns", "wf-id", "run-id", ""); + String base = "temporal:///namespaces/ns/workflows/wf-id/run-id"; + assertDecodes(expected, WORKFLOW, base); + assertDecodes(expected, WORKFLOW, base + "?reason="); + assertDecodes(expected, WORKFLOW, base + "?reason"); + assertDecodes(expected, WORKFLOW, base + "?reasonable=yes"); } - @Test - public void testConvertNexusToActivity_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/activities/act%20id%2Fwith%2Bcharacters/run-id/details") - .setType("temporal.api.common.v1.Link.Activity") - .build(); - - Link expected = - Link.newBuilder() - .setActivity( - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act id/with+characters") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToActivity(input)); - } + // =============================================================================================== + // Rejection. + // =============================================================================================== @Test - public void testConvertNexusToActivity_InvalidPath() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/activities/act-id/run-id") - .setType("temporal.api.common.v1.Link.Activity") - .build(); - - assertNull(nexusLinkToActivity(input)); + public void rejectsWrongScheme() { + assertRejected(WORKFLOW_EVENT, "https:///namespaces/ns/workflows/wf-id/run-id/history"); + assertRejected(WORKFLOW, "https:///namespaces/ns/workflows/wf-id/run-id"); + assertRejected(NEXUS_OPERATION, "https:///namespaces/ns/nexus-operations/op/run-id/details"); } + /** A workflow link ends at the run ID; a workflow-event link ends at {@code /history}. */ @Test - public void testNexusLinkToLink_WorkflowEventRoundTrip() { - Link.WorkflowEvent we = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link nexusLink = workflowEventToNexusLink(we); - assertEquals("temporal.api.common.v1.Link.WorkflowEvent", nexusLink.getType()); - - Link converted = nexusLinkToLink(nexusLink); - assertNotNull(converted); - assertEquals(Link.newBuilder().setWorkflowEvent(we).build(), converted); + public void rejectsMismatchedWorkflowPathShapes() { + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/history"); + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id"); } @Test - public void testNexusLinkToLink_NexusOperation() { - io.temporal.api.nexus.v1.Link nexusLink = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/nexus-operations/op-id/run-id/details") - .setType("temporal.api.common.v1.Link.NexusOperation") - .build(); - - Link expected = - Link.newBuilder() - .setNexusOperation( - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToLink(nexusLink)); + public void rejectsTrailingPathSegment() { + assertRejected( + WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/extra"); + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/extra"); + // A trailing slash is an empty extra segment, not a no-op. + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows/wf-id/run-id/history/"); + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id/run-id/"); } + /** A repeated key takes its first occurrence, as api-go does. No encoder emits one. */ @Test - public void testNexusLinkToLink_ActivityRoundTrip() { - Link.Activity activity = - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link nexusLink = activityToNexusLink(activity); - assertEquals(Link.newBuilder().setActivity(activity).build(), nexusLinkToLink(nexusLink)); + public void decodesFirstOccurrenceOfARepeatedQueryKey() { + assertDecodes( + workflow("ns", "wf-id", "run-id", "first"), + WORKFLOW, + "temporal:///namespaces/ns/workflows/wf-id/run-id?reason=first&reason=second"); } @Test - public void testNexusLinkToLink_UnknownType() { - io.temporal.api.nexus.v1.Link nexusLink = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id/history") - .setType("unknown.type") - .build(); - - assertNull(nexusLinkToLink(nexusLink)); + public void rejectsMissingPathSegment() { + assertRejected(WORKFLOW, "temporal:///namespaces/ns/workflows/wf-id"); + assertRejected(NEXUS_OPERATION, "temporal:///namespaces/ns/nexus-operations/op-id/run-id"); + assertRejected(ACTIVITY, "temporal:///namespaces/ns/activities/act-id/run-id"); } @Test - public void testLinkToNexusLink_WorkflowEvent() { - Link.WorkflowEvent we = - Link.WorkflowEvent.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setEventRef( - Link.WorkflowEvent.EventReference.newBuilder() - .setEventId(1) - .setEventType(EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED)) - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setWorkflowEvent(we).build()); - assertEquals(workflowEventToNexusLink(we), actual); + public void rejectsEmptyPathSegment() { + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces//workflows/wf-id/run-id/history"); + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/workflows//run-id/history"); } @Test - public void testLinkToNexusLink_NexusOperation() { - Link.NexusOperation no = - Link.NexusOperation.newBuilder() - .setNamespace("ns") - .setOperationId("op-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setNexusOperation(no).build()); - assertEquals(nexusOperationToNexusLink(no), actual); + public void rejectsWrongKindSegment() { + assertRejected(WORKFLOW_EVENT, "temporal:///namespaces/ns/activities/wf-id/run-id/history"); } + /** The declared type is authoritative on every decoder, not just the dispatcher. */ @Test - public void testLinkToNexusLink_Activity() { - Link.Activity activity = - Link.Activity.newBuilder() - .setNamespace("ns") - .setActivityId("act-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setActivity(activity).build()); - assertEquals(activityToNexusLink(activity), actual); + public void rejectsTypeThatDoesNotMatchThePath() { + String workflowEventUrl = "temporal:///namespaces/ns/workflows/wf-id/run-id/history"; + assertRejected(ACTIVITY, workflowEventUrl); + assertNull(nexusLinkToWorkflowEvent(nexusLink(WORKFLOW, workflowEventUrl))); + assertNull(nexusLinkToWorkflowLink(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); + assertNull(nexusLinkToActivity(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); + assertNull(nexusLinkToNexusOperation(nexusLink(WORKFLOW_EVENT, workflowEventUrl))); } @Test - public void testLinkToNexusLink_Empty() { - assertNull(linkToNexusLink(Link.newBuilder().build())); + public void rejectsUnknownLinkType() { + assertRejected( + "temporal.api.common.v1.Link.NotAVariant", + "temporal:///namespaces/ns/workflows/wf-id/run-id/history"); } @Test - public void testConvertWorkflowToNexus_Valid() { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals(expected, workflowLinkToNexusLink(input)); + public void rejectsMissingOrUnknownReferenceType() { + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?eventID=1&eventType=WorkflowExecutionStarted"); + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=NotAReference&eventType=WorkflowExecutionStarted"); } @Test - public void testConvertWorkflowToNexus_ValidReason() { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setReason("rejected update") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals(expected, workflowLinkToNexusLink(input)); + public void rejectsUnparseableEventTypeOrEventId() { + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventType=NotAnEventType"); + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=nope&eventType=WorkflowExecutionStarted"); + assertRejected( + WORKFLOW_EVENT, + "temporal:///namespaces/ns/workflows/wf-id/run-id/history" + + "?referenceType=EventReference&eventID=99999999999999999999" + + "&eventType=WorkflowExecutionStarted"); } + /** A malformed link is dropped, never thrown, so it cannot fail the call carrying it. */ @Test - public void testConvertWorkflowToNexus_ValidSlash() { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf/id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf%2Fid/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals(expected, workflowLinkToNexusLink(input)); + public void rejectsMalformedUrlsWithoutThrowing() { + for (String url : + new String[] {"", "not a uri at all", "%%%", "temporal:///", "temporal:///namespaces"}) { + assertRejected(WORKFLOW_EVENT, url); + } } - @Test - public void testConvertWorkflowToNexus_ValidSpace() throws UnsupportedEncodingException { - Link.Workflow input = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf id") - .setRunId("run-id") - .build(); - - io.temporal.api.nexus.v1.Link expected = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf%20id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - io.temporal.api.nexus.v1.Link actual = workflowLinkToNexusLink(input); - assertEquals(expected, actual); - // A space in the path has to survive as %20 rather than the '+' that form encoding would - // produce, otherwise the link resolves to a different workflow ID. - assertEquals( - "temporal:///namespaces/ns/workflows/wf id/run-id", - URLDecoder.decode(actual.getUrl(), StandardCharsets.UTF_8.toString())); - } + // =============================================================================================== + // Dispatch. + // =============================================================================================== @Test - public void testConvertNexusToWorkflow_Valid() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - Link expected = - Link.newBuilder() - .setWorkflow( - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id")) - .build(); - - assertEquals(expected, nexusLinkToWorkflowLink(input)); + public void dispatchCoversAllFourLinkTypes() { + assertNotNull(linkToNexusLink(eventRef("ns", "w", "r", 1, EventType.EVENT_TYPE_TIMER_STARTED))); + assertNotNull(linkToNexusLink(workflow("ns", "w", "r", ""))); + assertNotNull(linkToNexusLink(nexusOperation("ns", "o", "r"))); + assertNotNull(linkToNexusLink(activity("ns", "a", "r"))); } + /** + * An event type this SDK's protos do not know (a newer server sending a higher enum number) + * arrives as {@code UNRECOGNIZED}, which has no {@code EVENT_TYPE_} prefix to strip. Encoding it + * must drop the link rather than throw out of the Nexus call carrying it. + */ @Test - public void testConvertNexusToWorkflow_ValidReason() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=rejected+update") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - Link expected = + public void unknownEventTypeEncodesToNullRatherThanThrowing() { + Link link = Link.newBuilder() - .setWorkflow( - Link.Workflow.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() .setNamespace("ns") .setWorkflowId("wf-id") .setRunId("run-id") - .setReason("rejected update")) - .build(); - - assertEquals(expected, nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_WrongType() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.WorkflowEvent") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_InvalidScheme() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("random:///namespaces/ns/workflows/wf-id/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_InvalidPathTrailingSegment() { - // The workflow-event form addresses an event inside the workflow, so it must not be accepted - // as a workflow link even when the type says otherwise. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id/history") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testConvertNexusToWorkflow_ReasonNotFirstQueryParam() { - // The reason is located by key, not by position, so unrelated params ahead of it are skipped. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl( - "temporal:///namespaces/ns/workflows/wf-id/run-id?foo=bar&reason=Query+processed") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("Query processed", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_EmptyReasonValue() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason=") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_BareReasonKey() { - // A key with no '=' must not blow up on the missing value. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reason") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_ReasonPrefixKeyIgnored() { - // "reasonx" must not be treated as "reason". - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id/run-id?reasonx=nope") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("", nexusLinkToWorkflowLink(input).getWorkflow().getReason()); - } - - @Test - public void testConvertNexusToWorkflow_EmptyUrl() { - // A URL with no scheme must be reported as an invalid scheme rather than throwing. - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - /** - * A '+' in a path segment is a literal '+', not a space. Form decoding would turn it into a space - * and point at a different execution. - */ - @Test - public void testConvertNexusToWorkflow_LiteralPlusInPathIsPreserved() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/a+b/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("a+b", nexusLinkToWorkflowLink(input).getWorkflow().getWorkflowId()); - - // A percent-escaped space still decodes to a space. - io.temporal.api.nexus.v1.Link spaceInput = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/a%20b/run-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertEquals("a b", nexusLinkToWorkflowLink(spaceInput).getWorkflow().getWorkflowId()); - - // A '+' this SDK encoded itself does survive, because URLEncoder emits %2B. - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("a+b") - .setRunId("run-id") - .build(); - assertEquals( - Link.newBuilder().setWorkflow(w).build(), - nexusLinkToWorkflowLink(workflowLinkToNexusLink(w))); - } - - @Test - public void testConvertNexusToWorkflow_InvalidPathMissingRunID() { - io.temporal.api.nexus.v1.Link input = - io.temporal.api.nexus.v1.Link.newBuilder() - .setUrl("temporal:///namespaces/ns/workflows/wf-id") - .setType("temporal.api.common.v1.Link.Workflow") - .build(); - - assertNull(nexusLinkToWorkflowLink(input)); - } - - @Test - public void testWorkflowLinkRoundTrip() { - // Reserved characters in every field at once: the path segments are percent-escaped and the - // reason is form-encoded, so a reason containing '=' and '&' must not be split as query syntax. - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns/with/slash") - .setWorkflowId("wf id with space") - .setRunId("run-id") - .setReason("reason with = and &") + .setEventRef( + Link.WorkflowEvent.EventReference.newBuilder() + .setEventId(1) + .setEventTypeValue(99999))) .build(); - - io.temporal.api.nexus.v1.Link nexusLink = workflowLinkToNexusLink(w); - assertEquals("temporal.api.common.v1.Link.Workflow", nexusLink.getType()); - assertEquals(Link.newBuilder().setWorkflow(w).build(), nexusLinkToWorkflowLink(nexusLink)); + try { + assertNull(linkToNexusLink(link)); + } catch (RuntimeException e) { + fail("must not throw: " + e); + } } @Test - public void testLinkToNexusLink_Workflow() { - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setReason("Query processed") - .build(); - - io.temporal.api.nexus.v1.Link actual = - linkToNexusLink(Link.newBuilder().setWorkflow(w).build()); - assertEquals(workflowLinkToNexusLink(w), actual); + public void unsetVariantEncodesToNull() { + assertNull(linkToNexusLink(Link.newBuilder().build())); } - @Test - public void testNexusLinkToLink_WorkflowRoundTrip() { - Link.Workflow w = - Link.Workflow.newBuilder() - .setNamespace("ns") - .setWorkflowId("wf-id") - .setRunId("run-id") - .setReason("Query processed") - .build(); - - io.temporal.api.nexus.v1.Link nexusLink = workflowLinkToNexusLink(w); - Link converted = nexusLinkToLink(nexusLink); - assertNotNull(converted); - assertEquals(Link.newBuilder().setWorkflow(w).build(), converted); + /** A batch-job link is a real proto variant that no SDK converts. */ + @Test + public void batchJobVariantEncodesToNull() { + assertNull( + linkToNexusLink( + Link.newBuilder().setBatchJob(Link.BatchJob.newBuilder().setJobId("job")).build())); + } + + // =============================================================================================== + // Helpers. + // =============================================================================================== + + /** Asserts the encoded URL and type, then that the link decodes back to exactly the input. */ + private static void assertEncodes(String expectedUrl, String expectedType, Link input) { + io.temporal.api.nexus.v1.Link actual = linkToNexusLink(input); + assertNotNull("encoding returned null", actual); + assertEquals("type", expectedType, actual.getType()); + assertEquals("url", expectedUrl, actual.getUrl()); + assertEquals("round trip", input, nexusLinkToLink(actual)); + } + + private static void assertDecodes(Link expected, String type, String url) { + Link actual = nexusLinkToLink(nexusLink(type, url)); + assertNotNull("decoding returned null for " + url, actual); + assertEquals(url, expected, actual); + } + + private static void assertRejected(String type, String url) { + try { + assertNull("expected rejection of " + url, nexusLinkToLink(nexusLink(type, url))); + } catch (RuntimeException e) { + fail("expected rejection, but threw, for " + url + ": " + e); + } + } + + private static io.temporal.api.nexus.v1.Link nexusLink(String type, String url) { + return io.temporal.api.nexus.v1.Link.newBuilder().setUrl(url).setType(type).build(); + } + + private static Link eventRef(String ns, String wfId, String runId, long eventId, EventType t) { + Link.WorkflowEvent.EventReference.Builder ref = + Link.WorkflowEvent.EventReference.newBuilder().setEventType(t); + if (eventId != 0) { + ref.setEventId(eventId); + } + return Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(ns) + .setWorkflowId(wfId) + .setRunId(runId) + .setEventRef(ref)) + .build(); + } + + private static Link requestIdRef( + String ns, String wfId, String runId, String requestId, EventType t) { + return Link.newBuilder() + .setWorkflowEvent( + Link.WorkflowEvent.newBuilder() + .setNamespace(ns) + .setWorkflowId(wfId) + .setRunId(runId) + .setRequestIdRef( + Link.WorkflowEvent.RequestIdReference.newBuilder() + .setRequestId(requestId) + .setEventType(t))) + .build(); + } + + private static Link workflow(String ns, String wfId, String runId, String reason) { + return Link.newBuilder() + .setWorkflow( + Link.Workflow.newBuilder() + .setNamespace(ns) + .setWorkflowId(wfId) + .setRunId(runId) + .setReason(reason)) + .build(); + } + + private static Link nexusOperation(String ns, String opId, String runId) { + return Link.newBuilder() + .setNexusOperation( + Link.NexusOperation.newBuilder().setNamespace(ns).setOperationId(opId).setRunId(runId)) + .build(); + } + + private static Link activity(String ns, String actId, String runId) { + return Link.newBuilder() + .setActivity( + Link.Activity.newBuilder().setNamespace(ns).setActivityId(actId).setRunId(runId)) + .build(); } } From 6d6b7434b725d6f9c6d07456c24a72ba076d0e4b Mon Sep 17 00:00:00 2001 From: Sean Bollin Date: Tue, 15 Sep 2026 14:24:19 -0700 Subject: [PATCH 102/107] Move Cloud Run OpenTelemetry plugin to temporal-gcp-cloud-run-opentelemetry (#3064) Rename the contrib/temporal-gcp-cloud-run module to contrib/temporal-gcp-cloud-run-opentelemetry and nest its source under the io.temporal.gcp.cloudrun.opentelemetry package, so the OpenTelemetry plugin sits alongside the sibling temporal-gcp-cloud-run-worker-id module. Update settings.gradle, the BOM, and the README to the new coordinates. Co-authored-by: Claude Opus 4.8 --- .../README.md | 4 ++-- .../build.gradle | 0 .../cloudrun/opentelemetry}/CloudRunOpenTelemetryPlugin.java | 2 +- .../opentelemetry}/CloudRunOpenTelemetryPluginTest.java | 2 +- settings.gradle | 4 ++-- temporal-bom/build.gradle | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) rename contrib/{temporal-gcp-cloud-run => temporal-gcp-cloud-run-opentelemetry}/README.md (96%) rename contrib/{temporal-gcp-cloud-run => temporal-gcp-cloud-run-opentelemetry}/build.gradle (100%) rename contrib/{temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun => temporal-gcp-cloud-run-opentelemetry/src/main/java/io/temporal/gcp/cloudrun/opentelemetry}/CloudRunOpenTelemetryPlugin.java (99%) rename contrib/{temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun => temporal-gcp-cloud-run-opentelemetry/src/test/java/io/temporal/gcp/cloudrun/opentelemetry}/CloudRunOpenTelemetryPluginTest.java (99%) diff --git a/contrib/temporal-gcp-cloud-run/README.md b/contrib/temporal-gcp-cloud-run-opentelemetry/README.md similarity index 96% rename from contrib/temporal-gcp-cloud-run/README.md rename to contrib/temporal-gcp-cloud-run-opentelemetry/README.md index 01a6c15184..88791121ee 100644 --- a/contrib/temporal-gcp-cloud-run/README.md +++ b/contrib/temporal-gcp-cloud-run-opentelemetry/README.md @@ -1,4 +1,4 @@ -# Temporal Google Cloud Run module +# Temporal Google Cloud Run OpenTelemetry module This module provides an OpenTelemetry plugin with defaults for Temporal Java SDK workers running on Google Cloud Run. Cloud Run worker pools are the recommended deployment because Temporal workers are continuous, pull-based background workloads. @@ -10,7 +10,7 @@ A Cloud Run service can also host a Temporal worker, but it must use instance-ba ## Usage -Add `temporal-gcp-cloud-run` next to your Temporal SDK dependency, then install the plugin on service stubs options before creating clients and workers: +Add `temporal-gcp-cloud-run-opentelemetry` next to your Temporal SDK dependency, then install the plugin on service stubs options before creating clients and workers: ```java CloudRunOpenTelemetryPlugin plugin = CloudRunOpenTelemetryPlugin.newBuilder().build(); diff --git a/contrib/temporal-gcp-cloud-run/build.gradle b/contrib/temporal-gcp-cloud-run-opentelemetry/build.gradle similarity index 100% rename from contrib/temporal-gcp-cloud-run/build.gradle rename to contrib/temporal-gcp-cloud-run-opentelemetry/build.gradle diff --git a/contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java b/contrib/temporal-gcp-cloud-run-opentelemetry/src/main/java/io/temporal/gcp/cloudrun/opentelemetry/CloudRunOpenTelemetryPlugin.java similarity index 99% rename from contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java rename to contrib/temporal-gcp-cloud-run-opentelemetry/src/main/java/io/temporal/gcp/cloudrun/opentelemetry/CloudRunOpenTelemetryPlugin.java index a985e66326..fb69fb9d74 100644 --- a/contrib/temporal-gcp-cloud-run/src/main/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPlugin.java +++ b/contrib/temporal-gcp-cloud-run-opentelemetry/src/main/java/io/temporal/gcp/cloudrun/opentelemetry/CloudRunOpenTelemetryPlugin.java @@ -1,4 +1,4 @@ -package io.temporal.gcp.cloudrun; +package io.temporal.gcp.cloudrun.opentelemetry; import io.opentelemetry.api.OpenTelemetry; import io.temporal.client.WorkflowClientOptions; diff --git a/contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java b/contrib/temporal-gcp-cloud-run-opentelemetry/src/test/java/io/temporal/gcp/cloudrun/opentelemetry/CloudRunOpenTelemetryPluginTest.java similarity index 99% rename from contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java rename to contrib/temporal-gcp-cloud-run-opentelemetry/src/test/java/io/temporal/gcp/cloudrun/opentelemetry/CloudRunOpenTelemetryPluginTest.java index 3be4529168..f6028c0449 100644 --- a/contrib/temporal-gcp-cloud-run/src/test/java/io/temporal/gcp/cloudrun/CloudRunOpenTelemetryPluginTest.java +++ b/contrib/temporal-gcp-cloud-run-opentelemetry/src/test/java/io/temporal/gcp/cloudrun/opentelemetry/CloudRunOpenTelemetryPluginTest.java @@ -1,4 +1,4 @@ -package io.temporal.gcp.cloudrun; +package io.temporal.gcp.cloudrun.opentelemetry; import static org.junit.Assert.*; diff --git a/settings.gradle b/settings.gradle index 7ec1835ef7..61efe8636f 100644 --- a/settings.gradle +++ b/settings.gradle @@ -15,8 +15,8 @@ include 'temporal-workflowstreams' project(':temporal-workflowstreams').projectDir = file('contrib/temporal-workflowstreams') include 'temporal-aws-lambda' project(':temporal-aws-lambda').projectDir = file('contrib/temporal-aws-lambda') -include 'temporal-gcp-cloud-run' -project(':temporal-gcp-cloud-run').projectDir = file('contrib/temporal-gcp-cloud-run') +include 'temporal-gcp-cloud-run-opentelemetry' +project(':temporal-gcp-cloud-run-opentelemetry').projectDir = file('contrib/temporal-gcp-cloud-run-opentelemetry') include 'temporal-payload-storage-s3driver' project(':temporal-payload-storage-s3driver').projectDir = file('contrib/temporal-payload-storage-s3driver') include 'temporal-payload-storage-s3driver-awssdkv2' diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index c79c4df44a..66f98856b6 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -10,7 +10,7 @@ dependencies { api project(':temporal-opentelemetry') api project(':temporal-opentracing') api project(':temporal-aws-lambda') - api project(':temporal-gcp-cloud-run') + api project(':temporal-gcp-cloud-run-opentelemetry') api project(':temporal-remote-data-encoder') api project(':temporal-sdk') api project(':temporal-serviceclient') From 5c9599df0ba8585b4439c465cc3084f607f591fb Mon Sep 17 00:00:00 2001 From: Quinn Klassen Date: Tue, 15 Sep 2026 15:00:25 -0700 Subject: [PATCH 103/107] Account for dynamic workflows when setting WorkflowImplementationOptions (#3042) --- .../POJOWorkflowImplementationFactory.java | 9 +++- .../workflow/DynamicWorkflowTest.java | 46 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/POJOWorkflowImplementationFactory.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/POJOWorkflowImplementationFactory.java index 60003de893..fb20638296 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/POJOWorkflowImplementationFactory.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/POJOWorkflowImplementationFactory.java @@ -77,6 +77,8 @@ public final class POJOWorkflowImplementationFactory implements ReplayWorkflowFa private Functions.Func1 dynamicWorkflowImplementationFactory; + @Nullable private WorkflowImplementationOptions dynamicWorkflowImplementationOptions; + private final Map implementationOptions = Collections.synchronizedMap(new HashMap<>()); @@ -136,6 +138,7 @@ public void addWorkflowImplementationFactory( } dynamicWorkflowImplementationFactory = (Functions.Func1) factory; + dynamicWorkflowImplementationOptions = options; return; } workflowInstanceFactories.put(clazz, factory); @@ -213,6 +216,7 @@ private void registerWorkflowImplementationType( } } }; + dynamicWorkflowImplementationOptions = options; return; } catch (NoSuchMethodException e) { throw new IllegalArgumentException( @@ -285,8 +289,11 @@ private SyncWorkflowDefinition getWorkflowDefinition( public ReplayWorkflow getWorkflow( WorkflowType workflowType, WorkflowExecution workflowExecution) { SyncWorkflowDefinition workflow = getWorkflowDefinition(workflowType, workflowExecution); + boolean isDynamicWorkflow = !workflowDefinitions.containsKey(workflowType.getName()); WorkflowImplementationOptions workflowImplementationOptions = - implementationOptions.get(workflowType.getName()); + isDynamicWorkflow + ? dynamicWorkflowImplementationOptions + : implementationOptions.get(workflowType.getName()); DataConverter dataConverterWithWorkflowContext = dataConverter.withContext( new WorkflowSerializationContext(namespace, workflowExecution.getWorkflowId())); diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/DynamicWorkflowTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/DynamicWorkflowTest.java index 2cb0d39ec3..34e770b2f8 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/DynamicWorkflowTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/DynamicWorkflowTest.java @@ -1,7 +1,6 @@ package io.temporal.workflow; import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; import io.temporal.activity.Activity; import io.temporal.activity.ActivityOptions; @@ -14,6 +13,7 @@ import io.temporal.failure.ApplicationFailure; import io.temporal.testing.TestWorkflowEnvironment; import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkflowImplementationOptions; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -74,6 +74,13 @@ public Object execute(EncodedValues args) { } } + public static class FailingDynamicWorkflowImpl implements DynamicWorkflow { + @Override + public Object execute(EncodedValues args) { + throw new NullPointerException("simulated"); + } + } + @Test public void testDynamicWorkflow() { TestWorkflowEnvironment testEnvironment = testWorkflowRule.getTestEnvironment(); @@ -124,4 +131,41 @@ public void testDynamicWorkflowFailure() { workflow.start("startArg0", true /* fail */); workflow.getResult(String.class); } + + @Test(expected = WorkflowFailedException.class) + public void testDynamicWorkflowFailureTypes() { + TestWorkflowEnvironment testEnvironment = testWorkflowRule.getTestEnvironment(); + testEnvironment + .getWorkerFactory() + .getWorker(testWorkflowRule.getTaskQueue()) + .registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setFailWorkflowExceptionTypes(NullPointerException.class) + .build(), + FailingDynamicWorkflowImpl.class); + testEnvironment.start(); + + WorkflowStub workflow = testWorkflowRule.newUntypedWorkflowStub("workflowFoo"); + workflow.start(); + workflow.getResult(String.class); + } + + @Test(expected = WorkflowFailedException.class) + public void testDynamicWorkflowFactoryFailureTypes() { + TestWorkflowEnvironment testEnvironment = testWorkflowRule.getTestEnvironment(); + testEnvironment + .getWorkerFactory() + .getWorker(testWorkflowRule.getTaskQueue()) + .registerWorkflowImplementationFactory( + FailingDynamicWorkflowImpl.class, + FailingDynamicWorkflowImpl::new, + WorkflowImplementationOptions.newBuilder() + .setFailWorkflowExceptionTypes(NullPointerException.class) + .build()); + testEnvironment.start(); + + WorkflowStub workflow = testWorkflowRule.newUntypedWorkflowStub("workflowFoo"); + workflow.start(); + workflow.getResult(String.class); + } } From 827a5b84247d3c0bc7c5a414434443ddf7398203 Mon Sep 17 00:00:00 2001 From: Sean Bollin Date: Wed, 16 Sep 2026 13:01:48 -0700 Subject: [PATCH 104/107] Add Google Cloud Run CloudRunIdPlugin (#3028) * Add Google Cloud Run worker identity/deployment helper Adds an experimental Google Cloud Run helper, mirroring the existing AWS Lambda module's worker-ID behavior. Because Cloud Run runs a long-lived container (unlike Lambda's per-invocation model), this is a metadata helper rather than a worker wrapper: it reads the Cloud Run instance metadata -- the instance id from the metadata server, plus the worker pool/service name and revision from CLOUD_RUN_WORKER_POOL / CLOUD_RUN_REVISION (worker pools) or K_SERVICE / K_REVISION (services) -- and derives a worker identity and a WorkerDeploymentVersion to apply to a normal long-lived worker. Covers both Cloud Run worker pools and services. Co-Authored-By: Claude Opus 4.8 * Set worker versioning behavior to PINNED in the Cloud Run worker apply The worker-side apply helper enabled versioning and set the deployment version but left the default versioning behavior unset, so a versioned worker with a plain (un-annotated) workflow failed to register. Default it to PINNED; a per-workflow versioning behavior still takes precedence. Co-Authored-By: Claude Opus 4.8 * Add unit tests for GoogleCloudRunMetadata Cover the Cloud Run metadata helper: environment-variable precedence (CLOUD_RUN_WORKER_POOL over K_SERVICE, CLOUD_RUN_REVISION over K_REVISION), worker identity fallbacks, WorkerDeploymentVersion mapping and its empty name/revision error, the metadata HTTP request (Metadata-Flavor: Google header, body trimming, non-200 and unreachable errors), and the applyTo(...) methods (client identity, and PINNED worker deployment versioning). The metadata request is served by an in-process com.sun.net.httpserver HttpServer and the environment lookup is injected through a new package-private fetch(String, Duration, Function) test seam, so the tests touch neither the network nor the real process environment. The seam is not part of the public API and does not change public behavior. Add the matching testImplementation dependencies (temporal-sdk, junit) to the module, mirroring the temporal-aws-lambda module. Co-Authored-By: Claude Opus 4.8 * Avoid deprecated URL(String) constructor in GoogleCloudRunMetadata new URL(String) is deprecated since Java 20 and fails the SDK's -Werror build on newer JDKs (the Java 23 "Edge" CI job). Use URI.create(...).toURL() instead, the recommended non-deprecated replacement (MalformedURLException is still an IOException and stays caught). Co-Authored-By: Claude Opus 4.8 * Re-architect Cloud Run worker-ID helper into a CloudRunPlugin Add CloudRunPlugin (extends io.temporal.common.SimplePlugin), mirroring the module's CloudRunOpenTelemetryPlugin. Registering it on the workflow client fetches Cloud Run instance metadata once at client-configure time, caches it, sets the client identity from the derived worker identity when one is not already set, and sets each worker's deployment version with worker versioning enabled and a PINNED default behavior. It fails fast with an IllegalStateException when run off Cloud Run. GoogleCloudRunMetadata keeps fetch() and its accessors but drops the two applyTo(...) overloads, whose logic now lives in the plugin hooks. Adds CloudRunPluginTest (identity set only when unset, PINNED worker deployment, off-platform fail-fast, fetch-once caching, injected metadata) and updates the README to lead with the plugin. A package-private Supplier constructor is the test seam, reusing the existing fetch(url, timeout, getenv) seam. Co-Authored-By: Claude Opus 4.8 * Rename CloudRunPlugin to WorkerIdPlugin Cloud Run can host multiple Temporal plugins (a worker-ID plugin and an OpenTelemetry plugin) in the same module, so the worker-ID plugin must not claim the generic CloudRunPlugin name. Rename the class and file to WorkerIdPlugin, change the NAME id to io.temporal.gcp.cloudrun.workerid so it does not collide under duplicate detection, and update the test (WorkerIdPluginTest), the README, and the GoogleCloudRunMetadata doc link. The io.temporal.gcp.cloudrun package and GoogleCloudRunMetadata are unchanged. Co-Authored-By: Claude Opus 4.8 * Make Cloud Run WorkerID plugin identity-only The plugin now sets only the worker identity from Cloud Run metadata and no longer participates in Worker Deployment Versioning. - WorkerIdPlugin: remove the configureWorker override (it set WorkerDeploymentOptions with UseVersioning(true)/Version/PINNED default); drop the now-unused WorkerDeploymentOptions, VersioningBehavior, and WorkerOptions imports. Workers inherit the client identity. - GoogleCloudRunMetadata: remove workerDeploymentVersion() and the WorkerDeploymentVersion import. Keep instanceId/name/revision + workerIdentity(). - Tests: drop the deployment-version / pinned-versioning assertions; the fetched-once test now exercises the client hook alone. - README: reword to identity-only (no PINNED, no deployment version). Co-Authored-By: Claude Opus 4.8 * Remove deployment-name/build-ID wording from Cloud Run worker-ID docs The plugin sets only the worker identity, so describe the Cloud Run metadata as the worker pool/service name and revision rather than a Temporal deployment name and build ID. Co-Authored-By: Claude Opus 4.8 * Reflow worker-ID javadoc to satisfy spotless The doc-wording edits (and the earlier identity-only edit) left some javadoc lines wrapped differently than google-java-format 1.24.0 expects. Reformatted with that exact formatter version so spotlessCheck passes. Comment-only. Co-Authored-By: Claude Opus 4.8 * Simplify worker identity docs and comments Trim negative-contrast framing and editorializing so the comments and docs describe only the worker identity functionality, per review feedback. Co-Authored-By: Claude Opus 4.8 * Cleaning up Claude Code-isms * Rename plugin to CloudRunIDPlugin The plugin sets the client identity (which in turn sets the worker identity), and the new name carries the Cloud Run context without relying on the full package path. Co-Authored-By: Claude Opus 4.8 * Rename package leaf to id and metadata accessor to identity Renames the Gradle module to temporal-gcp-cloud-run-id, the package to io.temporal.gcp.cloudrun.id, and GoogleCloudRunMetadata.workerIdentity() to .identity(). Co-Authored-By: Claude Opus 4.8 * Move metadata-constructor Javadoc to that overload and reformat Moves the advanced/testing note from the class Javadoc onto the CloudRunIDPlugin(GoogleCloudRunMetadata) constructor per review, and applies google-java-format so spotlessCheck passes after the rename. Co-Authored-By: Claude Opus 4.8 * Make the metadata-injection constructor package-private Per review, CloudRunIDPlugin(GoogleCloudRunMetadata) is a test seam, so it is now package-private (tests share its package). The public GoogleCloudRunMetadata.fetch() direct-read API stays public. Co-Authored-By: Claude Opus 4.8 * Rename CloudRunIDPlugin to CloudRunIdPlugin Uses lowercase Id (CloudRunId) per repo naming convention, matching the .NET review feedback and applied across all SDKs. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- contrib/temporal-gcp-cloud-run-id/README.md | 83 +++++++ .../temporal-gcp-cloud-run-id/build.gradle | 12 + .../gcp/cloudrun/id/CloudRunIdPlugin.java | 120 ++++++++++ .../cloudrun/id/GoogleCloudRunMetadata.java | 210 ++++++++++++++++++ .../gcp/cloudrun/id/CloudRunIdPluginTest.java | 158 +++++++++++++ .../id/GoogleCloudRunMetadataTest.java | 197 ++++++++++++++++ settings.gradle | 2 + temporal-bom/build.gradle | 1 + 8 files changed, 783 insertions(+) create mode 100644 contrib/temporal-gcp-cloud-run-id/README.md create mode 100644 contrib/temporal-gcp-cloud-run-id/build.gradle create mode 100644 contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/CloudRunIdPlugin.java create mode 100644 contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadata.java create mode 100644 contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/CloudRunIdPluginTest.java create mode 100644 contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadataTest.java diff --git a/contrib/temporal-gcp-cloud-run-id/README.md b/contrib/temporal-gcp-cloud-run-id/README.md new file mode 100644 index 0000000000..7505e7fd84 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run-id/README.md @@ -0,0 +1,83 @@ +# Temporal Google Cloud Run worker identity support + +This module derives a Temporal worker **identity** for Google Cloud Run from instance metadata, for both Cloud Run **worker pools** and Cloud Run **services**. + +The primary API is `CloudRunIdPlugin`. Register it once on your workflow client and it sets the client identity automatically; every worker created from that client inherits it. This mirrors the `CloudRunOpenTelemetryPlugin` in the companion `temporal-gcp-cloud-run-opentelemetry` module. + +> Experimental: Google Cloud Run support is experimental and may change without notice. + +## Quick start + +Add `temporal-gcp-cloud-run-id` next to your Temporal SDK dependency, then register the plugin on the workflow client options: + +```java +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.gcp.cloudrun.id.CloudRunIdPlugin; +import io.temporal.serviceclient.WorkflowServiceStubs; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import io.temporal.worker.Worker; +import io.temporal.worker.WorkerFactory; + +public final class Main { + public static void main(String[] args) { + WorkflowServiceStubs service = + WorkflowServiceStubs.newServiceStubs( + WorkflowServiceStubsOptions.newBuilder() + .setTarget("my-namespace.tmprl.cloud:7233") + .build()); + + // Registering the plugin on the client: + // - reads Cloud Run instance metadata once while the client is configured, and + // - sets the client identity to the derived worker identity (unless you set one yourself). + WorkflowClient client = + WorkflowClient.newInstance( + service, + WorkflowClientOptions.newBuilder() + .setNamespace("my-namespace") + .setPlugins(new CloudRunIdPlugin()) + .build()); + + WorkerFactory factory = WorkerFactory.newInstance(client); + + // Workers created from this client inherit the identity the plugin set on the client. No + // per-worker wiring needed. + Worker worker = factory.newWorker("orders"); + worker.registerWorkflowImplementationTypes(OrderWorkflowImpl.class); + worker.registerActivitiesImplementations(new OrderActivitiesImpl()); + + factory.start(); + } +} +``` + +You can also register the plugin on `WorkflowServiceStubsOptions.Builder.setPlugins(...)`; from there it propagates to the client and workers as well. + +## How it works + +`CloudRunIdPlugin` reads Cloud Run instance metadata through `GoogleCloudRunMetadata`, which resolves three values: + +- **name**: the Cloud Run worker pool name — the first non-empty of `CLOUD_RUN_WORKER_POOL` (set on Cloud Run worker pools) then `K_SERVICE` (set on Cloud Run services). +- **revision**: the first non-empty of `CLOUD_RUN_REVISION` (worker pools) then `K_REVISION` (services). +- **instanceId**: read from the Cloud Run metadata server with a single HTTP `GET` to `http://metadata.google.internal/computeMetadata/v1/instance/id` with the required `Metadata-Flavor: Google` header. The metadata server is available on both worker pools and services. + +Worker pools receive `CLOUD_RUN_WORKER_POOL` and `CLOUD_RUN_REVISION` and no `K_*` variables, while services receive `K_SERVICE` and `K_REVISION`, so resolving each value from the worker-pool variable first and the service variable second supports both. + +The plugin then applies the metadata through the SDK's client plugin hook: + +- **Client** (`configureWorkflowClient`): sets the client identity to `@` (falling back to `@` and then the bare ``), but only when you have not already set an identity, so a user-provided identity always wins. The metadata is fetched here, once, and cached. Workers created from the client inherit this identity; the plugin sets nothing else on them. + +The metadata server is only reachable from a Cloud Run instance, so the fetch in `configureWorkflowClient` throws `IllegalStateException` when it cannot be reached (usually because the process is not running on Google Cloud Run). + +## Reading the metadata directly + +If you prefer to read the values yourself, use `GoogleCloudRunMetadata` directly: + +```java +GoogleCloudRunMetadata metadata = GoogleCloudRunMetadata.fetch(); +String identity = metadata.identity(); +``` + +`GoogleCloudRunMetadata.fetch(String metadataUrl, Duration timeout)` overrides the metadata URL or the request timeout. + +This module depends only on the Temporal SDK at compile time and uses the JDK's `HttpURLConnection` for the metadata request, so it adds no additional runtime dependencies. diff --git a/contrib/temporal-gcp-cloud-run-id/build.gradle b/contrib/temporal-gcp-cloud-run-id/build.gradle new file mode 100644 index 0000000000..f93f338ea2 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run-id/build.gradle @@ -0,0 +1,12 @@ +description = '''Temporal Java SDK Google Cloud Run Worker Identity Support Module''' + +dependencies { + // This module shouldn't carry temporal-sdk with it, especially for situations when users may + // be using a shaded artifact. + compileOnly project(':temporal-sdk') + + testImplementation project(':temporal-sdk') + testImplementation "junit:junit:${junitVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} diff --git a/contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/CloudRunIdPlugin.java b/contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/CloudRunIdPlugin.java new file mode 100644 index 0000000000..b368e7f9c1 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/CloudRunIdPlugin.java @@ -0,0 +1,120 @@ +package io.temporal.gcp.cloudrun.id; + +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.Experimental; +import io.temporal.common.SimplePlugin; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Plugin that configures a Temporal worker for Google Cloud Run from instance metadata, for both + * Cloud Run worker pools and Cloud Run services. + * + *

Register the plugin once on the workflow client and it propagates to every worker created from + * that client. It reads {@link GoogleCloudRunMetadata Cloud Run instance metadata} once while the + * client is configured, caches it, and sets the workflow client identity to the {@linkplain + * GoogleCloudRunMetadata#identity() derived worker identity}, but only when the caller has not + * already set an identity (a user-provided identity always wins). The workers created from that + * client inherit the client identity. + * + *

The metadata is fetched lazily when the client is configured. The metadata server is only + * reachable from a Cloud Run instance, so the fetch throws {@link IllegalStateException} when this + * process is not running on Cloud Run. + * + *

Register the plugin with {@link WorkflowClientOptions.Builder#setPlugins}: + * + *

{@code
+ * WorkflowClient client =
+ *     WorkflowClient.newInstance(
+ *         service,
+ *         WorkflowClientOptions.newBuilder()
+ *             .setNamespace(namespace)
+ *             .setPlugins(new CloudRunIdPlugin())
+ *             .build());
+ *
+ * WorkerFactory factory = WorkerFactory.newInstance(client);
+ * Worker worker = factory.newWorker("my-task-queue");
+ * }
+ * + *

Experimental: Google Cloud Run support is experimental and may change without notice. + */ +@Experimental +public final class CloudRunIdPlugin extends SimplePlugin { + /** Unique plugin name, used for logging and duplicate detection. */ + public static final String NAME = "io.temporal.gcp.cloudrun.id.CloudRunIdPlugin"; + + private final Supplier metadataSupplier; + private volatile GoogleCloudRunMetadata metadata; + + /** + * Creates a plugin that fetches Cloud Run instance metadata from the {@linkplain + * GoogleCloudRunMetadata#DEFAULT_METADATA_URL default metadata server} while the workflow client + * is configured. + */ + public CloudRunIdPlugin() { + this(GoogleCloudRunMetadata::fetch); + } + + /** + * Package-private seam that builds a plugin from already-resolved {@link GoogleCloudRunMetadata}, + * skipping the fetch. Used by tests. + * + * @param metadata previously fetched Cloud Run instance metadata. + */ + CloudRunIdPlugin(GoogleCloudRunMetadata metadata) { + this(fixedSupplier(metadata)); + } + + /** + * Package-private test seam that supplies the {@link GoogleCloudRunMetadata} lazily. It lets unit + * tests point the fetch at an in-process metadata server and injected environment through the + * {@link GoogleCloudRunMetadata#fetch(String, java.time.Duration, java.util.function.Function)} + * seam, and to exercise the off-platform fail-fast path. It is not part of the public API. + * + * @param metadataSupplier supplier invoked once, at client-configure time, to resolve the + * metadata. + */ + CloudRunIdPlugin(Supplier metadataSupplier) { + super(NAME); + this.metadataSupplier = Objects.requireNonNull(metadataSupplier, "metadataSupplier"); + } + + /** + * Fetches (once) and caches the Cloud Run instance metadata, then sets the derived worker + * identity on the client options when the caller has not already set an identity. + * + * @param builder the workflow client options builder to configure. + * @throws IllegalStateException if the Cloud Run metadata server cannot be reached, which usually + * means this process is not running on Google Cloud Run. + */ + @Override + public void configureWorkflowClient(WorkflowClientOptions.Builder builder) { + GoogleCloudRunMetadata resolved = metadata(); + if (isBlank(builder.build().getIdentity())) { + builder.setIdentity(resolved.identity()); + } + } + + private GoogleCloudRunMetadata metadata() { + GoogleCloudRunMetadata local = metadata; + if (local == null) { + synchronized (this) { + local = metadata; + if (local == null) { + local = Objects.requireNonNull(metadataSupplier.get(), "Cloud Run metadata"); + metadata = local; + } + } + } + return local; + } + + private static Supplier fixedSupplier(GoogleCloudRunMetadata metadata) { + Objects.requireNonNull(metadata, "metadata"); + return () -> metadata; + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadata.java b/contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadata.java new file mode 100644 index 0000000000..b9a7657525 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run-id/src/main/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadata.java @@ -0,0 +1,210 @@ +package io.temporal.gcp.cloudrun.id; + +import io.temporal.common.Experimental; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Objects; +import java.util.function.Function; + +/** + * Reads Google Cloud Run instance metadata and derives a Temporal worker identity from it. + * + *

Most applications register {@link CloudRunIdPlugin} on their workflow client instead of using + * this class directly; the plugin fetches this metadata and applies the derived identity to the + * client. Use this class directly to read the {@linkplain #identity() worker identity} yourself. + * + *

The name and revision are resolved from environment variables Cloud Run injects into every + * instance. Cloud Run worker pools set {@code CLOUD_RUN_WORKER_POOL} and {@code + * CLOUD_RUN_REVISION}; Cloud Run services set {@code K_SERVICE} and {@code K_REVISION}. The + * name is the first non-empty of {@code CLOUD_RUN_WORKER_POOL} then {@code K_SERVICE}, and the + * revision is the first non-empty of {@code CLOUD_RUN_REVISION} then {@code K_REVISION}. The unique + * instance id is only available from the Cloud Run metadata server, so {@link #fetch()} performs a + * single HTTP request against it. + * + *

Experimental: Google Cloud Run support is experimental and may change without notice. + */ +@Experimental +public final class GoogleCloudRunMetadata { + /** Name of the environment variable Cloud Run worker pools set to the worker pool name. */ + public static final String CLOUD_RUN_WORKER_POOL = "CLOUD_RUN_WORKER_POOL"; + + /** Name of the environment variable Cloud Run worker pools set to the revision name. */ + public static final String CLOUD_RUN_REVISION = "CLOUD_RUN_REVISION"; + + /** Name of the environment variable Cloud Run services set to the deployed service name. */ + public static final String K_SERVICE = "K_SERVICE"; + + /** Name of the environment variable Cloud Run services set to the deployed revision name. */ + public static final String K_REVISION = "K_REVISION"; + + /** Default Cloud Run metadata server URL that returns the unique instance id. */ + public static final String DEFAULT_METADATA_URL = + "http://metadata.google.internal/computeMetadata/v1/instance/id"; + + /** Default connect and read timeout used when contacting the metadata server. */ + public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(2); + + private static final String METADATA_FLAVOR_HEADER = "Metadata-Flavor"; + private static final String METADATA_FLAVOR_VALUE = "Google"; + + private final String instanceId; + private final String name; + private final String revision; + + private GoogleCloudRunMetadata(String instanceId, String name, String revision) { + this.instanceId = instanceId; + this.name = name; + this.revision = revision; + } + + /** + * Fetches Cloud Run instance metadata using the {@linkplain #DEFAULT_METADATA_URL default + * metadata URL} and the {@linkplain #DEFAULT_TIMEOUT default timeout}. + * + * @return metadata describing the current Cloud Run instance. + * @throws IllegalStateException if the metadata server cannot be reached, which usually means the + * process is not running on Google Cloud Run. + */ + public static GoogleCloudRunMetadata fetch() { + return fetch(DEFAULT_METADATA_URL, DEFAULT_TIMEOUT); + } + + /** + * Fetches Cloud Run instance metadata from the supplied metadata server URL. + * + *

The name is read from {@code CLOUD_RUN_WORKER_POOL} then {@code K_SERVICE}, and the revision + * from {@code CLOUD_RUN_REVISION} then {@code K_REVISION}. The unique instance id is read from + * {@code metadataUrl} with the required {@code Metadata-Flavor: Google} request header. + * + * @param metadataUrl URL of the Cloud Run metadata endpoint that returns the instance id. + * @param timeout connect and read timeout applied to the metadata request. + * @return metadata describing the current Cloud Run instance. + * @throws IllegalStateException if the metadata server cannot be reached, which usually means the + * process is not running on Google Cloud Run. + */ + public static GoogleCloudRunMetadata fetch(String metadataUrl, Duration timeout) { + return fetch(metadataUrl, timeout, System::getenv); + } + + /** + * Package-private test seam that injects the environment-variable lookup used to resolve the name + * and revision. This lets unit tests exercise the environment-variable precedence and the + * metadata HTTP request deterministically, without depending on the real process environment. It + * is not part of the public API and must not be relied on outside of tests; use {@link + * #fetch(String, Duration)} instead. + * + * @param metadataUrl URL of the Cloud Run metadata endpoint that returns the instance id. + * @param timeout connect and read timeout applied to the metadata request. + * @param getenv environment-variable lookup, normally {@code System::getenv}. + */ + static GoogleCloudRunMetadata fetch( + String metadataUrl, Duration timeout, Function getenv) { + Objects.requireNonNull(metadataUrl, "metadataUrl"); + Objects.requireNonNull(timeout, "timeout"); + Objects.requireNonNull(getenv, "getenv"); + + String name = firstNonBlank(getenv.apply(CLOUD_RUN_WORKER_POOL), getenv.apply(K_SERVICE)); + String revision = firstNonBlank(getenv.apply(CLOUD_RUN_REVISION), getenv.apply(K_REVISION)); + + HttpURLConnection connection = null; + try { + connection = (HttpURLConnection) URI.create(metadataUrl).toURL().openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty(METADATA_FLAVOR_HEADER, METADATA_FLAVOR_VALUE); + int timeoutMillis = timeoutMillis(timeout); + connection.setConnectTimeout(timeoutMillis); + connection.setReadTimeout(timeoutMillis); + + String instanceId = readBody(connection).trim(); + return new GoogleCloudRunMetadata(instanceId, name, revision); + } catch (IOException e) { + throw new IllegalStateException( + "Unable to read the Cloud Run instance id from the metadata server at " + + metadataUrl + + "; this process may not be running on Google Cloud Run", + e); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + /** + * @return the unique Cloud Run instance id read from the metadata server. + */ + public String getInstanceId() { + return instanceId; + } + + /** + * @return the Cloud Run worker pool or service name, resolved from {@code CLOUD_RUN_WORKER_POOL} + * then {@code K_SERVICE}, or {@code null} when neither was set. + */ + public String getName() { + return name; + } + + /** + * @return the Cloud Run revision name, resolved from {@code CLOUD_RUN_REVISION} then {@code + * K_REVISION}, or {@code null} when neither was set. + */ + public String getRevision() { + return revision; + } + + /** + * Builds a Temporal worker identity for this Cloud Run instance. + * + *

The identity is {@code instanceId@revision}. When the revision is blank the name is used + * instead, and when both are blank the bare instance id is returned. + * + * @return a worker identity string suitable for {@code WorkflowClientOptions} and {@code + * WorkerOptions}. + */ + public String identity() { + if (!isBlank(revision)) { + return instanceId + "@" + revision; + } + if (!isBlank(name)) { + return instanceId + "@" + name; + } + return instanceId; + } + + private static String readBody(HttpURLConnection connection) throws IOException { + try (InputStream in = connection.getInputStream()) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] chunk = new byte[512]; + int read; + while ((read = in.read(chunk)) != -1) { + out.write(chunk, 0, read); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } + } + + private static int timeoutMillis(Duration timeout) { + long millis = timeout.toMillis(); + if (millis < 0) { + throw new IllegalArgumentException("timeout must not be negative"); + } + return (int) Math.min(millis, Integer.MAX_VALUE); + } + + private static String firstNonBlank(String first, String second) { + if (!isBlank(first)) { + return first; + } + return isBlank(second) ? null : second; + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/CloudRunIdPluginTest.java b/contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/CloudRunIdPluginTest.java new file mode 100644 index 0000000000..f4c7cd3603 --- /dev/null +++ b/contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/CloudRunIdPluginTest.java @@ -0,0 +1,158 @@ +package io.temporal.gcp.cloudrun.id; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import io.temporal.client.WorkflowClientOptions; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link CloudRunIdPlugin}. + * + *

The metadata request is served by an in-process {@link HttpServer} and the environment lookup + * is injected through the {@link GoogleCloudRunMetadata#fetch(String, Duration, + * java.util.function.Function)} test seam, so these tests touch neither the network nor the real + * process environment. The plugin's package-private {@link + * CloudRunIdPlugin#CloudRunIdPlugin(Supplier)} seam lets each test point the plugin at that + * in-process server (or at an unreachable address, to exercise the off-platform fail-fast path). + */ +public class CloudRunIdPluginTest { + private static final Duration TIMEOUT = Duration.ofSeconds(2); + + private HttpServer server; + private final AtomicReference responseBody = new AtomicReference<>(""); + + @Before + public void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/computeMetadata/v1/instance/id", + exchange -> { + byte[] body = responseBody.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length == 0 ? -1 : body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + } + + @After + public void stopServer() { + server.stop(0); + } + + @Test + public void configureWorkflowClientSetsDerivedIdentityWhenUnset() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "revision-1"); + + WorkflowClientOptions.Builder builder = WorkflowClientOptions.newBuilder(); + pluginFor(env).configureWorkflowClient(builder); + + assertEquals("instance-1@revision-1", builder.build().getIdentity()); + } + + @Test + public void configureWorkflowClientPreservesUserProvidedIdentity() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "revision-1"); + + WorkflowClientOptions.Builder builder = + WorkflowClientOptions.newBuilder().setIdentity("user-set"); + pluginFor(env).configureWorkflowClient(builder); + + assertEquals("user-set", builder.build().getIdentity()); + } + + @Test + public void configureWorkflowClientFailsFastOffCloudRun() { + String unreachableUrl = + "http://127.0.0.1:" + reserveUnusedPort() + "/computeMetadata/v1/instance/id"; + CloudRunIdPlugin plugin = + new CloudRunIdPlugin( + () -> GoogleCloudRunMetadata.fetch(unreachableUrl, TIMEOUT, name -> null)); + + IllegalStateException e = + assertThrows( + IllegalStateException.class, + () -> plugin.configureWorkflowClient(WorkflowClientOptions.newBuilder())); + assertTrue(e.getMessage().contains("metadata server")); + } + + @Test + public void metadataIsFetchedOnceAndCached() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "revision-1"); + + GoogleCloudRunMetadata resolved = metadata(env); + AtomicInteger supplierCalls = new AtomicInteger(); + Supplier countingSupplier = + () -> { + supplierCalls.incrementAndGet(); + return resolved; + }; + CloudRunIdPlugin plugin = new CloudRunIdPlugin(countingSupplier); + + plugin.configureWorkflowClient(WorkflowClientOptions.newBuilder()); + plugin.configureWorkflowClient(WorkflowClientOptions.newBuilder()); + + assertEquals(1, supplierCalls.get()); + } + + @Test + public void injectedMetadataIsUsedWithoutFetching() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "revision-1"); + + CloudRunIdPlugin plugin = new CloudRunIdPlugin(metadata(env)); + + WorkflowClientOptions.Builder builder = WorkflowClientOptions.newBuilder(); + plugin.configureWorkflowClient(builder); + + assertEquals("instance-1@revision-1", builder.build().getIdentity()); + } + + private CloudRunIdPlugin pluginFor(Map env) { + return new CloudRunIdPlugin(() -> metadata(env)); + } + + private GoogleCloudRunMetadata metadata(Map env) { + return GoogleCloudRunMetadata.fetch(metadataUrl(), TIMEOUT, env::get); + } + + private String metadataUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/computeMetadata/v1/instance/id"; + } + + private static int reserveUnusedPort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadataTest.java b/contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadataTest.java new file mode 100644 index 0000000000..8e17d92e3f --- /dev/null +++ b/contrib/temporal-gcp-cloud-run-id/src/test/java/io/temporal/gcp/cloudrun/id/GoogleCloudRunMetadataTest.java @@ -0,0 +1,197 @@ +package io.temporal.gcp.cloudrun.id; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +/** + * Unit tests for {@link GoogleCloudRunMetadata}. + * + *

The metadata request is served by an in-process {@link HttpServer} and the environment lookup + * is injected through the package-private {@link GoogleCloudRunMetadata#fetch(String, Duration, + * java.util.function.Function)} test seam, so these tests touch neither the network nor the real + * process environment. + */ +public class GoogleCloudRunMetadataTest { + private static final Duration TIMEOUT = Duration.ofSeconds(2); + + private HttpServer server; + private final AtomicReference responseBody = new AtomicReference<>(""); + private final AtomicInteger responseStatus = new AtomicInteger(200); + private final AtomicReference capturedMetadataFlavor = new AtomicReference<>(); + private final AtomicReference capturedMethod = new AtomicReference<>(); + + @Before + public void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext( + "/computeMetadata/v1/instance/id", + exchange -> { + capturedMetadataFlavor.set(exchange.getRequestHeaders().getFirst("Metadata-Flavor")); + capturedMethod.set(exchange.getRequestMethod()); + byte[] body = responseBody.get().getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(responseStatus.get(), body.length == 0 ? -1 : body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + server.start(); + } + + @After + public void stopServer() { + server.stop(0); + } + + // --- Environment-variable precedence --- + + @Test + public void cloudRunWorkerPoolWinsOverKService() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.K_SERVICE, "service"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "worker-pool-revision"); + env.put(GoogleCloudRunMetadata.K_REVISION, "service-revision"); + + GoogleCloudRunMetadata metadata = fetch(env); + + assertEquals("worker-pool", metadata.getName()); + assertEquals("worker-pool-revision", metadata.getRevision()); + } + + @Test + public void kServiceUsedWhenWorkerPoolAbsent() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.K_SERVICE, "service"); + env.put(GoogleCloudRunMetadata.K_REVISION, "service-revision"); + + GoogleCloudRunMetadata metadata = fetch(env); + + assertEquals("service", metadata.getName()); + assertEquals("service-revision", metadata.getRevision()); + } + + @Test + public void blankWorkerPoolVariablesFallThroughToKService() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, " "); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, ""); + env.put(GoogleCloudRunMetadata.K_SERVICE, "service"); + env.put(GoogleCloudRunMetadata.K_REVISION, "service-revision"); + + GoogleCloudRunMetadata metadata = fetch(env); + + assertEquals("service", metadata.getName()); + assertEquals("service-revision", metadata.getRevision()); + } + + @Test + public void nameAndRevisionAreNullWhenNoEnvSet() { + responseBody.set("instance-1"); + + GoogleCloudRunMetadata metadata = fetch(new HashMap<>()); + + assertNull(metadata.getName()); + assertNull(metadata.getRevision()); + } + + // --- Worker identity --- + + @Test + public void identityCombinesInstanceIdAndRevision() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "revision-1"); + + assertEquals("instance-1@revision-1", fetch(env).identity()); + } + + @Test + public void identityFallsBackToNameWhenRevisionBlank() { + responseBody.set("instance-1"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + + assertEquals("instance-1@worker-pool", fetch(env).identity()); + } + + @Test + public void identityFallsBackToInstanceIdWhenNameAndRevisionBlank() { + responseBody.set("instance-1"); + + assertEquals("instance-1", fetch(new HashMap<>()).identity()); + } + + // --- Metadata HTTP request --- + + @Test + public void fetchSendsMetadataFlavorHeaderAndTrimsBody() { + responseBody.set(" instance-42\n"); + Map env = new HashMap<>(); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_WORKER_POOL, "worker-pool"); + env.put(GoogleCloudRunMetadata.CLOUD_RUN_REVISION, "revision-1"); + + GoogleCloudRunMetadata metadata = fetch(env); + + assertEquals("instance-42", metadata.getInstanceId()); + assertEquals("Google", capturedMetadataFlavor.get()); + assertEquals("GET", capturedMethod.get()); + } + + @Test + public void fetchThrowsOnNonSuccessStatus() { + responseStatus.set(500); + responseBody.set("boom"); + + IllegalStateException e = + assertThrows(IllegalStateException.class, () -> fetch(new HashMap<>())); + assertTrue(e.getMessage().contains("metadata server")); + } + + @Test + public void fetchThrowsWhenServerUnreachable() { + String unreachableUrl = + "http://127.0.0.1:" + reserveUnusedPort() + "/computeMetadata/v1/instance/id"; + Map env = new HashMap<>(); + + assertThrows( + IllegalStateException.class, + () -> GoogleCloudRunMetadata.fetch(unreachableUrl, TIMEOUT, env::get)); + } + + private GoogleCloudRunMetadata fetch(Map env) { + return GoogleCloudRunMetadata.fetch(metadataUrl(), TIMEOUT, env::get); + } + + private String metadataUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/computeMetadata/v1/instance/id"; + } + + private static int reserveUnusedPort() { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/settings.gradle b/settings.gradle index 61efe8636f..0b6bb9bda1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -17,6 +17,8 @@ include 'temporal-aws-lambda' project(':temporal-aws-lambda').projectDir = file('contrib/temporal-aws-lambda') include 'temporal-gcp-cloud-run-opentelemetry' project(':temporal-gcp-cloud-run-opentelemetry').projectDir = file('contrib/temporal-gcp-cloud-run-opentelemetry') +include 'temporal-gcp-cloud-run-id' +project(':temporal-gcp-cloud-run-id').projectDir = file('contrib/temporal-gcp-cloud-run-id') include 'temporal-payload-storage-s3driver' project(':temporal-payload-storage-s3driver').projectDir = file('contrib/temporal-payload-storage-s3driver') include 'temporal-payload-storage-s3driver-awssdkv2' diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index 66f98856b6..01ddcc47e2 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -11,6 +11,7 @@ dependencies { api project(':temporal-opentracing') api project(':temporal-aws-lambda') api project(':temporal-gcp-cloud-run-opentelemetry') + api project(':temporal-gcp-cloud-run-id') api project(':temporal-remote-data-encoder') api project(':temporal-sdk') api project(':temporal-serviceclient') From 3513f7586db47b975bf4d1e4ccba9f88bed11f39 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Wed, 16 Sep 2026 13:37:47 -0700 Subject: [PATCH 105/107] NexusSerializationContext for Java (#3075) Added NexusSerializationContext for nexus callers and sync handlers. This allows data and failure converters to use nexus endpoint, service and operation to be used for encoding and decoding. --- .../NexusOperationExecutionDescription.java | 34 +- .../client/UntypedNexusServiceClientImpl.java | 11 +- .../NexusClientCallsInterceptor.java | 76 ++++ .../client/NexusOperationHandleImpl.java | 37 +- .../client/RootNexusClientInvoker.java | 51 ++- .../nexus/InternalNexusOperationContext.java | 22 ++ .../internal/nexus/NexusTaskHandlerImpl.java | 27 +- .../internal/nexus/PayloadSerializer.java | 21 +- .../internal/sync/SyncWorkflowContext.java | 20 +- .../context/NexusSerializationContext.java | 104 +++++ .../payload/context/SerializationContext.java | 5 +- ...andaloneNexusSerializationContextTest.java | 374 ++++++++++++++++++ .../client/RootNexusClientInvokerTest.java | 44 +++ ...usTaskHandlerSerializationContextTest.java | 261 ++++++++++++ .../nexus/NexusSerializationContextTest.java | 321 +++++++++++++++ 15 files changed, 1376 insertions(+), 32 deletions(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/payload/context/NexusSerializationContext.java create mode 100644 temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java index 50fd5837ba..2e9ddfc337 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java @@ -24,12 +24,24 @@ public final class NexusOperationExecutionDescription extends NexusOperationExec private final DescribeNexusOperationExecutionResponse response; private final NexusOperationExecutionInfo info; - private final DataConverter dataConverter; + private final DataConverter dataConverterWithNexusContext; + // User metadata is attached by the caller without a Nexus serialization context, so it has to be + // decoded without one too. Everything else on a description belongs to the operation and is + // decoded with the operation's context. + private final DataConverter contextlessDataConverter; public NexusOperationExecutionDescription( DescribeNexusOperationExecutionResponse response, DataConverter dataConverter, String namespace) { + this(response, dataConverter, dataConverter, namespace); + } + + public NexusOperationExecutionDescription( + DescribeNexusOperationExecutionResponse response, + DataConverter dataConverterWithNexusContext, + DataConverter contextlessDataConverter, + String namespace) { super( null, response.getInfo().getOperationId(), @@ -51,7 +63,8 @@ public NexusOperationExecutionDescription( : null); this.response = response; this.info = response.getInfo(); - this.dataConverter = dataConverter; + this.dataConverterWithNexusContext = dataConverterWithNexusContext; + this.contextlessDataConverter = contextlessDataConverter; } /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ @@ -124,7 +137,7 @@ public Instant getLastAttemptCompleteTime() { @Nullable public Exception getLastAttemptFailure() { return info.hasLastAttemptFailure() - ? dataConverter.failureToException(info.getLastAttemptFailure()) + ? dataConverterWithNexusContext.failureToException(info.getLastAttemptFailure()) : null; } @@ -140,7 +153,8 @@ public Instant getNextAttemptScheduleTime() { @Nullable public NexusOperationCancellationInfo getCancellationInfo() { return info.hasCancellationInfo() - ? new NexusOperationCancellationInfo(info.getCancellationInfo(), dataConverter) + ? new NexusOperationCancellationInfo( + info.getCancellationInfo(), dataConverterWithNexusContext) : null; } @@ -183,7 +197,7 @@ public String getStaticSummary() { if (!info.hasUserMetadata() || !info.getUserMetadata().hasSummary()) { return null; } - return dataConverter.fromPayload( + return contextlessDataConverter.fromPayload( info.getUserMetadata().getSummary(), String.class, String.class); } @@ -196,7 +210,7 @@ public String getStaticDetails() { if (!info.hasUserMetadata() || !info.getUserMetadata().hasDetails()) { return null; } - return dataConverter.fromPayload( + return contextlessDataConverter.fromPayload( info.getUserMetadata().getDetails(), String.class, String.class); } @@ -231,7 +245,7 @@ public Optional getInput(Class valueType, Type genericType) { return Optional.empty(); } return Optional.ofNullable( - dataConverter.fromPayload(response.getInput(), valueType, genericType)); + dataConverterWithNexusContext.fromPayload(response.getInput(), valueType, genericType)); } /** @@ -266,7 +280,7 @@ public Optional getResult(Class valueType, Type genericType) { return Optional.empty(); } return Optional.ofNullable( - dataConverter.fromPayload(response.getResult(), valueType, genericType)); + dataConverterWithNexusContext.fromPayload(response.getResult(), valueType, genericType)); } /** @@ -275,6 +289,8 @@ public Optional getResult(Class valueType, Type genericType) { */ @Nullable public Exception getFailure() { - return response.hasFailure() ? dataConverter.failureToException(response.getFailure()) : null; + return response.hasFailure() + ? dataConverterWithNexusContext.failureToException(response.getFailure()) + : null; } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java index b7dd9334a9..73222e3ade 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedNexusServiceClientImpl.java @@ -8,6 +8,7 @@ import io.temporal.common.interceptors.NexusClientCallsInterceptor.StartNexusOperationExecutionOutput; import io.temporal.internal.client.NexusClientResolvedOptions; import io.temporal.internal.client.NexusOperationHandleImpl; +import io.temporal.payload.context.NexusSerializationContext; import java.lang.reflect.Type; import java.util.Collections; import javax.annotation.Nullable; @@ -43,12 +44,15 @@ class UntypedNexusServiceClientImpl implements UntypedNexusServiceClient { @Override public UntypedNexusOperationHandle start( String operation, StartNexusOperationOptions options, @Nullable Object arg) { - Payload payload = serializeInput(arg); + Payload payload = serializeInput(arg, operation); StartNexusOperationExecutionInput input = new StartNexusOperationExecutionInput( endpoint, serviceName, operation, payload, options, Collections.emptyMap()); StartNexusOperationExecutionOutput output = invoker.startNexusOperationExecution(input); - return new NexusOperationHandleImpl(output.getOperationId(), output.getRunId(), invoker); + // The handle keeps what the start request was for, including when the server returned an + // operation that was already running, so the result is decoded the way it was encoded. + return new NexusOperationHandleImpl( + output.getOperationId(), output.getRunId(), invoker, endpoint, serviceName, operation); } @Override @@ -71,12 +75,13 @@ public R execute( return NexusOperationHandle.fromUntyped(handle, resultClass, resultType).getResult(); } - private @Nullable Payload serializeInput(@Nullable Object arg) { + private @Nullable Payload serializeInput(@Nullable Object arg, String operation) { if (arg == null) { return null; } Class argClass = arg.getClass(); return dataConverter + .withContext(new NexusSerializationContext(endpoint, serviceName, operation)) .toPayload(arg) .orElseThrow( () -> diff --git a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java index 9af27865bc..5f0bd59980 100644 --- a/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java +++ b/temporal-sdk/src/main/java/io/temporal/common/interceptors/NexusClientCallsInterceptor.java @@ -10,6 +10,7 @@ import io.temporal.client.NexusOperationHandle; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; +import io.temporal.payload.context.NexusSerializationContext; import java.lang.reflect.Type; import java.util.Collections; import java.util.Map; @@ -250,18 +251,65 @@ final class GetNexusOperationResultInput { private final @Nonnull Deadline deadline; private final Class resultClass; private final @Nullable Type resultType; + private final @Nullable String endpoint; + private final @Nullable String service; + private final @Nullable String operation; + /** + * Equivalent to {@link #GetNexusOperationResultInput(String, String, Deadline, Class, Type, + * String, String, String)} with no endpoint, service or operation, which is the case for a + * handle obtained by operation ID rather than by starting an operation. + */ public GetNexusOperationResultInput( String operationId, @Nullable String runId, @Nonnull Deadline deadline, Class resultClass, @Nullable Type resultType) { + this(operationId, runId, deadline, resultClass, resultType, null, null, null); + } + + /** + * The endpoint, service and operation identify the Nexus operation the result is being read + * for, and are used to build the {@link NexusSerializationContext} the result and failure are + * decoded with. They must all be set or all be {@code null}: a partially identified operation + * would silently decode without a context, which for a converter that varies by context means + * reading the payload the wrong way rather than failing. + * + * @param endpoint Nexus endpoint the operation was started on, or {@code null} if the operation + * was not started through this handle + * @param service Nexus service the operation was started on, or {@code null} + * @param operation Nexus operation that was started, or {@code null} + * @throws IllegalArgumentException if only some of endpoint, service and operation are set + */ + public GetNexusOperationResultInput( + String operationId, + @Nullable String runId, + @Nonnull Deadline deadline, + Class resultClass, + @Nullable Type resultType, + @Nullable String endpoint, + @Nullable String service, + @Nullable String operation) { + boolean anySet = endpoint != null || service != null || operation != null; + boolean allSet = endpoint != null && service != null && operation != null; + if (anySet && !allSet) { + throw new IllegalArgumentException( + "endpoint, service and operation must all be set or all be null, got endpoint=" + + endpoint + + ", service=" + + service + + ", operation=" + + operation); + } this.operationId = operationId; this.runId = runId; this.deadline = deadline; this.resultClass = resultClass; this.resultType = resultType; + this.endpoint = endpoint; + this.service = service; + this.operation = operation; } public String getOperationId() { @@ -285,6 +333,34 @@ public Class getResultClass() { public Type getResultType() { return resultType; } + + /** + * Nexus endpoint the operation was started on. {@code null} when the operation was not started + * through this handle, in which case {@link #getService()} and {@link #getOperation()} are + * {@code null} too and the result is decoded without a Nexus serialization context. + */ + @Nullable + public String getEndpoint() { + return endpoint; + } + + /** + * Nexus service the operation was started on, or {@code null}. Set exactly when {@link + * #getEndpoint()} is set. + */ + @Nullable + public String getService() { + return service; + } + + /** + * Nexus operation that was started, or {@code null}. Set exactly when {@link #getEndpoint()} is + * set. + */ + @Nullable + public String getOperation() { + return operation; + } } final class GetNexusOperationResultOutput { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java index 732de7e49c..ac6ca98f07 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/NexusOperationHandleImpl.java @@ -25,9 +25,25 @@ public final class NexusOperationHandleImpl implements UntypedNexusOperationHand private final String operationId; private final @Nullable String runId; private final NexusClientCallsInterceptor interceptor; + // What the operation was started on, retained so its result and failure are decoded with the + // same serialization context that encoded them. All null for a handle obtained by operation ID, + // which never saw a start request. + private final @Nullable String endpoint; + private final @Nullable String service; + private final @Nullable String operation; public NexusOperationHandleImpl( String operationId, @Nullable String runId, NexusClientCallsInterceptor interceptor) { + this(operationId, runId, interceptor, null, null, null); + } + + public NexusOperationHandleImpl( + String operationId, + @Nullable String runId, + NexusClientCallsInterceptor interceptor, + @Nullable String endpoint, + @Nullable String service, + @Nullable String operation) { if (operationId == null) { throw new IllegalArgumentException("operationId is required"); } @@ -37,6 +53,9 @@ public NexusOperationHandleImpl( this.operationId = operationId; this.runId = runId; this.interceptor = interceptor; + this.endpoint = endpoint; + this.service = service; + this.operation = operation; } @Override @@ -116,7 +135,14 @@ public R getResult( throws TimeoutException { GetNexusOperationResultInput input = new GetNexusOperationResultInput<>( - operationId, runId, Deadline.after(timeout, unit), resultClass, resultType); + operationId, + runId, + Deadline.after(timeout, unit), + resultClass, + resultType, + endpoint, + service, + operation); return interceptor.getNexusOperationResult(input).getResult(); } @@ -131,7 +157,14 @@ public CompletableFuture getResultAsync( long timeout, TimeUnit unit, Class resultClass, @Nullable Type resultType) { GetNexusOperationResultInput input = new GetNexusOperationResultInput<>( - operationId, runId, Deadline.after(timeout, unit), resultClass, resultType); + operationId, + runId, + Deadline.after(timeout, unit), + resultClass, + resultType, + endpoint, + service, + operation); return interceptor .getNexusOperationResultAsync(input) .thenApply(GetNexusOperationResultOutput::getResult); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 415dbceea1..8d519b21db 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -8,6 +8,7 @@ import io.temporal.api.enums.v1.NexusOperationWaitStage; import io.temporal.api.errordetails.v1.NexusOperationExecutionAlreadyStartedFailure; import io.temporal.api.failure.v1.Failure; +import io.temporal.api.nexus.v1.NexusOperationExecutionInfo; import io.temporal.api.sdk.v1.UserMetadata; import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsRequest; import io.temporal.api.workflowservice.v1.CountNexusOperationExecutionsResponse; @@ -28,10 +29,12 @@ import io.temporal.client.NexusOperationNotFoundException; import io.temporal.client.StartNexusOperationOptions; import io.temporal.common.Experimental; +import io.temporal.common.converter.DataConverter; import io.temporal.common.interceptors.NexusClientCallsInterceptor; import io.temporal.internal.client.external.GenericWorkflowClient; import io.temporal.internal.common.ProtobufTimeUtils; import io.temporal.internal.common.WorkflowExecutionUtils; +import io.temporal.payload.context.NexusSerializationContext; import io.temporal.serviceclient.StatusUtils; import java.util.Iterator; import java.util.Objects; @@ -140,9 +143,42 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( } catch (StatusRuntimeException e) { throw mapNotFound(input.getOperationId(), input.getRunId().orElse(null), e); } + // The response names the endpoint, service and operation, so the description decodes its + // payloads and failures with the same context the operation was started with. + NexusOperationExecutionInfo info = response.getInfo(); + DataConverter dataConverter = + clientOptions + .getDataConverter() + .withContext( + new NexusSerializationContext( + info.getEndpoint(), info.getService(), info.getOperation())); return new DescribeNexusOperationExecutionOutput( new NexusOperationExecutionDescription( - response, clientOptions.getDataConverter(), clientOptions.getNamespace())); + response, + dataConverter, + // The summary and details were attached without a Nexus context, so a converter that + // varies by context only round-trips them if they are decoded without one too. + clientOptions.getDataConverter(), + clientOptions.getNamespace())); + } + + /** + * The client's data converter scoped to the Nexus operation the result is being read for, or left + * as-is when the operation is unknown, which is the case for a handle obtained by operation ID. + * + *

{@link GetNexusOperationResultInput} guarantees the endpoint, service and operation are set + * together or not at all, so one null means all three are null. Absence is tested with {@code + * null} rather than emptiness so an operation genuinely named with an empty string still gets a + * context. + */ + private DataConverter dataConverterFor(GetNexusOperationResultInput input) { + DataConverter dataConverter = clientOptions.getDataConverter(); + if (input.getEndpoint() == null) { + return dataConverter; + } + return dataConverter.withContext( + new NexusSerializationContext( + input.getEndpoint(), input.getService(), input.getOperation())); } private DescribeNexusOperationExecutionRequest buildDescribeRequest( @@ -246,13 +282,14 @@ private GetNexusOperationResultOutput extractResult( @Nullable String runId, PollNexusOperationExecutionResponse response, GetNexusOperationResultInput input) { + DataConverter dataConverter = dataConverterFor(input); if (response.hasFailure()) { Failure failure = response.getFailure(); throw new NexusOperationFailedException( "Nexus operation failed: operationId='" + operationId + "'", operationId, runId, - clientOptions.getDataConverter().failureToException(failure)); + dataConverter.failureToException(failure)); } if (!response.hasResult()) { throw new NexusOperationFailedException( @@ -266,12 +303,10 @@ private GetNexusOperationResultOutput extractResult( } Payload payload = response.getResult(); R deserialized = - clientOptions - .getDataConverter() - .fromPayload( - payload, - input.getResultClass(), - input.getResultType() != null ? input.getResultType() : input.getResultClass()); + dataConverter.fromPayload( + payload, + input.getResultClass(), + input.getResultType() != null ? input.getResultType() : input.getResultClass()); return new GetNexusOperationResultOutput<>(deserialized); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java index 3c5a6b0af8..91a0e398cb 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/InternalNexusOperationContext.java @@ -6,10 +6,12 @@ import io.temporal.common.interceptors.NexusOperationOutboundCallsInterceptor; import io.temporal.nexus.NexusOperationContext; import io.temporal.nexus.NexusOperationInfo; +import io.temporal.payload.context.NexusSerializationContext; import java.util.ArrayList; import java.util.Collections; import java.util.List; import javax.annotation.Nonnull; +import javax.annotation.Nullable; public class InternalNexusOperationContext { private final String namespace; @@ -45,6 +47,10 @@ public class InternalNexusOperationContext { private final List responseLinks = new ArrayList<>(); private NexusOperationMetadata nexusOperationMetadata; + // Serialization context for the operation this task is for. Set by the task handler once the + // service and operation names are known, which is only after the request variant has been + // inspected, so it is null while the task is being dispatched. + private NexusSerializationContext serializationContext; public InternalNexusOperationContext( String namespace, @@ -99,6 +105,22 @@ public NexusOperationMetadata getNexusOperationMetadata() { return nexusOperationMetadata; } + /** + * Sets the serialization context describing the operation this task is for. Called by the task + * handler once the request variant has been inspected and the service and operation are known. + */ + public void setSerializationContext(NexusSerializationContext serializationContext) { + this.serializationContext = serializationContext; + } + + /** + * Serialization context for the operation this task is for, or {@code null} if the service and + * operation are not known yet. + */ + public @Nullable NexusSerializationContext getSerializationContext() { + return serializationContext; + } + /** * Set the {@code common.v1.Link}s extracted from the inbound Nexus task so they can be attached * to RPCs issued by the operation handler. diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index 5ef945ec79..becea3bf97 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -26,6 +26,7 @@ import io.temporal.internal.worker.NexusTask; import io.temporal.internal.worker.NexusTaskHandler; import io.temporal.internal.worker.ShutdownManager; +import io.temporal.payload.context.NexusSerializationContext; import io.temporal.serviceclient.CheckedExceptionWrapper; import io.temporal.worker.TypeAlreadyRegisteredException; import java.net.URISyntaxException; @@ -154,6 +155,27 @@ public Result handle(NexusTask task, Scope metricsScope) throws TimeoutException } } + /** + * Records the serialization context for the operation this task is for, so that the data + * converter used for its input, result and failures is scoped to the endpoint, service and + * operation the request names. + */ + private void setSerializationContext(String service, String operation) { + InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.get(); + nexusContext.setSerializationContext( + new NexusSerializationContext(nexusContext.getEndpoint(), service, operation)); + } + + /** + * The data converter scoped to the operation this task is for. Falls back to the uncontextualized + * converter if the request variant did not name a service and operation. + */ + private DataConverter dataConverterForCurrentOperation() { + NexusSerializationContext context = + CurrentNexusOperationContext.get().getSerializationContext(); + return context != null ? dataConverter.withContext(context) : dataConverter; + } + private void cancelOperation(OperationContext context, OperationCancelDetails details) { try { serviceHandler.cancelOperation(context, details); @@ -173,6 +195,7 @@ private void cancelOperation(OperationContext context, OperationCancelDetails de private CancelOperationResponse handleCancelledOperation( OperationContext.Builder ctx, CancelOperationRequest task) { ctx.setService(task.getService()).setOperation(task.getOperation()); + setSerializationContext(task.getService(), task.getOperation()); @SuppressWarnings("deprecation") // getOperationId kept to support old server for a while OperationCancelDetails operationCancelDetails = @@ -281,6 +304,7 @@ private OperationStartResult startOperation( private StartOperationResponse handleStartOperation( OperationContext.Builder ctx, StartOperationRequest task) { ctx.setService(task.getService()).setOperation(task.getOperation()); + setSerializationContext(task.getService(), task.getOperation()); OperationStartDetails.Builder operationStartDetails = OperationStartDetails.newBuilder() @@ -382,7 +406,8 @@ private StartOperationResponse handleStartOperation( HandlerException.ErrorType.INTERNAL, new RuntimeException("Unknown operation state: " + e.getState())); } - startResponseBuilder.setFailure(dataConverter.exceptionToFailure(temporalFailure)); + startResponseBuilder.setFailure( + dataConverterForCurrentOperation().exceptionToFailure(temporalFailure)); } return startResponseBuilder.build(); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java index 97127768cb..ab6429a213 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/PayloadSerializer.java @@ -7,6 +7,7 @@ import io.temporal.common.converter.DataConverter; import io.temporal.common.converter.DataConverterException; import io.temporal.failure.ApplicationFailure; +import io.temporal.payload.context.NexusSerializationContext; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.Optional; @@ -47,9 +48,26 @@ class PayloadSerializer implements Serializer { this.dataConverter = dataConverter; } + /** + * The data converter scoped to the operation currently being handled. + * + *

A single serializer is shared by every operation the worker handles, so the context is + * resolved per call from the task being handled rather than captured once. Falls back to the + * uncontextualized converter when there is no Nexus task in scope, which is the case when this + * serializer is used directly rather than by the task handler. + */ + private DataConverter dataConverter() { + if (!CurrentNexusOperationContext.isNexusContext()) { + return dataConverter; + } + NexusSerializationContext context = + CurrentNexusOperationContext.get().getSerializationContext(); + return context != null ? dataConverter.withContext(context) : dataConverter; + } + @Override public Content serialize(@Nullable Object o) { - Optional payload = dataConverter.toPayload(o); + Optional payload = dataConverter().toPayload(o); Content.Builder content = Content.newBuilder(); content.setData(payload.get().toByteArray()); return content.build(); @@ -59,6 +77,7 @@ public Content serialize(@Nullable Object o) { public @Nullable Object deserialize(Content content, Type type) { try { Payload payload = Payload.parseFrom(content.getData()); + DataConverter dataConverter = dataConverter(); if ((type instanceof Class)) { return dataConverter.fromPayload(payload, (Class) type, type); } else if (type instanceof ParameterizedType) { diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index 065ce71428..e38c6ea4d3 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -46,6 +46,7 @@ import io.temporal.internal.replay.WorkflowContext; import io.temporal.internal.statemachines.*; import io.temporal.payload.context.ActivitySerializationContext; +import io.temporal.payload.context.NexusSerializationContext; import io.temporal.payload.context.WorkflowSerializationContext; import io.temporal.worker.WorkflowImplementationOptions; import io.temporal.workflow.*; @@ -798,9 +799,16 @@ public ExecuteNexusOperationOutput executeNexusOperation( CompletablePromise operationPromise = Workflow.newPromise(); CompletablePromise> resultPromise = Workflow.newPromise(); - // Not using the context aware data converter because the context will not be available on the - // worker side - Optional payload = dataConverter.toPayload(input.getArg()); + // The caller workflow is not available to the operation handler, so Nexus payloads are + // contextualized by the endpoint, service and operation instead. The same converter decodes the + // result and converts failures, so each operation keeps the converter selected for it even when + // several operations are in flight at once. + DataConverter nexusDataConverter = + dataConverter.withContext( + new NexusSerializationContext( + input.getEndpoint(), input.getService(), input.getOperation())); + + Optional payload = nexusDataConverter.toPayload(input.getArg()); ScheduleNexusOperationCommandAttributes.Builder attributes = ScheduleNexusOperationCommandAttributes.newBuilder(); @@ -835,7 +843,7 @@ public ExecuteNexusOperationOutput executeNexusOperation( "nexus operation start failed callback", () -> operationPromise.completeExceptionally( - dataConverter.failureToException(failure))); + nexusDataConverter.failureToException(failure))); } else { runner.executeInWorkflowThread( "nexus operation started callback", @@ -849,7 +857,7 @@ public ExecuteNexusOperationOutput executeNexusOperation( "nexus operation failure callback", () -> resultPromise.completeExceptionally( - dataConverter.failureToException(failure))); + nexusDataConverter.failureToException(failure))); } else { runner.executeInWorkflowThread( "nexus operation completion callback", () -> resultPromise.complete(result)); @@ -869,7 +877,7 @@ public ExecuteNexusOperationOutput executeNexusOperation( resultPromise.thenApply( (b) -> input.getResultClass() != Void.class - ? dataConverter.fromPayload( + ? nexusDataConverter.fromPayload( b.get(), input.getResultClass(), input.getResultType()) : null); // We register an empty handler to make sure that this promise is always "accessed" and never diff --git a/temporal-sdk/src/main/java/io/temporal/payload/context/NexusSerializationContext.java b/temporal-sdk/src/main/java/io/temporal/payload/context/NexusSerializationContext.java new file mode 100644 index 0000000000..3e355c1a14 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/payload/context/NexusSerializationContext.java @@ -0,0 +1,104 @@ +package io.temporal.payload.context; + +import io.temporal.common.Experimental; +import io.temporal.common.converter.FailureConverter; +import java.util.Objects; +import javax.annotation.Nonnull; + +/** + * {@link SerializationContext} for Nexus operation payloads, identifying the Nexus endpoint, + * service, and resolved operation the payload belongs to. + * + *

Callers receive this context when encoding operation inputs and when decoding operation + * results and failures. Handlers receive it when decoding operation inputs, encoding synchronous + * operation results, and encoding failures produced while handling a Nexus task. + * + *

The context is not propagated to the eventual result of an asynchronous operation, because the + * operation is completed out of band rather than by the task the handler was invoked for. A + * standalone operation handle uses the context of its start request, including when the start + * request returns an already-running operation; a handle obtained by operation ID without starting + * an operation has no endpoint, service, or operation to build a context from and therefore + * serializes without one. + * + *

Failure conversion is not symmetric: a failure is encoded by the handler and decoded by the + * caller, so an implementation sees this context on only one side of a given failure, and for some + * operation paths it sees no context at all. Context-dependent encodings must therefore be + * self-describing, and decoders must keep accepting payloads that were encoded without a context. + * This applies to {@link FailureConverter} as much as to payload encoding. + */ +@Experimental +public final class NexusSerializationContext implements SerializationContext { + private final @Nonnull String endpoint; + private final @Nonnull String service; + private final @Nonnull String operation; + + /** + * @param endpoint the Nexus endpoint name; must not be {@code null} + * @param service the Nexus service name; must not be {@code null} + * @param operation the resolved Nexus operation name; must not be {@code null} + */ + public NexusSerializationContext( + @Nonnull String endpoint, @Nonnull String service, @Nonnull String operation) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + this.service = Objects.requireNonNull(service, "service"); + this.operation = Objects.requireNonNull(operation, "operation"); + } + + /** + * @return the Nexus endpoint name + */ + @Nonnull + public String getEndpoint() { + return endpoint; + } + + /** + * @return the Nexus service name + */ + @Nonnull + public String getService() { + return service; + } + + /** + * @return the resolved Nexus operation name + */ + @Nonnull + public String getOperation() { + return operation; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof NexusSerializationContext)) { + return false; + } + NexusSerializationContext that = (NexusSerializationContext) o; + return endpoint.equals(that.endpoint) + && service.equals(that.service) + && operation.equals(that.operation); + } + + @Override + public int hashCode() { + return Objects.hash(endpoint, service, operation); + } + + @Override + public String toString() { + return "NexusSerializationContext{" + + "endpoint='" + + endpoint + + '\'' + + ", service='" + + service + + '\'' + + ", operation='" + + operation + + '\'' + + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/payload/context/SerializationContext.java b/temporal-sdk/src/main/java/io/temporal/payload/context/SerializationContext.java index 0dfc254cc4..0b4d4f1a7c 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/context/SerializationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/context/SerializationContext.java @@ -36,8 +36,9 @@ * PayloadConverter#withContext(SerializationContext)} and using the modified instance when * applicable. * - *

Nexus operations inside a workflow do NOT have a {@link WorkflowSerializationContext} because - * it is not available in the operation handler. + *

Nexus operation payloads get a {@link NexusSerializationContext} rather than a {@link + * WorkflowSerializationContext}, because the caller workflow is not available in the operation + * handler. * *

Note: Serialization Context is experimental feature, the class and field structure of {@link * SerializationContext} objects may change in the future. There may be also situation where the diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java new file mode 100644 index 0000000000..8ee35c8999 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java @@ -0,0 +1,374 @@ +package io.temporal.client.nexus; + +import static org.junit.Assume.assumeTrue; + +import com.google.protobuf.ByteString; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.nexus.v1.Endpoint; +import io.temporal.client.NexusClient; +import io.temporal.client.NexusClientOptions; +import io.temporal.client.NexusOperationExecutionDescription; +import io.temporal.client.NexusOperationFailedException; +import io.temporal.client.StartNexusOperationOptions; +import io.temporal.client.UntypedNexusOperationHandle; +import io.temporal.client.UntypedNexusServiceClient; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.converter.FailureConverter; +import io.temporal.failure.DefaultFailureConverter; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.shared.EchoNexusServiceImpl; +import io.temporal.workflow.shared.TestWorkflows; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import javax.annotation.Nonnull; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * Coverage that the standalone Nexus client scopes its data converter to the endpoint, service and + * operation of the operation it is acting on, and that a handle obtained by operation ID — which + * has no endpoint, service or operation — serializes without a context instead. + * + *

Standalone Nexus operations require a real server with them enabled. + */ +public class StandaloneNexusSerializationContextTest { + private static final String SERVICE = "TestNexusService1"; + private static final String OPERATION = "operation"; + + // Both sides share the codec so a signature written by one is checked by the other. Any payload + // the SDK encodes and decodes under different contexts therefore fails the decode, the way a + // codec keyed on the context would. Only the client gets the recording failure converter, so the + // contexts it records are the client's. + private static final RecordingCodec CODEC = new RecordingCodec(); + private static final RecordingFailureConverter FAILURE_CONVERTER = + new RecordingFailureConverter(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(PlaceholderWorkflowImpl.class) + .setNexusServiceImplementation(new EchoNexusServiceImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setDataConverter( + new CodecDataConverter( + DefaultDataConverter.STANDARD_INSTANCE, Collections.singletonList(CODEC))) + .build()) + .build(); + + private NexusClient nexusClient() { + return NexusClient.newInstance( + testWorkflowRule.getWorkflowServiceStubs(), + NexusClientOptions.newBuilder() + .setNamespace(testWorkflowRule.getWorkflowClient().getOptions().getNamespace()) + .setDataConverter( + new CodecDataConverter( + DefaultDataConverter.newDefaultInstance() + .withFailureConverter(FAILURE_CONVERTER), + Collections.singletonList(CODEC))) + .build()); + } + + @Before + public void requireStandaloneNexusSupport() { + assumeTrue( + "server does not support standalone Nexus operations", + testWorkflowRule.isUseExternalService()); + CODEC.reset(); + FAILURE_CONVERTER.reset(); + } + + @Test + public void startedHandleUsesItsStartRequestContext() { + String input = "ping-" + UUID.randomUUID(); + UntypedNexusOperationHandle handle = startOperation(input); + + // Decoding the result correctly is itself the assertion: the codec rejects a payload whose + // recorded context does not match the context it is being decoded under. + Assert.assertEquals("echo:" + input, handle.getResult(String.class)); + Assert.assertTrue( + "the start input and the polled result should both use the operation's context, but saw " + + CODEC.nexusContexts(), + CODEC.nexusContexts().contains(expectedContext())); + } + + @Test + public void failureUsesTheOperationsContext() { + UntypedNexusOperationHandle handle = + startOperation(EchoNexusServiceImpl.FAIL_PREFIX + UUID.randomUUID()); + // Ignore what the start request itself converted, so only the failure path is observed. + FAILURE_CONVERTER.reset(); + + Assert.assertThrows(NexusOperationFailedException.class, () -> handle.getResult(String.class)); + Assert.assertEquals( + "the operation failure should be converted under the operation's context", + Collections.singletonList(expectedContext()), + FAILURE_CONVERTER.nexusContexts()); + } + + @Test + public void describeUsesContextFromTheResponse() { + String input = "ping-" + UUID.randomUUID(); + UntypedNexusOperationHandle handle = startOperation(input); + handle.getResult(String.class); + CODEC.reset(); + + // A description decodes its payloads lazily, so reading one is what exercises the converter it + // was built with. + NexusOperationExecutionDescription description = handle.describe(); + Assert.assertEquals( + java.util.Optional.of("echo:" + input), description.getResult(String.class)); + + Assert.assertTrue( + "describe should build the context from the endpoint, service and operation the server " + + "reports, but saw " + + CODEC.allContexts(), + CODEC.nexusContexts().contains(expectedContext())); + } + + @Test + public void describeDecodesTheLastAttemptFailureWithContext() { + UntypedNexusOperationHandle handle = + startOperation(EchoNexusServiceImpl.FAIL_PREFIX + UUID.randomUUID()); + Assert.assertThrows(NexusOperationFailedException.class, () -> handle.getResult(String.class)); + FAILURE_CONVERTER.reset(); + + NexusOperationExecutionDescription description = handle.describe(); + Assert.assertNotNull("expected a terminal failure to describe", description.getFailure()); + + Assert.assertEquals( + "the described failure should be converted under the context the server reported", + Collections.singletonList(expectedContext()), + FAILURE_CONVERTER.nexusContexts()); + } + + @Test + public void describeReadsTheUncontextualizedSummary() { + NexusClient client = nexusClient(); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient serviceClient = + client.newUntypedNexusServiceClient(endpoint.getSpec().getName(), SERVICE); + UntypedNexusOperationHandle handle = + serviceClient.start( + OPERATION, + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .setSummary("the-summary") + .build(), + "ping-" + UUID.randomUUID()); + handle.getResult(String.class); + + // The summary is encoded without a Nexus context, so describe must read it back the same way. + // Decoding it under a context the encoder never used would corrupt it. + Assert.assertEquals("the-summary", handle.describe().getStaticSummary()); + } + + @Test + public void handleObtainedByIdHasNoContext() { + String input = "ping-" + UUID.randomUUID(); + UntypedNexusOperationHandle started = startOperation(input); + started.getResult(String.class); + CODEC.reset(); + + // A handle obtained by ID never saw a start request, so there is no endpoint, service or + // operation to scope its converter by. + UntypedNexusOperationHandle detached = + nexusClient().getHandle(started.getNexusOperationId(), started.getNexusOperationRunId()); + Assert.assertEquals("echo:" + input, detached.getResult(String.class)); + + Assert.assertEquals( + "a handle obtained by operation ID should decode without a Nexus context", + Collections.emptyList(), + CODEC.nexusContexts()); + } + + private NexusSerializationContext expectedContext() { + return new NexusSerializationContext( + testWorkflowRule.getNexusEndpoint().getSpec().getName(), SERVICE, OPERATION); + } + + private UntypedNexusOperationHandle startOperation(String input) { + NexusClient client = nexusClient(); + Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); + UntypedNexusServiceClient serviceClient = + client.newUntypedNexusServiceClient(endpoint.getSpec().getName(), SERVICE); + StartNexusOperationOptions options = + StartNexusOperationOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setScheduleToCloseTimeout(Duration.ofSeconds(30)) + .build(); + return serviceClient.start(OPERATION, options, input); + } + + public static class PlaceholderWorkflowImpl implements TestWorkflows.TestWorkflow1 { + @Override + public String execute(String input) { + return input; + } + } + + /** Records the Nexus contexts the SDK scopes failure conversion by. */ + private static class RecordingFailureConverter implements FailureConverter { + private final List seen; + private final SerializationContext context; + private final FailureConverter delegate = new DefaultFailureConverter(); + + RecordingFailureConverter() { + this(Collections.synchronizedList(new ArrayList<>()), null); + } + + private RecordingFailureConverter( + List seen, SerializationContext context) { + this.seen = seen; + this.context = context; + } + + void reset() { + seen.clear(); + } + + List nexusContexts() { + List result = new ArrayList<>(); + synchronized (seen) { + for (SerializationContext each : seen) { + if (each instanceof NexusSerializationContext) { + result.add((NexusSerializationContext) each); + } + } + } + return result; + } + + @Override + @Nonnull + public FailureConverter withContext(@Nonnull SerializationContext context) { + return new RecordingFailureConverter(seen, context); + } + + @Override + @Nonnull + public RuntimeException failureToException( + @Nonnull io.temporal.api.failure.v1.Failure failure, @Nonnull DataConverter dataConverter) { + seen.add(context); + return delegate.failureToException(failure, dataConverter); + } + + @Override + @Nonnull + public io.temporal.api.failure.v1.Failure exceptionToFailure( + @Nonnull Throwable throwable, @Nonnull DataConverter dataConverter) { + seen.add(context); + return delegate.exceptionToFailure(throwable, dataConverter); + } + } + + /** + * Records the Nexus contexts it is handed, and tags each payload it encodes with the context + * used, refusing to decode a payload under a context other than the one that encoded it. + */ + private static class RecordingCodec implements PayloadCodec { + private static final String SIGNATURE_KEY = "ser-ctx-signature"; + + // Shared by every instance derived via withContext, so a test sees all contexts that were used. + private final List seen; + private final SerializationContext context; + + RecordingCodec() { + this(Collections.synchronizedList(new ArrayList<>()), null); + } + + private RecordingCodec(List seen, SerializationContext context) { + this.seen = seen; + this.context = context; + } + + void reset() { + seen.clear(); + } + + List allContexts() { + synchronized (seen) { + return new ArrayList<>(seen); + } + } + + List nexusContexts() { + List result = new ArrayList<>(); + synchronized (seen) { + for (SerializationContext each : seen) { + if (each instanceof NexusSerializationContext) { + result.add((NexusSerializationContext) each); + } + } + } + return result; + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext context) { + return new RecordingCodec(seen, context); + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + seen.add(context); + if (!(context instanceof NexusSerializationContext)) { + return payloads; + } + NexusSerializationContext nexus = (NexusSerializationContext) context; + String signature = + nexus.getEndpoint() + ":" + nexus.getService() + ":" + nexus.getOperation(); + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + encoded.add( + Payload.newBuilder(payload) + .putMetadata(SIGNATURE_KEY, ByteString.copyFromUtf8(signature)) + .build()); + } + return encoded; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + seen.add(context); + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + ByteString signature = payload.getMetadataMap().get(SIGNATURE_KEY); + if (signature == null) { + // Decoding under a Nexus context something that was encoded without one means the SDK + // picked different contexts for the two halves of a round trip. A codec keyed on the + // context, such as a per-endpoint encryption key, could not recover this payload. + Assert.assertFalse( + "payload encoded without a context was decoded under " + context, + context instanceof NexusSerializationContext); + decoded.add(payload); + continue; + } + if (context instanceof NexusSerializationContext) { + NexusSerializationContext nexus = (NexusSerializationContext) context; + Assert.assertEquals( + "payload should be decoded under the context it was encoded with", + nexus.getEndpoint() + ":" + nexus.getService() + ":" + nexus.getOperation(), + signature.toStringUtf8()); + } + decoded.add(Payload.newBuilder(payload).removeMetadata(SIGNATURE_KEY).build()); + } + return decoded; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java b/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java index 6f3cea502c..4b4779a34f 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/RootNexusClientInvokerTest.java @@ -37,6 +37,50 @@ public class RootNexusClientInvokerTest { NexusClientOptions.getDefaultInstance().getDataConverter(), NexusClientOptions.getDefaultInstance().getIdentity())); + @Test + public void resultInputRejectsPartiallyIdentifiedOperation() { + // A partially identified operation would decode without a Nexus context, which for a converter + // that varies by context means reading the payload the wrong way rather than failing. + Assert.assertThrows( + IllegalArgumentException.class, + () -> + new GetNexusOperationResultInput<>( + "op-1", + null, + Deadline.after(10, TimeUnit.SECONDS), + String.class, + String.class, + "endpoint", + null, + "operation")); + } + + @Test + public void resultInputAcceptsFullyIdentifiedOperation() { + GetNexusOperationResultInput input = + new GetNexusOperationResultInput<>( + "op-1", + null, + Deadline.after(10, TimeUnit.SECONDS), + String.class, + String.class, + "endpoint", + "service", + "operation"); + Assert.assertEquals("endpoint", input.getEndpoint()); + Assert.assertEquals("service", input.getService()); + Assert.assertEquals("operation", input.getOperation()); + } + + @Test + public void resultInputAcceptsUnidentifiedOperation() { + // A handle obtained by operation ID never saw a start request. + GetNexusOperationResultInput input = input(); + Assert.assertNull(input.getEndpoint()); + Assert.assertNull(input.getService()); + Assert.assertNull(input.getOperation()); + } + private static GetNexusOperationResultInput input() { return new GetNexusOperationResultInput<>( "op-1", null, Deadline.after(10, TimeUnit.SECONDS), String.class, String.class); diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java new file mode 100644 index 0000000000..bebb0cc3ac --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java @@ -0,0 +1,261 @@ +package io.temporal.internal.nexus; + +import static org.mockito.Mockito.mock; + +import com.google.protobuf.ByteString; +import com.uber.m3.tally.RootScopeBuilder; +import com.uber.m3.tally.Scope; +import com.uber.m3.util.Duration; +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.failure.v1.Failure; +import io.temporal.api.nexus.v1.Request; +import io.temporal.api.nexus.v1.StartOperationRequest; +import io.temporal.api.workflowservice.v1.PollNexusTaskQueueResponse; +import io.temporal.client.WorkflowClient; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.common.interceptors.WorkerInterceptor; +import io.temporal.common.reporter.TestStatsReporter; +import io.temporal.internal.worker.NexusTask; +import io.temporal.internal.worker.NexusTaskHandler; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.workflow.shared.TestNexusServices; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeoutException; +import javax.annotation.Nonnull; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +/** + * Verifies that the Nexus task handler scopes its data converter to the endpoint, service and + * operation the inbound request names, for operation input, synchronous results and failures. + */ +public class NexusTaskHandlerSerializationContextTest { + private static final String NAMESPACE = "testNamespace"; + private static final String TASK_QUEUE = "testTaskQueue"; + private static final String ENDPOINT = "handler-endpoint"; + private static final String SERVICE = "TestNexusService1"; + private static final String OPERATION = "operation"; + + private Scope metricsScope; + + @Before + public void setUp() { + metricsScope = + new RootScopeBuilder().reporter(new TestStatsReporter()).reportEvery(Duration.ofMillis(10)); + } + + @Test + public void inputAndSyncResultUseOperationContext() throws TimeoutException { + NexusSerializationContext expected = + new NexusSerializationContext(ENDPOINT, SERVICE, OPERATION); + // A separate converter stands in for the caller, so the contexts recorded by the handler's own + // codec are only the ones the handler used. + DataConverter callerConverter = signingConverter(new SigningCodec()); + SigningCodec handlerCodec = new SigningCodec(); + DataConverter handlerConverter = signingConverter(handlerCodec); + + // The caller encodes the input under the operation's context, so the handler has to decode it + // under the same context to read it back. + Payload input = callerConverter.withContext(expected).toPayload("handler-input").get(); + + NexusTaskHandler.Result result = + handle(handlerConverter, new EchoServiceImpl(), startTask(input)); + + Assert.assertNull(result.getHandlerException()); + Payload resultPayload = result.getResponse().getStartOperation().getSyncSuccess().getPayload(); + Assert.assertEquals( + "the sync result should be encoded under the operation's context", + signature(expected), + resultPayload.getMetadataOrThrow(SigningCodec.SIGNATURE_KEY).toStringUtf8()); + Assert.assertEquals( + "Hello, handler-input!", + callerConverter + .withContext(expected) + .fromPayload(resultPayload, String.class, String.class)); + Assert.assertEquals( + "the handler should decode the input and encode the result under the operation's context", + java.util.Arrays.asList(expected, expected), + handlerCodec.contexts()); + } + + @Test + public void operationFailureUsesOperationContext() throws TimeoutException { + NexusSerializationContext expected = + new NexusSerializationContext(ENDPOINT, SERVICE, OPERATION); + DataConverter callerConverter = signingConverter(new SigningCodec()); + SigningCodec handlerCodec = new SigningCodec(); + DataConverter handlerConverter = signingConverter(handlerCodec); + Payload input = callerConverter.withContext(expected).toPayload("boom").get(); + + NexusTaskHandler.Result result = + handle(handlerConverter, new FailingServiceImpl(), startTask(input)); + + Assert.assertNull(result.getHandlerException()); + Failure failure = result.getResponse().getStartOperation().getFailure(); + Assert.assertNotEquals( + "the operation should have reported a failure", Failure.getDefaultInstance(), failure); + Assert.assertEquals( + "every converter call the handler made should be under the operation's context", + Collections.singleton(expected), + new java.util.HashSet<>(handlerCodec.contexts())); + } + + @Test + public void serializerWithoutTaskInScopeUsesContextlessConverter() { + // The serializer is shared by the whole worker and is also reachable outside of a Nexus task, + // where there is no endpoint/service/operation to scope it by. + SigningCodec codec = new SigningCodec(); + DataConverter dataConverter = signingConverter(codec); + + PayloadSerializer serializer = new PayloadSerializer(dataConverter); + serializer.serialize("no-task-in-scope"); + + Assert.assertEquals( + "no Nexus task is in scope, so the codec should be called without a context", + Collections.singletonList(null), + codec.contexts()); + } + + private static DataConverter signingConverter(SigningCodec codec) { + return new CodecDataConverter( + DefaultDataConverter.STANDARD_INSTANCE, Collections.singletonList(codec)); + } + + private NexusTaskHandler.Result handle( + DataConverter dataConverter, Object serviceImpl, PollNexusTaskQueueResponse.Builder task) + throws TimeoutException { + NexusTaskHandlerImpl handler = + new NexusTaskHandlerImpl( + mock(WorkflowClient.class), + NAMESPACE, + TASK_QUEUE, + dataConverter, + new WorkerInterceptor[] {}); + handler.registerNexusServiceImplementations(new Object[] {serviceImpl}); + handler.start(); + return handler.handle(new NexusTask(task, null, null), metricsScope); + } + + private static PollNexusTaskQueueResponse.Builder startTask(Payload input) { + return PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setEndpoint(ENDPOINT) + .setStartOperation( + StartOperationRequest.newBuilder() + .setService(SERVICE) + .setOperation(OPERATION) + .setPayload(input))); + } + + private static String signature(NexusSerializationContext context) { + return context.getEndpoint() + ":" + context.getService() + ":" + context.getOperation(); + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class EchoServiceImpl { + @OperationImpl + public OperationHandler operation() { + return io.nexusrpc.handler.OperationHandler.sync( + (ctx, details, name) -> "Hello, " + name + "!"); + } + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class FailingServiceImpl { + @OperationImpl + public OperationHandler operation() { + return io.nexusrpc.handler.OperationHandler.sync( + (ctx, details, name) -> { + throw OperationException.failed(name); + }); + } + } + + /** + * Stamps the serialization context it was given onto every payload it encodes, and records each + * context it is handed so a test can assert which contexts were used and in what order. + */ + private static class SigningCodec implements PayloadCodec { + static final String SIGNATURE_KEY = "ser-ctx-signature"; + + private final List seen; + private final SerializationContext context; + + SigningCodec() { + this(Collections.synchronizedList(new ArrayList<>()), null); + } + + private SigningCodec(List seen, SerializationContext context) { + this.seen = seen; + this.context = context; + } + + List contexts() { + synchronized (seen) { + return new ArrayList<>(seen); + } + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext context) { + return new SigningCodec(seen, context); + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + seen.add(context); + if (!(context instanceof NexusSerializationContext)) { + return payloads; + } + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + encoded.add( + Payload.newBuilder(payload) + .putMetadata( + SIGNATURE_KEY, + ByteString.copyFromUtf8(signature((NexusSerializationContext) context))) + .build()); + } + return encoded; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + seen.add(context); + if (!(context instanceof NexusSerializationContext)) { + return payloads; + } + String expected = signature((NexusSerializationContext) context); + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + ByteString actual = payload.getMetadataMap().get(SIGNATURE_KEY); + // Payloads encoded without a context stay readable, as the contract requires. + if (actual != null) { + Assert.assertEquals( + "payload should be decoded under the context it was encoded with", + expected, + actual.toStringUtf8()); + decoded.add(Payload.newBuilder(payload).removeMetadata(SIGNATURE_KEY).build()); + } else { + decoded.add(payload); + } + } + return decoded; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java new file mode 100644 index 0000000000..2a4daa8fc7 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java @@ -0,0 +1,321 @@ +package io.temporal.workflow.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.history.v1.HistoryEvent; +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.DeleteNexusEndpointRequest; +import io.temporal.client.WorkflowFailedException; +import io.temporal.client.WorkflowStub; +import io.temporal.common.converter.CodecDataConverter; +import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.payload.codec.PayloadCodec; +import io.temporal.payload.context.NexusSerializationContext; +import io.temporal.payload.context.SerializationContext; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.NexusOperationOptions; +import io.temporal.workflow.NexusServiceOptions; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import io.temporal.workflow.shared.TestNexusServices; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import javax.annotation.Nonnull; +import org.junit.After; +import org.junit.Assert; +import org.junit.Assume; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/** + * End-to-end coverage that a workflow calling a Nexus operation encodes its input, decodes its + * result, and converts its failures under the endpoint, service and operation of that operation. + * + *

Two endpoints are routed to the same worker so that a single workflow can call both and the + * payloads for each can be told apart on the wire. + * + *

Nexus requires a real server, so these are skipped unless {@code USE_EXTERNAL_SERVICE=true}. + */ +public class NexusSerializationContextTest { + private static final String RED_ENDPOINT = "red-nexus-endpoint"; + private static final String BLUE_ENDPOINT = "blue-nexus-endpoint"; + private static final String SERVICE = "TestNexusService1"; + private static final String OPERATION = "operation"; + + private static final SigningCodec CODEC = new SigningCodec(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(TwoEndpointWorkflowImpl.class, FailingWorkflowImpl.class) + .setNexusServiceImplementation(new TestNexusServiceImpl()) + .setWorkflowClientOptions( + io.temporal.client.WorkflowClientOptions.newBuilder() + .setDataConverter( + new CodecDataConverter( + DefaultDataConverter.STANDARD_INSTANCE, Collections.singletonList(CODEC))) + .build()) + .build(); + + private final List endpoints = new ArrayList<>(); + + @Before + public void setUp() { + Assume.assumeTrue( + "Nexus operations require a real server", SDKTestWorkflowRule.useExternalService); + CODEC.reset(); + endpoints.add(createEndpoint(RED_ENDPOINT)); + endpoints.add(createEndpoint(BLUE_ENDPOINT)); + } + + @After + public void tearDown() { + for (Endpoint endpoint : endpoints) { + testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .deleteNexusEndpoint( + DeleteNexusEndpointRequest.newBuilder() + .setId(endpoint.getId()) + .setVersion(endpoint.getVersion()) + .build()); + } + endpoints.clear(); + } + + @Test + public void inputAndResultUseTheOperationsOwnContext() { + TwoEndpointWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(TwoEndpointWorkflow.class); + // Each operation must come back with the value it was called with, which only happens if the + // result was decoded under the same context it was encoded with. + Assert.assertEquals(Arrays.asList("Hello, red!", "Hello, blue!"), workflow.execute()); + String workflowId = WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId(); + + // The payloads on the wire carry the context each operation was scheduled with. + Map inputSignatures = new HashMap<>(); + Map resultSignatures = new HashMap<>(); + Map scheduledEndpoints = new HashMap<>(); + for (HistoryEvent event : testWorkflowRule.getExecutionHistory(workflowId).getEvents()) { + if (event.hasNexusOperationScheduledEventAttributes()) { + io.temporal.api.history.v1.NexusOperationScheduledEventAttributes attrs = + event.getNexusOperationScheduledEventAttributes(); + scheduledEndpoints.put(event.getEventId(), attrs.getEndpoint()); + inputSignatures.put(attrs.getEndpoint(), signatureOf(attrs.getInput())); + } else if (event.hasNexusOperationCompletedEventAttributes()) { + io.temporal.api.history.v1.NexusOperationCompletedEventAttributes attrs = + event.getNexusOperationCompletedEventAttributes(); + resultSignatures.put( + scheduledEndpoints.get(attrs.getScheduledEventId()), signatureOf(attrs.getResult())); + } + } + + Assert.assertEquals( + "each operation's input should be encoded under its own endpoint", + expectedSignatures(), + inputSignatures); + Assert.assertEquals( + "each operation's result should be encoded under its own endpoint", + expectedSignatures(), + resultSignatures); + } + + @Test + public void failuresUseTheOperationsOwnContext() { + FailingWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(FailingWorkflow.class); + Assert.assertThrows(WorkflowFailedException.class, workflow::execute); + + NexusSerializationContext expected = + new NexusSerializationContext(RED_ENDPOINT, SERVICE, OPERATION); + Assert.assertTrue( + "the caller should have converted the operation failure under the operation's context, " + + "but saw " + + CODEC.nexusContexts(), + CODEC.nexusContexts().contains(expected)); + } + + private Map expectedSignatures() { + Map expected = new HashMap<>(); + expected.put(RED_ENDPOINT, signature(RED_ENDPOINT)); + expected.put(BLUE_ENDPOINT, signature(BLUE_ENDPOINT)); + return expected; + } + + private static String signatureOf(Payload payload) { + ByteString signature = payload.getMetadataMap().get(SigningCodec.SIGNATURE_KEY); + return signature == null ? null : signature.toStringUtf8(); + } + + private static String signature(String endpoint) { + return endpoint + ":" + SERVICE + ":" + OPERATION; + } + + private Endpoint createEndpoint(String name) { + return testWorkflowRule + .getTestEnvironment() + .getOperatorServiceStubs() + .blockingStub() + .createNexusEndpoint( + CreateNexusEndpointRequest.newBuilder() + .setSpec( + EndpointSpec.newBuilder() + .setName(name) + .setTarget( + EndpointTarget.newBuilder() + .setWorker( + EndpointTarget.Worker.newBuilder() + .setNamespace( + testWorkflowRule.getTestEnvironment().getNamespace()) + .setTaskQueue(testWorkflowRule.getTaskQueue())))) + .build()) + .getEndpoint(); + } + + @WorkflowInterface + public interface TwoEndpointWorkflow { + @WorkflowMethod + List execute(); + } + + @WorkflowInterface + public interface FailingWorkflow { + @WorkflowMethod + void execute(); + } + + public static class TwoEndpointWorkflowImpl implements TwoEndpointWorkflow { + @Override + public List execute() { + return Arrays.asList( + stubFor(RED_ENDPOINT).operation("red"), stubFor(BLUE_ENDPOINT).operation("blue")); + } + } + + public static class FailingWorkflowImpl implements FailingWorkflow { + @Override + public void execute() { + stubFor(RED_ENDPOINT).operation("fail"); + } + } + + private static TestNexusServices.TestNexusService1 stubFor(String endpoint) { + return Workflow.newNexusServiceStub( + TestNexusServices.TestNexusService1.class, + NexusServiceOptions.newBuilder() + .setEndpoint(endpoint) + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(20)) + .build()) + .build()); + } + + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class TestNexusServiceImpl { + @OperationImpl + public OperationHandler operation() { + return OperationHandler.sync( + (ctx, details, name) -> { + if ("fail".equals(name)) { + throw OperationException.failed("operation failed on purpose"); + } + return "Hello, " + name + "!"; + }); + } + } + + /** + * Stamps the Nexus context it was given onto every payload it encodes, so the context used for a + * payload can be read back off the wire, and records the Nexus contexts it was handed. + */ + private static class SigningCodec implements PayloadCodec { + static final String SIGNATURE_KEY = "ser-ctx-signature"; + + // Shared by every instance derived via withContext, so a test sees all contexts that were used. + private final List seen; + private final SerializationContext context; + + SigningCodec() { + this(Collections.synchronizedList(new ArrayList<>()), null); + } + + private SigningCodec(List seen, SerializationContext context) { + this.seen = seen; + this.context = context; + } + + void reset() { + seen.clear(); + } + + List nexusContexts() { + List result = new ArrayList<>(); + synchronized (seen) { + for (SerializationContext each : seen) { + if (each instanceof NexusSerializationContext) { + result.add((NexusSerializationContext) each); + } + } + } + return result; + } + + @Override + @Nonnull + public PayloadCodec withContext(@Nonnull SerializationContext context) { + return new SigningCodec(seen, context); + } + + @Override + @Nonnull + public List encode(@Nonnull List payloads) { + seen.add(context); + if (!(context instanceof NexusSerializationContext)) { + return payloads; + } + NexusSerializationContext nexus = (NexusSerializationContext) context; + String signature = + nexus.getEndpoint() + ":" + nexus.getService() + ":" + nexus.getOperation(); + List encoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + encoded.add( + Payload.newBuilder(payload) + .putMetadata(SIGNATURE_KEY, ByteString.copyFromUtf8(signature)) + .build()); + } + return encoded; + } + + @Override + @Nonnull + public List decode(@Nonnull List payloads) { + seen.add(context); + List decoded = new ArrayList<>(payloads.size()); + for (Payload payload : payloads) { + // Payloads encoded without a context stay readable, as the contract requires. + if (payload.getMetadataMap().containsKey(SIGNATURE_KEY)) { + decoded.add(Payload.newBuilder(payload).removeMetadata(SIGNATURE_KEY).build()); + } else { + decoded.add(payload); + } + } + return decoded; + } + } +} From 3e327b9674929ad46986b0f7515df1b537142059 Mon Sep 17 00:00:00 2001 From: Maciej Dudkowski Date: Fri, 18 Sep 2026 13:36:23 -0400 Subject: [PATCH 106/107] :boom: Use ActivitySerializationContext when starting and getting result of Standalone Activity (#3063) --- .../client/RootActivityClientInvoker.java | 27 +- .../context/ActivitySerializationContext.java | 26 +- .../ContextAwareDataConverterTest.java | 540 ++++++++++++++++++ 3 files changed, 578 insertions(+), 15 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/payload/context/ContextAwareDataConverterTest.java diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java index fc85f0039f..55297194fe 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootActivityClientInvoker.java @@ -32,6 +32,7 @@ import io.temporal.internal.nexus.CurrentNexusOperationContext; import io.temporal.internal.nexus.InternalNexusOperationContext; import io.temporal.internal.nexus.NexusOperationMetadata; +import io.temporal.payload.context.ActivitySerializationContext; import io.temporal.serviceclient.StatusUtils; import java.lang.reflect.Type; import java.util.*; @@ -61,7 +62,18 @@ public StartActivityOutput startActivity(StartActivityInput input) { if (Strings.isNullOrEmpty(options.getTaskQueue())) { throw new IllegalArgumentException("taskQueue must not be null or empty"); } - DataConverter dc = clientOptions.getDataConverter(); + DataConverter dc = + clientOptions + .getDataConverter() + .withContext( + new ActivitySerializationContext( + clientOptions.getNamespace(), + null, + null, + input.getActivityType(), + options.getTaskQueue(), + false)); + InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.isNexusContext() ? CurrentNexusOperationContext.get() : null; NexusOperationMetadata nexusOperationMetadata = @@ -201,7 +213,8 @@ public StartActivityOutput startActivity(StartActivityInput input) { public GetActivityResultOutput getActivityResult(GetActivityResultInput input) throws TimeoutException { String namespace = clientOptions.getNamespace(); - DataConverter dc = clientOptions.getDataConverter(); + DataConverter dc = + clientOptions.getDataConverter().withContext(resultSerializationContext(input)); Deadline deadline = Deadline.after(input.getTimeout(), input.getTimeoutUnit()); while (true) { @@ -276,7 +289,8 @@ public GetActivityResultOutput getActivityResult(GetActivityResultInput CompletableFuture> getActivityResultAsync( GetActivityResultInput input) { - DataConverter dc = clientOptions.getDataConverter(); + DataConverter dc = + clientOptions.getDataConverter().withContext(resultSerializationContext(input)); Deadline deadline = Deadline.after(input.getTimeout(), input.getTimeoutUnit()); return pollActivityUntilOutcome(input, deadline) .handle( @@ -358,6 +372,13 @@ private GetActivityResultOutput decodeOutcome( } } + private ActivitySerializationContext resultSerializationContext(GetActivityResultInput input) { + // Currently, result doesn't have access to activity type and serialization context doesn't hold + // activity ID. + return new ActivitySerializationContext( + clientOptions.getNamespace(), null, null, null, null, false); + } + @Override public DescribeActivityOutput describeActivity(DescribeActivityInput input) { DescribeActivityExecutionRequest.Builder req = diff --git a/temporal-sdk/src/main/java/io/temporal/payload/context/ActivitySerializationContext.java b/temporal-sdk/src/main/java/io/temporal/payload/context/ActivitySerializationContext.java index 57dcfb3138..78ba63d247 100644 --- a/temporal-sdk/src/main/java/io/temporal/payload/context/ActivitySerializationContext.java +++ b/temporal-sdk/src/main/java/io/temporal/payload/context/ActivitySerializationContext.java @@ -11,32 +11,34 @@ public class ActivitySerializationContext implements HasWorkflowSerializationCon private final @Nonnull String namespace; private final @Nullable String workflowId; private final @Nullable String workflowType; - private final @Nonnull String activityType; - private final @Nonnull String activityTaskQueue; + private final @Nullable String activityType; + private final @Nullable String activityTaskQueue; private final boolean local; /** * @param namespace the activity's namespace; must not be {@code null} * @param workflowId the workflow ID that scheduled the activity, or {@code null} for standalone - * activities (stored as an empty string) + * activities * @param workflowType the workflow type that scheduled the activity, or {@code null} for - * standalone activities (stored as an empty string) - * @param activityType the activity type name; must not be {@code null} - * @param activityTaskQueue the task queue for this activity; must not be {@code null} + * standalone activities + * @param activityType the activity type name, or {@code null} if unknown. Activity type is + * unknown when getting a Standalone Activity result. + * @param activityTaskQueue the task queue for this activity, or {@code null} if unknown. Task + * queue is unknown when getting a Standalone Activity result. * @param local {@code true} if this is a local activity */ public ActivitySerializationContext( @Nonnull String namespace, @Nullable String workflowId, @Nullable String workflowType, - @Nonnull String activityType, - @Nonnull String activityTaskQueue, + @Nullable String activityType, + @Nullable String activityTaskQueue, boolean local) { this.namespace = Objects.requireNonNull(namespace); this.workflowId = workflowId; this.workflowType = workflowType; - this.activityType = Objects.requireNonNull(activityType); - this.activityTaskQueue = Objects.requireNonNull(activityTaskQueue); + this.activityType = activityType; + this.activityTaskQueue = activityTaskQueue; this.local = local; } @@ -67,12 +69,12 @@ public String getWorkflowType() { return workflowType; } - @Nonnull + @Nullable public String getActivityType() { return activityType; } - @Nonnull + @Nullable public String getActivityTaskQueue() { return activityTaskQueue; } diff --git a/temporal-sdk/src/test/java/io/temporal/payload/context/ContextAwareDataConverterTest.java b/temporal-sdk/src/test/java/io/temporal/payload/context/ContextAwareDataConverterTest.java new file mode 100644 index 0000000000..273911048c --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/payload/context/ContextAwareDataConverterTest.java @@ -0,0 +1,540 @@ +package io.temporal.payload.context; + +import static org.junit.Assume.assumeTrue; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.activity.LocalActivityOptions; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.history.v1.HistoryEvent; +import io.temporal.client.*; +import io.temporal.common.converter.DataConverter; +import io.temporal.common.converter.DataConverterException; +import io.temporal.common.converter.GlobalDataConverter; +import io.temporal.internal.history.LocalActivityMarkerUtils; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.lang.reflect.Type; +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; + +public class ContextAwareDataConverterTest { + @ActivityInterface + public interface Activities { + @ActivityMethod(name = "HelloActivity") + TracedValue hello(TracedValue input); + } + + public static class ActivitiesImpl implements Activities { + @Override + public TracedValue hello(TracedValue input) { + return new TracedValue("Hello " + input.getValue()); + } + } + + @WorkflowInterface + public interface HelloWorkflow { + @WorkflowMethod + TracedValue execute(TracedValue arg, boolean local); + } + + public static class HelloWorkflowImpl implements HelloWorkflow { + private final Activities activities = + Workflow.newActivityStub( + Activities.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + private final Activities localActivities = + Workflow.newLocalActivityStub( + Activities.class, + LocalActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build()); + + @Override + public TracedValue execute(TracedValue arg, boolean local) { + if (local) { + return localActivities.hello(arg); + } else { + return activities.hello(arg); + } + } + } + + private static final String TAG_WORKER = "worker"; + private static final String TAG_CLIENT = "client"; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(HelloWorkflowImpl.class) + .setActivityImplementations(new ActivitiesImpl()) + .setWorkflowClientOptions( + WorkflowClientOptions.newBuilder() + .setDataConverter(new TracingDataConverter(GlobalDataConverter.get(), TAG_WORKER)) + .build()) + .setActivityClientOptions( + ActivityClientOptions.newBuilder() + .setNamespace(SDKTestWorkflowRule.NAMESPACE) + .setDataConverter(new TracingDataConverter(GlobalDataConverter.get(), TAG_CLIENT)) + .build()) + .build(); + + @Test + public void standaloneActivitySerializationContext() { + assumeTrue( + "Test server doesn't support standalone activities", + testWorkflowRule.isUseExternalService()); + + String activityId = "act-" + UUID.randomUUID(); + + ActivityHandle handle = + testWorkflowRule + .getActivityClient() + .start( + Activities.class, + Activities::hello, + StartActivityOptions.newBuilder() + .setId(activityId) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build(), + new TracedValue("world")); + + ActivitySerializationContext workerContext = + new ActivitySerializationContext( + SDKTestWorkflowRule.NAMESPACE, + null, + null, + "HelloActivity", + testWorkflowRule.getTaskQueue(), + false); + + // Currently, client doesn't set activityType in serialization context + ActivitySerializationContext clientContext = + new ActivitySerializationContext( + SDKTestWorkflowRule.NAMESPACE, + null, + null, + null, + testWorkflowRule.getTaskQueue(), + false); + + TracedValue expected = + new TracedValue("Hello world") + .addTrace( + TraceEntry.encode(workerContext, TAG_WORKER), + TraceEntry.decode(clientContext, TAG_CLIENT)); + + Assert.assertEquals(expected, handle.getResult()); + Assert.assertEquals(expected, handle.getResultAsync().join()); + } + + @Test + public void workflowActivitySerializationContext() { + WorkflowClient client = getWorkflowClient(); + + HelloWorkflow workflow = + client.newWorkflowStub( + HelloWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowRunTimeout(Duration.ofSeconds(10)) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .build()); + + TracedValue result = workflow.execute(new TracedValue("world"), false); + WorkflowExecution execution = WorkflowStub.fromTyped(workflow).getExecution(); + Assert.assertNotNull(execution); + List history = + client + .fetchHistory(execution.getWorkflowId(), execution.getRunId()) + .getHistory() + .getEventsList(); + List scheduledEvents = + history.stream() + .filter(HistoryEvent::hasActivityTaskScheduledEventAttributes) + .collect(Collectors.toList()); + Assert.assertEquals(1, scheduledEvents.size()); + HistoryEvent scheduledEvent = scheduledEvents.get(0); + List completedEvents = + history.stream() + .filter(HistoryEvent::hasActivityTaskCompletedEventAttributes) + .collect(Collectors.toList()); + Assert.assertEquals(1, completedEvents.size()); + HistoryEvent completedEvent = completedEvents.get(0); + Assert.assertEquals( + scheduledEvent.getEventId(), + completedEvent.getActivityTaskCompletedEventAttributes().getScheduledEventId()); + + WorkflowSerializationContext workflowContext = + new WorkflowSerializationContext(SDKTestWorkflowRule.NAMESPACE, execution.getWorkflowId()); + + ActivitySerializationContext activityContext = + new ActivitySerializationContext( + SDKTestWorkflowRule.NAMESPACE, + execution.getWorkflowId(), + "HelloWorkflow", + "HelloActivity", + testWorkflowRule.getTaskQueue(), + false); + + Assert.assertEquals( + new TracedValue("Hello world") + .addTrace( + TraceEntry.encode(activityContext, TAG_WORKER), + TraceEntry.decode(activityContext, TAG_WORKER), + TraceEntry.encode(workflowContext, TAG_WORKER), + TraceEntry.decode(workflowContext, TAG_CLIENT)), + result); + + Assert.assertEquals( + new TracedValue("world") + .addTrace( + TraceEntry.encode(workflowContext, TAG_CLIENT), + TraceEntry.decode(workflowContext, TAG_WORKER), + TraceEntry.encode(activityContext, TAG_WORKER)), + GlobalDataConverter.get() + .fromPayloads( + 0, + Optional.of(scheduledEvent.getActivityTaskScheduledEventAttributes().getInput()), + TracedValue.class, + TracedValue.class)); + + Assert.assertEquals( + new TracedValue("Hello world").addTrace(TraceEntry.encode(activityContext, TAG_WORKER)), + GlobalDataConverter.get() + .fromPayloads( + 0, + Optional.of(completedEvent.getActivityTaskCompletedEventAttributes().getResult()), + TracedValue.class, + TracedValue.class)); + } + + @Test + public void localActivitySerializationContext() { + WorkflowClient client = getWorkflowClient(); + + HelloWorkflow workflow = + client.newWorkflowStub( + HelloWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowRunTimeout(Duration.ofSeconds(10)) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .build()); + + TracedValue result = workflow.execute(new TracedValue("world"), true); + WorkflowExecution execution = WorkflowStub.fromTyped(workflow).getExecution(); + Assert.assertNotNull(execution); + List history = + client + .fetchHistory(execution.getWorkflowId(), execution.getRunId()) + .getHistory() + .getEventsList(); + List markerEvents = + history.stream() + .filter(HistoryEvent::hasMarkerRecordedEventAttributes) + .collect(Collectors.toList()); + Assert.assertEquals(1, markerEvents.size()); + HistoryEvent markerEvent = markerEvents.get(0); + Assert.assertTrue(LocalActivityMarkerUtils.hasLocalActivityStructure(markerEvent)); + + WorkflowSerializationContext workflowContext = + new WorkflowSerializationContext(SDKTestWorkflowRule.NAMESPACE, execution.getWorkflowId()); + + ActivitySerializationContext activityContext = + new ActivitySerializationContext( + SDKTestWorkflowRule.NAMESPACE, + execution.getWorkflowId(), + "HelloWorkflow", + "HelloActivity", + testWorkflowRule.getTaskQueue(), + true); + + Assert.assertEquals( + new TracedValue("Hello world") + .addTrace( + TraceEntry.encode(activityContext, TAG_WORKER), + TraceEntry.decode(activityContext, TAG_WORKER), + TraceEntry.encode(workflowContext, TAG_WORKER), + TraceEntry.decode(workflowContext, TAG_CLIENT)), + result); + + Assert.assertEquals( + new TracedValue("Hello world").addTrace(TraceEntry.encode(activityContext, TAG_WORKER)), + GlobalDataConverter.get() + .fromPayloads( + 0, + Optional.ofNullable( + LocalActivityMarkerUtils.getResult( + markerEvent.getMarkerRecordedEventAttributes())), + TracedValue.class, + TracedValue.class)); + } + + private WorkflowClient getWorkflowClient() { + WorkflowClient client = testWorkflowRule.getWorkflowClient(); + WorkflowClientOptions options = + client.getOptions().toBuilder() + .setDataConverter(new TracingDataConverter(GlobalDataConverter.get(), TAG_CLIENT)) + .build(); + return WorkflowClient.newInstance(client.getWorkflowServiceStubs(), options); + } + + private static class TracingDataConverter implements DataConverter { + private final DataConverter dc; + private final String tag; + private final SerializationContext context; + + public TracingDataConverter(DataConverter dc, String tag) { + this(dc, tag, null); + } + + private TracingDataConverter(DataConverter dc, String tag, SerializationContext context) { + this.dc = dc; + this.tag = tag; + this.context = context; + } + + @Override + public Optional toPayload(T value) throws DataConverterException { + if (value instanceof TracedValue) { + return dc.toPayload(((TracedValue) value).addTrace(TraceEntry.encode(context, tag))); + } else { + return dc.toPayload(value); + } + } + + @Override + public T fromPayload(Payload payload, Class valueClass, Type valueType) + throws DataConverterException { + if (valueClass == TracedValue.class) { + return valueClass.cast( + dc.fromPayload(payload, TracedValue.class, valueType) + .addTrace(TraceEntry.decode(context, tag))); + } else { + return dc.fromPayload(payload, valueClass, valueType); + } + } + + @Override + public Optional toPayloads(Object... values) throws DataConverterException { + if (Arrays.stream(values).anyMatch(v -> v instanceof TracedValue)) { + Payloads.Builder builder = Payloads.newBuilder(); + for (Object v : values) { + builder.addPayloads(toPayload(v).get()); + } + return Optional.of(builder.build()); + } else { + return dc.toPayloads(values); + } + } + + @Override + public T fromPayloads( + int index, Optional content, Class valueType, Type valueGenericType) + throws DataConverterException { + if (valueType == TracedValue.class) { + return valueType.cast( + dc.fromPayloads(index, content, TracedValue.class, valueGenericType) + .addTrace(TraceEntry.decode(context, tag))); + } else { + return dc.fromPayloads(index, content, valueType, valueGenericType); + } + } + + @Override + public @NonNull DataConverter withContext(@NonNull SerializationContext context) { + return new TracingDataConverter(dc, tag, context); + } + } + + public static class TracedValue { + private final String value; + private final ArrayList trace; + + public TracedValue(String value) { + this.value = value; + this.trace = new ArrayList<>(); + } + + @JsonCreator + public TracedValue( + @JsonProperty("value") String value, @JsonProperty("trace") List trace) { + this.value = value; + this.trace = new ArrayList<>(trace); + } + + public String getValue() { + return value; + } + + public List getTrace() { + return Collections.unmodifiableList(trace); + } + + public TracedValue addTrace(TraceEntry... entries) { + return new TracedValue( + value, + Stream.concat(trace.stream(), Arrays.stream(entries)).collect(Collectors.toList())); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + TracedValue that = (TracedValue) o; + return Objects.equals(value, that.value) && Objects.equals(trace, that.trace); + } + + @Override + public int hashCode() { + return Objects.hash(value, trace); + } + + @Override + public String toString() { + return "TracedValue{" + "value='" + value + '\'' + ", trace=" + trace + '}'; + } + } + + public static class TraceEntry { + private final @NonNull String tag; + private final Operation operation; + private final @Nullable String namespace; + private final @Nullable String workflowId; + private final @Nullable String activityType; + private final @Nullable Boolean local; + + @JsonCreator + public TraceEntry( + @JsonProperty("tag") @NonNull String tag, + @JsonProperty("operation") Operation operation, + @JsonProperty("namespace") @Nullable String namespace, + @JsonProperty("workflowId") @Nullable String workflowId, + @JsonProperty("activityType") @Nullable String activityType, + @JsonProperty("local") @Nullable Boolean local) { + this.tag = tag; + this.operation = operation; + this.namespace = namespace; + this.workflowId = workflowId; + this.activityType = activityType; + this.local = local; + } + + public TraceEntry( + @Nullable SerializationContext context, @NonNull String tag, Operation operation) { + this.tag = tag; + this.operation = operation; + if (context == null) { + namespace = null; + workflowId = null; + activityType = null; + local = null; + } else if (context instanceof WorkflowSerializationContext) { + WorkflowSerializationContext c = (WorkflowSerializationContext) context; + namespace = c.getNamespace(); + workflowId = c.getWorkflowId(); + activityType = null; + local = null; + } else if (context instanceof ActivitySerializationContext) { + ActivitySerializationContext c = (ActivitySerializationContext) context; + namespace = c.getNamespace(); + workflowId = c.getWorkflowId(); + activityType = c.getActivityType(); + local = c.isLocal(); + } else { + throw new IllegalArgumentException( + "Unknown context type: " + context.getClass().getCanonicalName()); + } + } + + public static TraceEntry encode(@NonNull SerializationContext context, @NonNull String tag) { + return new TraceEntry(context, tag, Operation.Encode); + } + + public static TraceEntry decode(@NonNull SerializationContext context, @NonNull String tag) { + return new TraceEntry(context, tag, Operation.Decode); + } + + public @NonNull String getTag() { + return tag; + } + + public Operation getOperation() { + return operation; + } + + public @Nullable String getNamespace() { + return namespace; + } + + public @Nullable String getWorkflowId() { + return workflowId; + } + + public @Nullable String getActivityType() { + return activityType; + } + + public @Nullable Boolean isLocal() { + return local; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + TraceEntry that = (TraceEntry) o; + return Objects.equals(tag, that.tag) + && operation == that.operation + && Objects.equals(namespace, that.namespace) + && Objects.equals(workflowId, that.workflowId) + && Objects.equals(activityType, that.activityType) + && Objects.equals(local, that.local); + } + + @Override + public int hashCode() { + return Objects.hash(tag, operation, namespace, workflowId, activityType, local); + } + + @Override + public String toString() { + return "TraceEntry{" + + "tag='" + + tag + + '\'' + + ", operation=" + + operation + + ", namespace='" + + namespace + + '\'' + + ", workflowId='" + + workflowId + + '\'' + + ", activityType='" + + activityType + + '\'' + + ", local=" + + local + + '}'; + } + + public enum Operation { + Encode, + Decode + } + } +} From 961a35e8ddc8e03fbdea035a1b1006db7562e19c Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Fri, 18 Sep 2026 11:32:54 -0700 Subject: [PATCH 107/107] Nexus serialization consistency (#3084) * Results of a consistency pass between Python, DotNet, and Java for NexusSerializationContext * Updating summary and detail to use context --- .../NexusOperationExecutionDescription.java | 17 +-- .../client/RootNexusClientInvoker.java | 15 +-- .../internal/nexus/NexusTaskHandlerImpl.java | 33 ++++-- .../internal/sync/SyncWorkflowContext.java | 3 +- .../internal/worker/NexusTaskHandler.java | 23 ++++ .../temporal/internal/worker/NexusWorker.java | 14 ++- ...andaloneNexusSerializationContextTest.java | 19 +++- ...usTaskHandlerSerializationContextTest.java | 100 +++++++++++++++++- .../nexus/NexusSerializationContextTest.java | 33 ++++-- 9 files changed, 213 insertions(+), 44 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java index 2e9ddfc337..4717bcdbe4 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/NexusOperationExecutionDescription.java @@ -25,22 +25,10 @@ public final class NexusOperationExecutionDescription extends NexusOperationExec private final DescribeNexusOperationExecutionResponse response; private final NexusOperationExecutionInfo info; private final DataConverter dataConverterWithNexusContext; - // User metadata is attached by the caller without a Nexus serialization context, so it has to be - // decoded without one too. Everything else on a description belongs to the operation and is - // decoded with the operation's context. - private final DataConverter contextlessDataConverter; - - public NexusOperationExecutionDescription( - DescribeNexusOperationExecutionResponse response, - DataConverter dataConverter, - String namespace) { - this(response, dataConverter, dataConverter, namespace); - } public NexusOperationExecutionDescription( DescribeNexusOperationExecutionResponse response, DataConverter dataConverterWithNexusContext, - DataConverter contextlessDataConverter, String namespace) { super( null, @@ -64,7 +52,6 @@ public NexusOperationExecutionDescription( this.response = response; this.info = response.getInfo(); this.dataConverterWithNexusContext = dataConverterWithNexusContext; - this.contextlessDataConverter = contextlessDataConverter; } /** Underlying proto response. Exposed while the Nexus SDK surface is still experimental. */ @@ -197,7 +184,7 @@ public String getStaticSummary() { if (!info.hasUserMetadata() || !info.getUserMetadata().hasSummary()) { return null; } - return contextlessDataConverter.fromPayload( + return dataConverterWithNexusContext.fromPayload( info.getUserMetadata().getSummary(), String.class, String.class); } @@ -210,7 +197,7 @@ public String getStaticDetails() { if (!info.hasUserMetadata() || !info.getUserMetadata().hasDetails()) { return null; } - return contextlessDataConverter.fromPayload( + return dataConverterWithNexusContext.fromPayload( info.getUserMetadata().getDetails(), String.class, String.class); } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java index 8d519b21db..d92e2f3989 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/client/RootNexusClientInvoker.java @@ -108,7 +108,13 @@ public StartNexusOperationExecutionOutput startNexusOperationExecution( if (options.getSummary() != null) { UserMetadata metadata = WorkflowExecutionUtils.makeUserMetaData( - options.getSummary(), null, clientOptions.getDataConverter()); + options.getSummary(), + null, + clientOptions + .getDataConverter() + .withContext( + new NexusSerializationContext( + input.getEndpoint(), input.getService(), input.getOperation()))); if (metadata != null) { request.setUserMetadata(metadata); } @@ -154,12 +160,7 @@ public DescribeNexusOperationExecutionOutput describeNexusOperationExecution( info.getEndpoint(), info.getService(), info.getOperation())); return new DescribeNexusOperationExecutionOutput( new NexusOperationExecutionDescription( - response, - dataConverter, - // The summary and details were attached without a Nexus context, so a converter that - // varies by context only round-trips them if they are decoded without one too. - clientOptions.getDataConverter(), - clientOptions.getNamespace())); + response, dataConverter, clientOptions.getNamespace())); } /** diff --git a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java index becea3bf97..0da2ebf077 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/nexus/NexusTaskHandlerImpl.java @@ -37,6 +37,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Collectors; import javax.annotation.Nonnull; +import javax.annotation.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -138,10 +139,13 @@ public Result handle(NexusTask task, Scope metricsScope) throws TimeoutException (Throwable) null); } } catch (HandlerException e) { - return new Result(e); + // The context is still in scope here but not when the reply is encoded, so carry it out on + // the result. + return new Result(e, currentSerializationContext()); } catch (Throwable e) { return new Result( - new HandlerException(HandlerException.ErrorType.INTERNAL, "internal handler error", e)); + new HandlerException(HandlerException.ErrorType.INTERNAL, "internal handler error", e), + currentSerializationContext()); } finally { // If the task timed out, we should not send a response back to the server if (timedOut.get()) { @@ -159,23 +163,38 @@ public Result handle(NexusTask task, Scope metricsScope) throws TimeoutException * Records the serialization context for the operation this task is for, so that the data * converter used for its input, result and failures is scoped to the endpoint, service and * operation the request names. + * + *

Note that servers before 1.30.0 do not report the endpoint the task was addressed to, so the + * context is scoped by an empty endpoint there and does not agree with the caller's. Nexus + * serialization context on the handler side requires server 1.30.0 or later. */ private void setSerializationContext(String service, String operation) { InternalNexusOperationContext nexusContext = CurrentNexusOperationContext.get(); + String endpoint = nexusContext.getEndpoint(); nexusContext.setSerializationContext( - new NexusSerializationContext(nexusContext.getEndpoint(), service, operation)); + new NexusSerializationContext(endpoint, service, operation)); } /** - * The data converter scoped to the operation this task is for. Falls back to the uncontextualized - * converter if the request variant did not name a service and operation. + * The data converter scoped to the operation this task is for, or the uncontextualized converter + * when there is no Nexus task in scope. */ private DataConverter dataConverterForCurrentOperation() { - NexusSerializationContext context = - CurrentNexusOperationContext.get().getSerializationContext(); + NexusSerializationContext context = currentSerializationContext(); return context != null ? dataConverter.withContext(context) : dataConverter; } + /** + * Serialization context of the operation currently being handled, or null if there is no Nexus + * task in scope or the request variant did not name a service and operation. + */ + private static @Nullable NexusSerializationContext currentSerializationContext() { + if (!CurrentNexusOperationContext.isNexusContext()) { + return null; + } + return CurrentNexusOperationContext.get().getSerializationContext(); + } + private void cancelOperation(OperationContext context, OperationCancelDetails details) { try { serviceHandler.cancelOperation(context, details); diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index e38c6ea4d3..742a663f8f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -827,8 +827,7 @@ public ExecuteNexusOperationOutput executeNexusOperation( @Nullable UserMetadata userMetadata = - makeUserMetaData( - input.getOptions().getSummary(), null, dataConverterWithCurrentWorkflowContext); + makeUserMetaData(input.getOptions().getSummary(), null, nexusDataConverter); StartNexusOperationParameters parameters = new StartNexusOperationParameters( diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusTaskHandler.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusTaskHandler.java index d2df027adb..f11eefc8a9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusTaskHandler.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusTaskHandler.java @@ -3,6 +3,7 @@ import com.uber.m3.tally.Scope; import io.nexusrpc.handler.HandlerException; import io.temporal.api.nexus.v1.Response; +import io.temporal.payload.context.NexusSerializationContext; import java.util.Objects; import java.util.concurrent.TimeoutException; import javax.annotation.Nonnull; @@ -23,19 +24,41 @@ public interface NexusTaskHandler { class Result { @Nullable private final Response response; @Nullable private final HandlerException handlerException; + // Serialization context of the operation the task was for. Carried on the result because the + // reply is encoded after the handler has returned, by which point the per-task context is no + // longer in scope. Null when the task named no operation, or when the server did not report + // the endpoint it was addressed to. + @Nullable private final NexusSerializationContext serializationContext; public Result(@Nonnull Response response) { Objects.requireNonNull(response); this.response = response; handlerException = null; + serializationContext = null; } public Result(@Nonnull HandlerException handlerException) { + this(handlerException, null); + } + + public Result( + @Nonnull HandlerException handlerException, + @Nullable NexusSerializationContext serializationContext) { Objects.requireNonNull(handlerException); this.handlerException = handlerException; + this.serializationContext = serializationContext; response = null; } + /** + * Serialization context to encode {@link #getHandlerException()} with, or null to encode it + * without one. + */ + @Nullable + public NexusSerializationContext getSerializationContext() { + return serializationContext; + } + @Nullable public Response getResponse() { return response; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java index 1e5e4105a4..9cd2e14aa4 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/worker/NexusWorker.java @@ -22,6 +22,7 @@ import io.temporal.internal.payload.storage.ExternalStorageNotConfiguredException; import io.temporal.internal.payload.storage.ExternalStorageRunner; import io.temporal.internal.retryer.GrpcRetryer; +import io.temporal.payload.context.NexusSerializationContext; import io.temporal.serviceclient.MetricsTag; import io.temporal.serviceclient.WorkflowServiceStubs; import io.temporal.serviceclient.rpcretry.DefaultStubServiceOperationRpcRetryOptions; @@ -550,10 +551,19 @@ private void sendReply( .setTaskToken(taskToken) .setIdentity(options.getIdentity()) .setNamespace(namespace); + // The caller decodes this failure with the operation's context, so it has to be encoded + // with the same one. The context rides on the result because it is no longer in scope by + // the time the reply is built. + NexusSerializationContext serializationContext = response.getSerializationContext(); + DataConverter dataConverterWithContext = + serializationContext != null + ? dataConverter.withContext(serializationContext) + : dataConverter; if (supportTemporalFailure) { - request.setFailure(dataConverter.exceptionToFailure(handlerException)); + request.setFailure(dataConverterWithContext.exceptionToFailure(handlerException)); } else { - request.setError(NexusUtil.handlerErrorToNexusError(handlerException, dataConverter)); + request.setError( + NexusUtil.handlerErrorToNexusError(handlerException, dataConverterWithContext)); } if (useExternalStorage) { storeOutbound(request); diff --git a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java index 8ee35c8999..733b6aa6cf 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/nexus/StandaloneNexusSerializationContextTest.java @@ -51,6 +51,10 @@ public class StandaloneNexusSerializationContextTest { // codec keyed on the context would. Only the client gets the recording failure converter, so the // contexts it records are the client's. private static final RecordingCodec CODEC = new RecordingCodec(); + + // A handle obtained by operation ID decodes a context-encoded payload without a context, so + // that direction is only an error when a test says it should be. + private static boolean allowContextlessDecodeOfSignedPayload; private static final RecordingFailureConverter FAILURE_CONVERTER = new RecordingFailureConverter(); @@ -87,6 +91,7 @@ public void requireStandaloneNexusSupport() { testWorkflowRule.isUseExternalService()); CODEC.reset(); FAILURE_CONVERTER.reset(); + allowContextlessDecodeOfSignedPayload = false; } @Test @@ -154,7 +159,7 @@ public void describeDecodesTheLastAttemptFailureWithContext() { } @Test - public void describeReadsTheUncontextualizedSummary() { + public void describeReadsTheSummaryUnderTheOperationsContext() { NexusClient client = nexusClient(); Endpoint endpoint = testWorkflowRule.getNexusEndpoint(); UntypedNexusServiceClient serviceClient = @@ -170,13 +175,14 @@ public void describeReadsTheUncontextualizedSummary() { "ping-" + UUID.randomUUID()); handle.getResult(String.class); - // The summary is encoded without a Nexus context, so describe must read it back the same way. - // Decoding it under a context the encoder never used would corrupt it. + // User metadata is serialized with the operation's context, so describe has to read it back + // under the same one. The strict codec below fails either half of a context mismatch. Assert.assertEquals("the-summary", handle.describe().getStaticSummary()); } @Test public void handleObtainedByIdHasNoContext() { + allowContextlessDecodeOfSignedPayload = true; String input = "ping-" + UUID.randomUUID(); UntypedNexusOperationHandle started = startOperation(input); started.getResult(String.class); @@ -359,6 +365,13 @@ public List decode(@Nonnull List payloads) { decoded.add(payload); continue; } + if (!(context instanceof NexusSerializationContext)) { + // The reverse mismatch: encoded under a context, decoded without one. Expected only + // for a handle obtained by operation ID, which opts in below. + Assert.assertTrue( + "payload encoded under a Nexus context was decoded under " + context, + allowContextlessDecodeOfSignedPayload); + } if (context instanceof NexusSerializationContext) { NexusSerializationContext nexus = (NexusSerializationContext) context; Assert.assertEquals( diff --git a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java index bebb0cc3ac..35328b15f3 100644 --- a/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java +++ b/temporal-sdk/src/test/java/io/temporal/internal/nexus/NexusTaskHandlerSerializationContextTest.java @@ -21,6 +21,7 @@ import io.temporal.common.converter.DefaultDataConverter; import io.temporal.common.interceptors.WorkerInterceptor; import io.temporal.common.reporter.TestStatsReporter; +import io.temporal.failure.ApplicationFailure; import io.temporal.internal.worker.NexusTask; import io.temporal.internal.worker.NexusTaskHandler; import io.temporal.payload.codec.PayloadCodec; @@ -105,12 +106,63 @@ public void operationFailureUsesOperationContext() throws TimeoutException { Failure failure = result.getResponse().getStartOperation().getFailure(); Assert.assertNotEquals( "the operation should have reported a failure", Failure.getDefaultInstance(), failure); + Assert.assertTrue( + "the failure conversion should have reached the codec; if it did not, this test cannot " + + "distinguish a contextual failure encode from a contextless one", + handlerCodec.contexts().size() > 1); Assert.assertEquals( "every converter call the handler made should be under the operation's context", Collections.singleton(expected), new java.util.HashSet<>(handlerCodec.contexts())); } + @Test + public void handlerErrorCarriesTheOperationContextOnTheResult() throws TimeoutException { + // A throwable that is not an OperationException becomes a HandlerException, and its failure is + // encoded by the worker after the per-task context has gone out of scope. The context has to + // travel out on the result for the caller, which decodes with it, to agree. + NexusSerializationContext expected = + new NexusSerializationContext(ENDPOINT, SERVICE, OPERATION); + DataConverter converter = signingConverter(new SigningCodec()); + Payload input = converter.withContext(expected).toPayload("boom").get(); + + NexusTaskHandler.Result result = handle(converter, new ThrowingServiceImpl(), startTask(input)); + + Assert.assertNotNull("expected a handler error", result.getHandlerException()); + Assert.assertEquals( + "the handler error should carry the operation's context out to the reply", + expected, + result.getSerializationContext()); + } + + @Test + public void cancelTaskCarriesTheOperationContextOnTheResult() throws TimeoutException { + // Cancel tasks name an operation too, and a failure reported for one is encoded on the same + // out-of-scope path as a start task's. + NexusSerializationContext expected = + new NexusSerializationContext(ENDPOINT, SERVICE, OPERATION); + + NexusTaskHandler.Result result = + handle( + signingConverter(new SigningCodec()), + new ThrowingServiceImpl(), + PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setEndpoint(ENDPOINT) + .setCancelOperation( + io.temporal.api.nexus.v1.CancelOperationRequest.newBuilder() + .setService(SERVICE) + .setOperation(OPERATION) + .setOperationToken("token")))); + + Assert.assertNotNull("expected a handler error", result.getHandlerException()); + Assert.assertEquals( + "a cancel task's failure should carry the operation's context too", + expected, + result.getSerializationContext()); + } + @Test public void serializerWithoutTaskInScopeUsesContextlessConverter() { // The serializer is shared by the whole worker and is also reachable outside of a Nexus task, @@ -127,6 +179,36 @@ public void serializerWithoutTaskInScopeUsesContextlessConverter() { codec.contexts()); } + @Test + public void taskWithoutAnEndpointIsScopedByAnEmptyEndpoint() throws TimeoutException { + // Servers before 1.30.0 do not report the endpoint a Nexus task was addressed to. The handler + // still scopes by service and operation, with an empty endpoint, which will not agree with the + // caller's context but is a Nexus context rather than an absent one. + NexusSerializationContext expected = new NexusSerializationContext("", SERVICE, OPERATION); + DataConverter callerConverter = signingConverter(new SigningCodec()); + SigningCodec handlerCodec = new SigningCodec(); + DataConverter handlerConverter = signingConverter(handlerCodec); + Payload input = callerConverter.withContext(expected).toPayload("no-endpoint").get(); + + handle(handlerConverter, new EchoServiceImpl(), startTaskWithoutEndpoint(input)); + + Assert.assertEquals( + "an absent endpoint should still produce a context scoped by service and operation", + java.util.Arrays.asList(expected, expected), + handlerCodec.contexts()); + } + + private static PollNexusTaskQueueResponse.Builder startTaskWithoutEndpoint(Payload input) { + return PollNexusTaskQueueResponse.newBuilder() + .setRequest( + Request.newBuilder() + .setStartOperation( + StartOperationRequest.newBuilder() + .setService(SERVICE) + .setOperation(OPERATION) + .setPayload(input))); + } + private static DataConverter signingConverter(SigningCodec codec) { return new CodecDataConverter( DefaultDataConverter.STANDARD_INSTANCE, Collections.singletonList(codec)); @@ -172,13 +254,29 @@ public OperationHandler operation() { } } + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) + public static class ThrowingServiceImpl { + @OperationImpl + public OperationHandler operation() { + return io.nexusrpc.handler.OperationHandler.sync( + (ctx, details, name) -> { + throw new RuntimeException("not an operation failure"); + }); + } + } + @ServiceImpl(service = TestNexusServices.TestNexusService1.class) public static class FailingServiceImpl { @OperationImpl public OperationHandler operation() { return io.nexusrpc.handler.OperationHandler.sync( (ctx, details, name) -> { - throw OperationException.failed(name); + // The cause carries details so the failure conversion actually reaches the codec. A + // failure with no details and no encoded attributes converts without touching it, and + // an assertion on the codec would then prove nothing. + throw OperationException.failed( + ApplicationFailure.newNonRetryableFailure( + name, "ContextFailure", "failure-detail")); }); } } diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java index 2a4daa8fc7..a62b3b689a 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/nexus/NexusSerializationContextTest.java @@ -16,6 +16,7 @@ import io.temporal.client.WorkflowStub; import io.temporal.common.converter.CodecDataConverter; import io.temporal.common.converter.DefaultDataConverter; +import io.temporal.failure.ApplicationFailure; import io.temporal.payload.codec.PayloadCodec; import io.temporal.payload.context.NexusSerializationContext; import io.temporal.payload.context.SerializationContext; @@ -33,6 +34,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; import javax.annotation.Nonnull; import org.junit.After; import org.junit.Assert; @@ -51,8 +53,10 @@ *

Nexus requires a real server, so these are skipped unless {@code USE_EXTERNAL_SERVICE=true}. */ public class NexusSerializationContextTest { - private static final String RED_ENDPOINT = "red-nexus-endpoint"; - private static final String BLUE_ENDPOINT = "blue-nexus-endpoint"; + // Unique per run. Fixed names are left behind by a run that dies before tearDown and then make + // every later run fail to create them. + private static final String RED_ENDPOINT = "red-nexus-endpoint-" + UUID.randomUUID(); + private static final String BLUE_ENDPOINT = "blue-nexus-endpoint-" + UUID.randomUUID(); private static final String SERVICE = "TestNexusService1"; private static final String OPERATION = "operation"; @@ -233,7 +237,11 @@ public OperationHandler operation() { return OperationHandler.sync( (ctx, details, name) -> { if ("fail".equals(name)) { - throw OperationException.failed("operation failed on purpose"); + // Details give the failure real payloads, so the codec is consulted and the strict + // decode check below can compare the handler's context with the caller's. + throw OperationException.failed( + ApplicationFailure.newNonRetryableFailure( + "operation failed on purpose", "ContextFailure", "failure-detail")); } return "Hello, " + name + "!"; }); @@ -308,12 +316,23 @@ public List decode(@Nonnull List payloads) { seen.add(context); List decoded = new ArrayList<>(payloads.size()); for (Payload payload : payloads) { - // Payloads encoded without a context stay readable, as the contract requires. - if (payload.getMetadataMap().containsKey(SIGNATURE_KEY)) { - decoded.add(Payload.newBuilder(payload).removeMetadata(SIGNATURE_KEY).build()); - } else { + ByteString signature = payload.getMetadataMap().get(SIGNATURE_KEY); + if (signature == null) { + // Payloads encoded without a context stay readable, as the contract requires. decoded.add(payload); + continue; } + // This codec only signs under a Nexus context, so a signed payload was encoded under one. + // Decoding it without one means the two halves of the round trip disagreed. + Assert.assertTrue( + "payload encoded under a Nexus context was decoded under " + context, + context instanceof NexusSerializationContext); + NexusSerializationContext nexus = (NexusSerializationContext) context; + Assert.assertEquals( + "payload should be decoded under the context it was encoded with", + nexus.getEndpoint() + ":" + nexus.getService() + ":" + nexus.getOperation(), + signature.toStringUtf8()); + decoded.add(Payload.newBuilder(payload).removeMetadata(SIGNATURE_KEY).build()); } return decoded; }