From 34a1350941d07a66ca80111b4d1513b16de3257f Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 18 Jun 2026 15:00:29 -0400 Subject: [PATCH 01/53] wip --- .../client/ActivityExecutionOptions.java | 124 ++++++++ .../temporal/client/ActivityHandleImpl.java | 35 +++ .../temporal/client/ResetActivityOptions.java | 147 ++++++++++ .../client/UnpauseActivityOptions.java | 142 ++++++++++ .../client/UntypedActivityHandle.java | 43 +++ .../client/UpdateActivityOptions.java | 247 ++++++++++++++++ .../ActivityClientCallsInterceptor.java | 234 +++++++++++++++ .../ActivityClientCallsInterceptorBase.java | 20 ++ .../internal/client/ActivityHandleImpl.java | 133 +++++++++ .../client/RootActivityClientInvoker.java | 80 ++++++ .../external/GenericWorkflowClient.java | 13 + .../external/GenericWorkflowClientImpl.java | 45 +++ .../functional/StandaloneActivityTest.java | 267 +++++++++++++++++- .../ActivityHandleOperatorCommandsTest.java | 163 +++++++++++ 14 files changed, 1692 insertions(+), 1 deletion(-) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java create mode 100644 temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java 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..1730ec8339 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java @@ -0,0 +1,124 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import java.time.Duration; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * The resolved options of a standalone activity execution, returned by {@link + * UntypedActivityHandle#updateOptions(UpdateActivityOptions)}. + * + *

Reflects the activity's options as the server resolved them after the update was applied. + */ +@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; + + public ActivityExecutionOptions( + @Nullable String taskQueue, + @Nullable Duration scheduleToCloseTimeout, + @Nullable Duration scheduleToStartTimeout, + @Nullable Duration startToCloseTimeout, + @Nullable Duration heartbeatTimeout, + @Nullable RetryOptions retryOptions, + @Nullable Priority priority) { + this.taskQueue = taskQueue; + this.scheduleToCloseTimeout = scheduleToCloseTimeout; + this.scheduleToStartTimeout = scheduleToStartTimeout; + this.startToCloseTimeout = startToCloseTimeout; + this.heartbeatTimeout = heartbeatTimeout; + this.retryOptions = retryOptions; + this.priority = priority; + } + + @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; + } + + @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); + } + + @Override + public int hashCode() { + return Objects.hash( + taskQueue, + scheduleToCloseTimeout, + scheduleToStartTimeout, + startToCloseTimeout, + heartbeatTimeout, + retryOptions, + priority); + } + + @Override + public String toString() { + return "ActivityExecutionOptions{" + + "taskQueue='" + + taskQueue + + "', scheduleToCloseTimeout=" + + scheduleToCloseTimeout + + ", scheduleToStartTimeout=" + + scheduleToStartTimeout + + ", startToCloseTimeout=" + + startToCloseTimeout + + ", heartbeatTimeout=" + + heartbeatTimeout + + ", retryOptions=" + + retryOptions + + ", priority=" + + priority + + '}'; + } +} 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..bd127935da 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java @@ -121,4 +121,39 @@ public void terminate() { public void terminate(@Nullable String reason) { delegate.terminate(reason); } + + @Override + public void pause() { + delegate.pause(); + } + + @Override + public void pause(@Nullable String reason) { + delegate.pause(reason); + } + + @Override + public void unpause() { + delegate.unpause(); + } + + @Override + public void unpause(UnpauseActivityOptions options) { + delegate.unpause(options); + } + + @Override + public void reset() { + delegate.reset(); + } + + @Override + public void reset(ResetActivityOptions options) { + delegate.reset(options); + } + + @Override + public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { + return delegate.updateOptions(options); + } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java new file mode 100644 index 0000000000..ec2a63053f --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java @@ -0,0 +1,147 @@ +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#reset(ResetActivityOptions)}. + * + *

All fields are optional. An instance with no fields set resets the activity with default + * behavior. + */ +@Experimental +public final class ResetActivityOptions { + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(ResetActivityOptions options) { + return new Builder(options); + } + + public static ResetActivityOptions getDefaultInstance() { + return DEFAULT_INSTANCE; + } + + private static final ResetActivityOptions DEFAULT_INSTANCE = + ResetActivityOptions.newBuilder().build(); + + public static final class Builder { + private boolean resetHeartbeat; + private boolean keepPaused; + private @Nullable Duration jitter; + private boolean restoreOriginalOptions; + + private Builder() {} + + private Builder(ResetActivityOptions options) { + if (options == null) { + return; + } + this.resetHeartbeat = options.resetHeartbeat; + this.keepPaused = options.keepPaused; + this.jitter = options.jitter; + this.restoreOriginalOptions = options.restoreOriginalOptions; + } + + /** If set, the reset activity will clear its recorded heartbeat details. */ + public Builder setResetHeartbeat(boolean resetHeartbeat) { + this.resetHeartbeat = resetHeartbeat; + return this; + } + + /** If set and the activity is paused, it will remain paused after the reset. */ + public Builder setKeepPaused(boolean keepPaused) { + this.keepPaused = keepPaused; + return this; + } + + /** + * If set and the activity is in backoff, the activity will start at a random time within the + * given jitter window (unless it is paused and {@link #setKeepPaused(boolean)} is set). + */ + public Builder setJitter(@Nullable Duration jitter) { + this.jitter = jitter; + return this; + } + + /** + * If set, the activity options are restored to the originals the activity was created with (the + * options recorded in the first schedule event). + */ + public Builder setRestoreOriginalOptions(boolean restoreOriginalOptions) { + this.restoreOriginalOptions = restoreOriginalOptions; + return this; + } + + public ResetActivityOptions build() { + return new ResetActivityOptions(this); + } + } + + private final boolean resetHeartbeat; + private final boolean keepPaused; + private final @Nullable Duration jitter; + private final boolean restoreOriginalOptions; + + private ResetActivityOptions(Builder builder) { + this.resetHeartbeat = builder.resetHeartbeat; + this.keepPaused = builder.keepPaused; + this.jitter = builder.jitter; + this.restoreOriginalOptions = builder.restoreOriginalOptions; + } + + public Builder toBuilder() { + return new Builder(this); + } + + public boolean isResetHeartbeat() { + return resetHeartbeat; + } + + public boolean isKeepPaused() { + return keepPaused; + } + + @Nullable + public Duration getJitter() { + return jitter; + } + + public boolean isRestoreOriginalOptions() { + return restoreOriginalOptions; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ResetActivityOptions that = (ResetActivityOptions) o; + return resetHeartbeat == that.resetHeartbeat + && keepPaused == that.keepPaused + && restoreOriginalOptions == that.restoreOriginalOptions + && Objects.equals(jitter, that.jitter); + } + + @Override + public int hashCode() { + return Objects.hash(resetHeartbeat, keepPaused, jitter, restoreOriginalOptions); + } + + @Override + public String toString() { + return "ResetActivityOptions{" + + "resetHeartbeat=" + + resetHeartbeat + + ", keepPaused=" + + keepPaused + + ", jitter=" + + jitter + + ", restoreOriginalOptions=" + + restoreOriginalOptions + + '}'; + } +} 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..0c26be3346 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java @@ -0,0 +1,142 @@ +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 boolean resetAttempts; + private boolean resetHeartbeat; + private @Nullable Duration jitter; + + private Builder() {} + + private Builder(UnpauseActivityOptions options) { + if (options == null) { + return; + } + this.reason = options.reason; + this.resetAttempts = options.resetAttempts; + this.resetHeartbeat = options.resetHeartbeat; + this.jitter = options.jitter; + } + + /** Human-readable reason for unpausing. */ + public Builder setReason(@Nullable String reason) { + this.reason = reason; + return this; + } + + /** If set, also resets the activity's attempt counter back to 1. */ + public Builder setResetAttempts(boolean resetAttempts) { + this.resetAttempts = resetAttempts; + return this; + } + + /** If set, also clears the activity's recorded heartbeat details. */ + public Builder setResetHeartbeat(boolean resetHeartbeat) { + this.resetHeartbeat = resetHeartbeat; + 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 boolean resetAttempts; + private final boolean resetHeartbeat; + private final @Nullable Duration jitter; + + private UnpauseActivityOptions(Builder builder) { + this.reason = builder.reason; + this.resetAttempts = builder.resetAttempts; + this.resetHeartbeat = builder.resetHeartbeat; + this.jitter = builder.jitter; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @Nullable + public String getReason() { + return reason; + } + + public boolean isResetAttempts() { + return resetAttempts; + } + + public boolean isResetHeartbeat() { + return resetHeartbeat; + } + + @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 resetAttempts == that.resetAttempts + && resetHeartbeat == that.resetHeartbeat + && Objects.equals(reason, that.reason) + && Objects.equals(jitter, that.jitter); + } + + @Override + public int hashCode() { + return Objects.hash(reason, resetAttempts, resetHeartbeat, jitter); + } + + @Override + public String toString() { + return "UnpauseActivityOptions{" + + "reason='" + + reason + + "', resetAttempts=" + + resetAttempts + + ", resetHeartbeat=" + + resetHeartbeat + + ", 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..5e49ec0f91 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -146,4 +146,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 an optional reason. + * + * @param reason human-readable reason for pausing, may be {@code null} + */ + void pause(@Nullable String reason); + + /** 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 (reset attempts, reset heartbeat, jitter, reason) + */ + void unpause(UnpauseActivityOptions options); + + /** Resets the activity with default options, scheduling a fresh attempt. */ + void reset(); + + /** + * Resets the activity with the given options. + * + * @param options reset options (reset heartbeat, keep paused, jitter, restore original options) + */ + void reset(ResetActivityOptions options); + + /** + * Updates the activity's options. Only the fields explicitly set in {@code options} are changed; + * a derived field mask leaves the rest untouched. Alternatively, {@link + * UpdateActivityOptions.Builder#setRestoreOriginal(boolean)} reverts the options to the values + * the activity was created with. + * + * @param options the options to apply + * @return the activity options as resolved by the server after the update + */ + ActivityExecutionOptions updateOptions(UpdateActivityOptions options); } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java new file mode 100644 index 0000000000..432bc477ab --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java @@ -0,0 +1,247 @@ +package io.temporal.client; + +import com.google.common.base.Preconditions; +import io.temporal.common.Experimental; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import java.time.Duration; +import java.util.Objects; +import javax.annotation.Nullable; + +/** + * Options for {@link UntypedActivityHandle#updateOptions(UpdateActivityOptions)}. + * + *

Only the fields that are explicitly set are sent to the server; a derived field mask ensures + * that unset fields are left unchanged (a partial update). + * + *

{@link Builder#setRestoreOriginal(boolean)} is mutually exclusive with every other field: an + * instance that sets {@code restoreOriginal} together with any other option is rejected by {@link + * Builder#build()} before any request is sent. + */ +@Experimental +public final class UpdateActivityOptions { + + public static Builder newBuilder() { + return new Builder(); + } + + public static Builder newBuilder(UpdateActivityOptions options) { + return new Builder(options); + } + + public static final class Builder { + private @Nullable String taskQueue; + private @Nullable Duration scheduleToCloseTimeout; + private @Nullable Duration scheduleToStartTimeout; + private @Nullable Duration startToCloseTimeout; + private @Nullable Duration heartbeatTimeout; + private @Nullable RetryOptions retryOptions; + private @Nullable Priority priority; + private boolean restoreOriginal; + + private Builder() {} + + private Builder(UpdateActivityOptions options) { + if (options == null) { + return; + } + this.taskQueue = options.taskQueue; + this.scheduleToCloseTimeout = options.scheduleToCloseTimeout; + this.scheduleToStartTimeout = options.scheduleToStartTimeout; + this.startToCloseTimeout = options.startToCloseTimeout; + this.heartbeatTimeout = options.heartbeatTimeout; + this.retryOptions = options.retryOptions; + this.priority = options.priority; + this.restoreOriginal = options.restoreOriginal; + } + + /** New task queue for the activity. */ + public Builder setTaskQueue(@Nullable String taskQueue) { + this.taskQueue = taskQueue; + return this; + } + + /** New schedule-to-close timeout. */ + public Builder setScheduleToCloseTimeout(@Nullable Duration scheduleToCloseTimeout) { + this.scheduleToCloseTimeout = scheduleToCloseTimeout; + return this; + } + + /** New schedule-to-start timeout. */ + public Builder setScheduleToStartTimeout(@Nullable Duration scheduleToStartTimeout) { + this.scheduleToStartTimeout = scheduleToStartTimeout; + return this; + } + + /** New start-to-close timeout. */ + public Builder setStartToCloseTimeout(@Nullable Duration startToCloseTimeout) { + this.startToCloseTimeout = startToCloseTimeout; + return this; + } + + /** New heartbeat timeout. */ + public Builder setHeartbeatTimeout(@Nullable Duration heartbeatTimeout) { + this.heartbeatTimeout = heartbeatTimeout; + return this; + } + + /** New retry policy. */ + public Builder setRetryOptions(@Nullable RetryOptions retryOptions) { + this.retryOptions = retryOptions; + return this; + } + + /** New priority. */ + public Builder setPriority(@Nullable Priority priority) { + this.priority = priority; + return this; + } + + /** + * If set, the activity options are restored to the originals the activity was created with. + * This flag cannot be combined with any other field. + */ + public Builder setRestoreOriginal(boolean restoreOriginal) { + this.restoreOriginal = restoreOriginal; + return this; + } + + public UpdateActivityOptions build() { + if (restoreOriginal) { + Preconditions.checkArgument( + taskQueue == null + && scheduleToCloseTimeout == null + && scheduleToStartTimeout == null + && startToCloseTimeout == null + && heartbeatTimeout == null + && retryOptions == null + && priority == null, + "restoreOriginal cannot be combined with any other option"); + } else { + Preconditions.checkArgument( + taskQueue != null + || scheduleToCloseTimeout != null + || scheduleToStartTimeout != null + || startToCloseTimeout != null + || heartbeatTimeout != null + || retryOptions != null + || priority != null, + "At least one option must be set, or restoreOriginal must be used"); + } + return new UpdateActivityOptions(this); + } + } + + 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 boolean restoreOriginal; + + private UpdateActivityOptions(Builder builder) { + this.taskQueue = builder.taskQueue; + this.scheduleToCloseTimeout = builder.scheduleToCloseTimeout; + this.scheduleToStartTimeout = builder.scheduleToStartTimeout; + this.startToCloseTimeout = builder.startToCloseTimeout; + this.heartbeatTimeout = builder.heartbeatTimeout; + this.retryOptions = builder.retryOptions; + this.priority = builder.priority; + this.restoreOriginal = builder.restoreOriginal; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @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; + } + + public boolean isRestoreOriginal() { + return restoreOriginal; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UpdateActivityOptions that = (UpdateActivityOptions) o; + return restoreOriginal == that.restoreOriginal + && 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); + } + + @Override + public int hashCode() { + return Objects.hash( + taskQueue, + scheduleToCloseTimeout, + scheduleToStartTimeout, + startToCloseTimeout, + heartbeatTimeout, + retryOptions, + priority, + restoreOriginal); + } + + @Override + public String toString() { + return "UpdateActivityOptions{" + + "taskQueue='" + + taskQueue + + "', scheduleToCloseTimeout=" + + scheduleToCloseTimeout + + ", scheduleToStartTimeout=" + + scheduleToStartTimeout + + ", startToCloseTimeout=" + + startToCloseTimeout + + ", heartbeatTimeout=" + + heartbeatTimeout + + ", retryOptions=" + + retryOptions + + ", priority=" + + priority + + ", restoreOriginal=" + + restoreOriginal + + '}'; + } +} 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..1f0ce68431 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 @@ -8,6 +8,7 @@ import io.temporal.client.StartActivityOptions; import io.temporal.common.Experimental; import java.lang.reflect.Type; +import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -80,6 +81,42 @@ 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, optionally resetting its attempt counter and + * heartbeat details. + * + * @param input activity ID, optional run ID, and unpause options + * @return an empty output object (reserved for future use) + */ + UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input); + + /** + * Resets a standalone activity, scheduling a fresh attempt. + * + * @param input activity ID, optional run ID, and reset options + * @return an empty output object (reserved for future use) + */ + ResetActivityOutput resetActivity(ResetActivityInput 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 @@ -339,6 +376,203 @@ public String getReason() { @Experimental final class TerminateActivityOutput {} + @Experimental + final class PauseActivityInput { + private final String id; + private final @Nullable String runId; + private final @Nullable String reason; + + public PauseActivityInput(String id, @Nullable String runId, @Nullable String reason) { + this.id = id; + this.runId = runId; + this.reason = reason; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nullable + public String getReason() { + return reason; + } + } + + @Experimental + final class PauseActivityOutput {} + + @Experimental + final class UnpauseActivityInput { + private final String id; + private final @Nullable String runId; + private final @Nullable String reason; + private final boolean resetAttempts; + private final boolean resetHeartbeat; + private final @Nullable Duration jitter; + + public UnpauseActivityInput( + String id, + @Nullable String runId, + @Nullable String reason, + boolean resetAttempts, + boolean resetHeartbeat, + @Nullable Duration jitter) { + this.id = id; + this.runId = runId; + this.reason = reason; + this.resetAttempts = resetAttempts; + this.resetHeartbeat = resetHeartbeat; + this.jitter = jitter; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + @Nullable + public String getReason() { + return reason; + } + + public boolean isResetAttempts() { + return resetAttempts; + } + + public boolean isResetHeartbeat() { + return resetHeartbeat; + } + + @Nullable + public Duration getJitter() { + return jitter; + } + } + + @Experimental + final class UnpauseActivityOutput {} + + @Experimental + final class ResetActivityInput { + private final String id; + private final @Nullable String runId; + private final boolean resetHeartbeat; + private final boolean keepPaused; + private final @Nullable Duration jitter; + private final boolean restoreOriginalOptions; + + public ResetActivityInput( + String id, + @Nullable String runId, + boolean resetHeartbeat, + boolean keepPaused, + @Nullable Duration jitter, + boolean restoreOriginalOptions) { + this.id = id; + this.runId = runId; + this.resetHeartbeat = resetHeartbeat; + this.keepPaused = keepPaused; + this.jitter = jitter; + this.restoreOriginalOptions = restoreOriginalOptions; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + public boolean isResetHeartbeat() { + return resetHeartbeat; + } + + public boolean isKeepPaused() { + return keepPaused; + } + + @Nullable + public Duration getJitter() { + return jitter; + } + + public boolean isRestoreOriginalOptions() { + return restoreOriginalOptions; + } + } + + @Experimental + final class ResetActivityOutput {} + + @Experimental + final class UpdateActivityOptionsInput { + private final String id; + private final @Nullable String runId; + private final io.temporal.api.activity.v1.ActivityOptions activityOptions; + private final com.google.protobuf.FieldMask updateMask; + private final boolean restoreOriginal; + + public UpdateActivityOptionsInput( + String id, + @Nullable String runId, + io.temporal.api.activity.v1.ActivityOptions activityOptions, + com.google.protobuf.FieldMask updateMask, + boolean restoreOriginal) { + this.id = id; + this.runId = runId; + this.activityOptions = activityOptions; + this.updateMask = updateMask; + this.restoreOriginal = restoreOriginal; + } + + public String getId() { + return id; + } + + @Nullable + public String getRunId() { + return runId; + } + + public io.temporal.api.activity.v1.ActivityOptions getActivityOptions() { + return activityOptions; + } + + public com.google.protobuf.FieldMask getUpdateMask() { + return updateMask; + } + + public boolean isRestoreOriginal() { + return restoreOriginal; + } + } + + @Experimental + final class UpdateActivityOptionsOutput { + private final io.temporal.api.activity.v1.ActivityOptions activityOptions; + + public UpdateActivityOptionsOutput( + io.temporal.api.activity.v1.ActivityOptions activityOptions) { + this.activityOptions = activityOptions; + } + + /** The activity options as resolved by the server after the update. */ + public io.temporal.api.activity.v1.ActivityOptions getActivityOptions() { + return activityOptions; + } + } + @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..73b0899a0a 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 @@ -44,6 +44,26 @@ 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 ResetActivityOutput resetActivity(ResetActivityInput input) { + return next.resetActivity(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..bb574ae962 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,23 @@ package io.temporal.internal.client; +import static io.temporal.internal.common.RetryOptionsUtils.toRetryPolicy; + +import com.google.protobuf.FieldMask; +import io.temporal.api.activity.v1.ActivityOptions; +import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.client.ActivityExecutionDescription; +import io.temporal.client.ActivityExecutionOptions; +import io.temporal.client.ResetActivityOptions; +import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; +import io.temporal.client.UpdateActivityOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.internal.common.ProtoConverters; +import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.RetryOptionsUtils; import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -130,4 +144,123 @@ public void terminate(@Nullable String reason) { new ActivityClientCallsInterceptor.TerminateActivityInput( activityId, activityRunId, reason)); } + + @Override + public void pause() { + pause(null); + } + + @Override + public void pause(@Nullable String reason) { + clientCallsInterceptor.pauseActivity( + new ActivityClientCallsInterceptor.PauseActivityInput(activityId, activityRunId, reason)); + } + + @Override + public void unpause() { + unpause(UnpauseActivityOptions.getDefaultInstance()); + } + + @Override + public void unpause(UnpauseActivityOptions options) { + clientCallsInterceptor.unpauseActivity( + new ActivityClientCallsInterceptor.UnpauseActivityInput( + activityId, + activityRunId, + options.getReason(), + options.isResetAttempts(), + options.isResetHeartbeat(), + options.getJitter())); + } + + @Override + public void reset() { + reset(ResetActivityOptions.getDefaultInstance()); + } + + @Override + public void reset(ResetActivityOptions options) { + clientCallsInterceptor.resetActivity( + new ActivityClientCallsInterceptor.ResetActivityInput( + activityId, + activityRunId, + options.isResetHeartbeat(), + options.isKeepPaused(), + options.getJitter(), + options.isRestoreOriginalOptions())); + } + + @Override + public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { + ActivityOptions.Builder activityOptions = ActivityOptions.newBuilder(); + List maskPaths = new ArrayList<>(); + + if (!options.isRestoreOriginal()) { + if (options.getTaskQueue() != null) { + activityOptions.setTaskQueue( + TaskQueue.newBuilder().setName(options.getTaskQueue()).build()); + maskPaths.add("task_queue"); + } + if (options.getScheduleToCloseTimeout() != null) { + activityOptions.setScheduleToCloseTimeout( + ProtobufTimeUtils.toProtoDuration(options.getScheduleToCloseTimeout())); + maskPaths.add("schedule_to_close_timeout"); + } + if (options.getScheduleToStartTimeout() != null) { + activityOptions.setScheduleToStartTimeout( + ProtobufTimeUtils.toProtoDuration(options.getScheduleToStartTimeout())); + maskPaths.add("schedule_to_start_timeout"); + } + if (options.getStartToCloseTimeout() != null) { + activityOptions.setStartToCloseTimeout( + ProtobufTimeUtils.toProtoDuration(options.getStartToCloseTimeout())); + maskPaths.add("start_to_close_timeout"); + } + if (options.getHeartbeatTimeout() != null) { + activityOptions.setHeartbeatTimeout( + ProtobufTimeUtils.toProtoDuration(options.getHeartbeatTimeout())); + maskPaths.add("heartbeat_timeout"); + } + if (options.getRetryOptions() != null) { + activityOptions.setRetryPolicy(toRetryPolicy(options.getRetryOptions())); + maskPaths.add("retry_policy"); + } + if (options.getPriority() != null) { + activityOptions.setPriority(ProtoConverters.toProto(options.getPriority())); + maskPaths.add("priority"); + } + } + + FieldMask updateMask = FieldMask.newBuilder().addAllPaths(maskPaths).build(); + + ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = + clientCallsInterceptor.updateActivityOptions( + new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( + activityId, + activityRunId, + activityOptions.build(), + updateMask, + options.isRestoreOriginal())); + + return fromProto(output.getActivityOptions()); + } + + private static ActivityExecutionOptions fromProto(ActivityOptions proto) { + return new ActivityExecutionOptions( + proto.hasTaskQueue() ? proto.getTaskQueue().getName() : null, + proto.hasScheduleToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getScheduleToCloseTimeout()) + : null, + proto.hasScheduleToStartTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getScheduleToStartTimeout()) + : null, + proto.hasStartToCloseTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getStartToCloseTimeout()) + : null, + proto.hasHeartbeatTimeout() + ? ProtobufTimeUtils.toJavaDuration(proto.getHeartbeatTimeout()) + : null, + proto.hasRetryPolicy() ? RetryOptionsUtils.toRetryOptions(proto.getRetryPolicy()) : null, + proto.hasPriority() ? ProtoConverters.fromProto(proto.getPriority()) : null); + } } 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..c6f1f075ae 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 @@ -333,6 +333,86 @@ 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.getReason() != null) { + req.setReason(input.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()) + .setResetAttempts(input.isResetAttempts()) + .setResetHeartbeat(input.isResetHeartbeat()); + if (input.getRunId() != null) { + req.setRunId(input.getRunId()); + } + if (input.getReason() != null) { + req.setReason(input.getReason()); + } + if (input.getJitter() != null) { + req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getJitter())); + } + genericClient.unpauseActivity(req.build()); + return new UnpauseActivityOutput(); + } + + @Override + public ResetActivityOutput resetActivity(ResetActivityInput input) { + ResetActivityExecutionRequest.Builder req = + ResetActivityExecutionRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setActivityId(input.getId()) + .setResetHeartbeat(input.isResetHeartbeat()) + .setKeepPaused(input.isKeepPaused()) + .setRestoreOriginalOptions(input.isRestoreOriginalOptions()); + if (input.getRunId() != null) { + req.setRunId(input.getRunId()); + } + if (input.getJitter() != null) { + req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getJitter())); + } + genericClient.resetActivity(req.build()); + return new ResetActivityOutput(); + } + + @Override + public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input) { + UpdateActivityExecutionOptionsRequest.Builder req = + UpdateActivityExecutionOptionsRequest.newBuilder() + .setNamespace(clientOptions.getNamespace()) + .setIdentity(clientOptions.getIdentity()) + .setActivityId(input.getId()); + if (input.getRunId() != null) { + req.setRunId(input.getRunId()); + } + if (input.isRestoreOriginal()) { + req.setRestoreOriginal(true); + } else { + req.setActivityOptions(input.getActivityOptions()).setUpdateMask(input.getUpdateMask()); + } + UpdateActivityExecutionOptionsResponse response = + genericClient.updateActivityOptions(req.build()); + return new UpdateActivityOptionsOutput(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 a81fa253a0..d1d06c1363 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,19 @@ CompletableFuture pollActivityAsync( @Experimental void terminateActivity(TerminateActivityExecutionRequest request); + @Experimental + void pauseActivity(PauseActivityExecutionRequest request); + + @Experimental + void unpauseActivity(UnpauseActivityExecutionRequest request); + + @Experimental + void resetActivity(ResetActivityExecutionRequest 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 f74d1b6e37..b26ca55fc7 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 @@ -632,6 +632,51 @@ 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 void resetActivity(ResetActivityExecutionRequest request) { + grpcRetryer.retry( + () -> + service + .blockingStub() + .withOption(METRICS_TAGS_CALL_OPTIONS_KEY, metricsScope) + .resetActivityExecution(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/functional/StandaloneActivityTest.java b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityTest.java index a54f846cec..3e4be669ef 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,6 +15,7 @@ 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.enums.v1.PendingActivityState; import io.temporal.client.*; import io.temporal.common.RetryOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; @@ -96,6 +97,12 @@ public interface AlwaysFailActivity { void alwaysFail(); } + @ActivityInterface + public interface RetryThenSucceedActivity { + @ActivityMethod(name = "RetryThenSucceed") + String run(); + } + /** Snapshot of {@link ActivityInfo} fields captured inside an activity body. */ public static class ActivityInfoSnapshot { public String activityId; @@ -200,6 +207,24 @@ public void alwaysFail() { } } + /** + * Fails on the first attempt and succeeds on the second. Used to drive an activity into retry + * backoff so it can be paused/unpaused/reset between attempts. + */ + private static volatile java.util.concurrent.atomic.AtomicInteger retryAttempts; + + public static class RetryThenSucceedActivityImpl implements RetryThenSucceedActivity { + @Override + public String run() { + java.util.concurrent.atomic.AtomicInteger counter = retryAttempts; + int attempt = counter == null ? 1 : counter.incrementAndGet(); + if (attempt < 2) { + throw ApplicationFailure.newFailure("retry me", "retry-type"); + } + return "succeeded-on-attempt-" + attempt; + } + } + // --------------------------------------------------------------------------- // Test rule // --------------------------------------------------------------------------- @@ -215,7 +240,8 @@ public void alwaysFail() { new InspectInfoActivityImpl(), new EchoVoidActivityImpl(), new ConcatActivityImpl(), - new AlwaysFailActivityImpl()) + new AlwaysFailActivityImpl(), + new RetryThenSucceedActivityImpl()) .build(); // --------------------------------------------------------------------------- @@ -986,6 +1012,245 @@ public void testOnlyStartToCloseTimeoutIsValid() { newActivityClient().execute(SimpleActivity.class, SimpleActivity::execute, opts, "x")); } + // --------------------------------------------------------------------------- + // Operator commands: pause / unpause / reset / updateOptions + // --------------------------------------------------------------------------- + + private static boolean isPaused(ActivityExecutionDescription desc) { + PendingActivityState state = desc.getRunState(); + return state == PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED + || state == PendingActivityState.PENDING_ACTIVITY_STATE_PAUSE_REQUESTED; + } + + @Test + public void pauseShowsPaused() throws InterruptedException { + assumeTrue(SDKTestWorkflowRule.useExternalService); + cancelLatch = new CountDownLatch(1); + try { + ActivityClient client = newActivityClient(); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setHeartbeatTimeout(Duration.ofSeconds(10)) + .build(); + ActivityHandle handle = + client.start(WaitForCancelActivity.class, WaitForCancelActivity::waitForCancel, opts); + + assertTrue("Activity did not start within 30s", cancelLatch.await(30, TimeUnit.SECONDS)); + + handle.pause("operator pause"); + + assertEventually( + Duration.ofSeconds(30), + () -> { + ActivityExecutionDescription desc = handle.describe(); + assertTrue("expected paused run state, got " + desc.getRunState(), isPaused(desc)); + }); + } finally { + cancelLatch = null; + // best-effort cleanup + } + } + + // Overrides the rule's default 10s global timeout: retry backoff makes this scenario take longer. + @Test(timeout = 60_000) + public void unpauseResumes() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + retryAttempts = new java.util.concurrent.atomic.AtomicInteger(1); + try { + ActivityClient client = newActivityClient(); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(30)) + .setMaximumAttempts(5) + .build()) + .build(); + ActivityHandle handle = + client.start(RetryThenSucceedActivity.class, RetryThenSucceedActivity::run, opts); + + // Wait until the first attempt has failed and the activity is backing off. + assertEventually( + Duration.ofSeconds(60), + () -> { + ActivityExecutionDescription desc = handle.describe(); + assertNotNull("expected a recorded failure before pausing", desc.getLastFailure()); + }); + + handle.pause("hold"); + assertEventually(Duration.ofSeconds(30), () -> assertTrue(isPaused(handle.describe()))); + + // Unpause and reset the backoff so the next attempt fires immediately. + handle.unpause(UnpauseActivityOptions.newBuilder().setReason("resume").build()); + + assertEquals("succeeded-on-attempt-2", handle.getResult()); + } finally { + retryAttempts = null; + } + } + + // Overrides the rule's default 10s global timeout: driving retries + reset takes longer. + @Test(timeout = 60_000) + public void reset() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + // Never succeed: we only want to observe the attempt counter being reset. + retryAttempts = new java.util.concurrent.atomic.AtomicInteger(100); + try { + ActivityClient client = newActivityClient(); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setScheduleToCloseTimeout(Duration.ofMinutes(10)) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(1.0) + .setMaximumAttempts(100) + .build()) + .build(); + ActivityHandle handle = + client.start(RetryThenSucceedActivity.class, RetryThenSucceedActivity::run, opts); + + // Drive the activity well past its first attempt (short, constant backoff). + assertEventually( + Duration.ofSeconds(30), + () -> + assertTrue( + "expected attempt >= 3 before reset", handle.describe().getAttempt() >= 3)); + + handle.reset(); + + // After reset the attempt counter returns to 1. + assertEventually( + Duration.ofSeconds(30), + () -> assertEquals("attempt should be reset to 1", 1, handle.describe().getAttempt())); + } finally { + retryAttempts = null; + } + } + + @Test + public void updateOptionsRespectsMask() throws InterruptedException { + assumeTrue(SDKTestWorkflowRule.useExternalService); + cancelLatch = new CountDownLatch(1); + try { + ActivityClient client = newActivityClient(); + Duration originalStartToClose = Duration.ofSeconds(45); + Duration originalScheduleToClose = Duration.ofMinutes(10); + Duration newStartToClose = Duration.ofSeconds(90); + + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(originalStartToClose) + .setScheduleToCloseTimeout(originalScheduleToClose) + .setHeartbeatTimeout(Duration.ofSeconds(10)) + .build(); + ActivityHandle handle = + client.start(WaitForCancelActivity.class, WaitForCancelActivity::waitForCancel, opts); + assertTrue("Activity did not start within 30s", cancelLatch.await(30, TimeUnit.SECONDS)); + + ActivityExecutionOptions updated = + handle.updateOptions( + UpdateActivityOptions.newBuilder().setStartToCloseTimeout(newStartToClose).build()); + + // Returned options reflect the change AND leave the untouched field as-is. + assertEquals(newStartToClose, updated.getStartToCloseTimeout()); + assertEquals(originalScheduleToClose, updated.getScheduleToCloseTimeout()); + + // describe confirms server-side state matches. + assertEventually( + Duration.ofSeconds(30), + () -> { + ActivityExecutionDescription desc = handle.describe(); + assertEquals(newStartToClose, desc.getStartToCloseTimeout()); + assertEquals(originalScheduleToClose, desc.getScheduleToCloseTimeout()); + }); + } finally { + cancelLatch = null; + } + } + + @Test + public void updateOptionsRestoreOriginalExclusive() throws InterruptedException { + assumeTrue(SDKTestWorkflowRule.useExternalService); + cancelLatch = new CountDownLatch(1); + try { + ActivityClient client = newActivityClient(); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(45)) + .setScheduleToCloseTimeout(Duration.ofMinutes(10)) + .setHeartbeatTimeout(Duration.ofSeconds(10)) + .build(); + ActivityHandle handle = + client.start(WaitForCancelActivity.class, WaitForCancelActivity::waitForCancel, opts); + assertTrue("Activity did not start within 30s", cancelLatch.await(30, TimeUnit.SECONDS)); + + // restoreOriginal combined with another option must fail client-side, before the RPC. + assertThrows( + IllegalArgumentException.class, + () -> + handle.updateOptions( + UpdateActivityOptions.newBuilder() + .setRestoreOriginal(true) + .setStartToCloseTimeout(Duration.ofSeconds(99)) + .build())); + } finally { + cancelLatch = null; + } + } + + @Test + public void updateOptionsRestoreOriginalAlone() throws InterruptedException { + assumeTrue(SDKTestWorkflowRule.useExternalService); + cancelLatch = new CountDownLatch(1); + try { + ActivityClient client = newActivityClient(); + Duration originalStartToClose = Duration.ofSeconds(45); + Duration changedStartToClose = Duration.ofSeconds(90); + + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(originalStartToClose) + .setScheduleToCloseTimeout(Duration.ofMinutes(10)) + .setHeartbeatTimeout(Duration.ofSeconds(10)) + .build(); + ActivityHandle handle = + client.start(WaitForCancelActivity.class, WaitForCancelActivity::waitForCancel, opts); + assertTrue("Activity did not start within 30s", cancelLatch.await(30, TimeUnit.SECONDS)); + + // Change a field, confirm it took effect. + ActivityExecutionOptions changed = + handle.updateOptions( + UpdateActivityOptions.newBuilder() + .setStartToCloseTimeout(changedStartToClose) + .build()); + assertEquals(changedStartToClose, changed.getStartToCloseTimeout()); + + // Restore originals. + ActivityExecutionOptions restored = + handle.updateOptions(UpdateActivityOptions.newBuilder().setRestoreOriginal(true).build()); + assertEquals(originalStartToClose, restored.getStartToCloseTimeout()); + } finally { + cancelLatch = null; + } + } + // --------------------------------------------------------------------------- // Interceptor helpers // --------------------------------------------------------------------------- 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..6b97e75ebf --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/client/ActivityHandleOperatorCommandsTest.java @@ -0,0 +1,163 @@ +package io.temporal.internal.client; + +import static org.junit.Assert.assertEquals; +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.activity.v1.ActivityOptions; +import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; +import io.temporal.api.workflowservice.v1.ResetActivityExecutionRequest; +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.ResetActivityOptions; +import io.temporal.client.UnpauseActivityOptions; +import io.temporal.client.UntypedActivityHandle; +import io.temporal.client.UpdateActivityOptions; +import io.temporal.common.interceptors.ActivityClientCallsInterceptor; +import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; +import io.temporal.internal.client.external.GenericWorkflowClient; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.Test; + +/** + * Unit test verifying that each operator command on the activity handle flows through the + * interceptor chain and reaches the gRPC client. + */ +public class ActivityHandleOperatorCommandsTest { + + private final GenericWorkflowClient genericClient = mock(GenericWorkflowClient.class); + + private final ActivityClientOptions clientOptions = + ActivityClientOptions.newBuilder() + .setNamespace("test-namespace") + .setIdentity("test-identity") + .build(); + + private final List recorded = new ArrayList<>(); + + private UntypedActivityHandle newHandle() { + ActivityClientCallsInterceptor root = + new RootActivityClientInvoker(genericClient, clientOptions); + ActivityClientCallsInterceptor recording = + new ActivityClientCallsInterceptorBase(root) { + @Override + public PauseActivityOutput pauseActivity(PauseActivityInput input) { + recorded.add("pause"); + return super.pauseActivity(input); + } + + @Override + public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { + recorded.add("unpause"); + return super.unpauseActivity(input); + } + + @Override + public ResetActivityOutput resetActivity(ResetActivityInput input) { + recorded.add("reset"); + return super.resetActivity(input); + } + + @Override + public UpdateActivityOptionsOutput updateActivityOptions( + UpdateActivityOptionsInput input) { + recorded.add("updateOptions"); + return super.updateActivityOptions(input); + } + }; + return new ActivityHandleImpl("act-1", "run-1", recording); + } + + @Test + public void interceptorInvokesEachOperatorCommand() { + when(genericClient.updateActivityOptions(any())) + .thenReturn( + UpdateActivityExecutionOptionsResponse.newBuilder() + .setActivityOptions( + ActivityOptions.newBuilder() + .setStartToCloseTimeout( + com.google.protobuf.Duration.newBuilder().setSeconds(30).build())) + .build()); + + UntypedActivityHandle handle = newHandle(); + + handle.pause("because"); + handle.unpause( + UnpauseActivityOptions.newBuilder() + .setResetAttempts(true) + .setResetHeartbeat(true) + .setJitter(Duration.ofSeconds(5)) + .setReason("go") + .build()); + handle.reset( + ResetActivityOptions.newBuilder() + .setResetHeartbeat(true) + .setKeepPaused(true) + .setJitter(Duration.ofSeconds(2)) + .build()); + handle.updateOptions( + UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(30)).build()); + + // Each command flowed through the interceptor. + assertEquals(Arrays.asList("pause", "unpause", "reset", "updateOptions"), recorded); + + // Each command reached the gRPC client with the expected fields. + PauseActivityExecutionRequest pauseReq = capturePause(); + assertEquals("act-1", pauseReq.getActivityId()); + assertEquals("run-1", pauseReq.getRunId()); + assertEquals("because", pauseReq.getReason()); + assertEquals("", pauseReq.getWorkflowId()); + assertTrue(!pauseReq.getRequestId().isEmpty()); + + UnpauseActivityExecutionRequest unpauseReq = captureUnpause(); + assertTrue(unpauseReq.getResetAttempts()); + assertTrue(unpauseReq.getResetHeartbeat()); + assertEquals("go", unpauseReq.getReason()); + assertEquals(5, unpauseReq.getJitter().getSeconds()); + + ResetActivityExecutionRequest resetReq = captureReset(); + assertTrue(resetReq.getResetHeartbeat()); + assertTrue(resetReq.getKeepPaused()); + assertEquals(2, resetReq.getJitter().getSeconds()); + + UpdateActivityExecutionOptionsRequest updateReq = captureUpdate(); + assertEquals(Arrays.asList("start_to_close_timeout"), updateReq.getUpdateMask().getPathsList()); + assertEquals(30, updateReq.getActivityOptions().getStartToCloseTimeout().getSeconds()); + } + + private PauseActivityExecutionRequest capturePause() { + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(PauseActivityExecutionRequest.class); + verify(genericClient).pauseActivity(captor.capture()); + return captor.getValue(); + } + + private UnpauseActivityExecutionRequest captureUnpause() { + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(UnpauseActivityExecutionRequest.class); + verify(genericClient).unpauseActivity(captor.capture()); + return captor.getValue(); + } + + private ResetActivityExecutionRequest captureReset() { + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); + verify(genericClient).resetActivity(captor.capture()); + return captor.getValue(); + } + + private UpdateActivityExecutionOptionsRequest captureUpdate() { + org.mockito.ArgumentCaptor captor = + org.mockito.ArgumentCaptor.forClass(UpdateActivityExecutionOptionsRequest.class); + verify(genericClient).updateActivityOptions(captor.capture()); + return captor.getValue(); + } +} From 0e3094cc3dc9877dc4af559b967600124739777e Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 18 Jun 2026 15:50:07 -0400 Subject: [PATCH 02/53] wip --- ...tandaloneActivityOperatorCommandsTest.java | 384 ++++++++++++++++++ .../ActivityHandleOperatorCommandsTest.java | 2 +- 2 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java 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..f105629d0e --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -0,0 +1,384 @@ +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.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.enums.v1.PendingActivityState; +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.StartActivityOptions; +import io.temporal.client.UpdateActivityOptions; +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, reset 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 { + + // --------------------------------------------------------------------------- + // 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); + } + } + } + + /** 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"; + } + } + + // --------------------------------------------------------------------------- + // Rule + helpers + // --------------------------------------------------------------------------- + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setActivityImplementations( + new SlowActivityImpl(), new QuickActivityImpl(), new FailThenSucceedActivityImpl()) + .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 assertPaused(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; + } + + private StartActivityOptions.Builder slowOpts() { + return StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(30)); + } + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + @Test + public void pauseShowsPaused() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startRunningSlowActivity(slowOpts()); + handle.pause("test-pause-reason"); + assertPaused(handle); + handle.terminate("cleanup"); + } + + // 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("pause-before-unpause"); + // 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(); + // After unpause the activity proceeds and completes successfully (proving it resumed). + assertEquals("resumed", handle.getResult()); + } + + // Overrides the rule's default 10s global timeout: driving retries + reset takes longer. + @Test(timeout = 60_000) + public void reset() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityClient client = newActivityClient(); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(200)) + .setBackoffCoefficient(1.0) + .setMaximumInterval(Duration.ofMillis(200)) + .setMaximumAttempts(50) + .build()) + .build(); + ActivityHandle handle = + client.start(FailThenSucceedActivity.class, FailThenSucceedActivity::run, opts); + + // Wait until the activity has recorded more than one attempt (i.e. it has retried). + assertEventually( + Duration.ofSeconds(30), + () -> assertTrue("expected attempt > 1 before reset", handle.describe().getAttempt() > 1)); + + handle.reset(); + + // After reset the attempt counter goes back to the start. + assertEventually( + Duration.ofSeconds(30), + () -> assertEquals("attempt should be reset to 1", 1, handle.describe().getAttempt())); + 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( + UpdateActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(90)) + .build()); + + // 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"); + } + + @Test + public void updateOptionsRestoreOriginalExclusive() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startRunningSlowActivity(slowOpts()); + // Building the request with restore_original AND another option is rejected before any RPC. + IllegalArgumentException err = + assertThrows( + IllegalArgumentException.class, + () -> + handle.updateOptions( + UpdateActivityOptions.newBuilder() + .setRestoreOriginal(true) + .setStartToCloseTimeout(Duration.ofSeconds(5)) + .build())); + assertTrue(err.getMessage().toLowerCase().contains("restore")); + handle.terminate("cleanup"); + } + + @Test + public void updateOptionsRestoreOriginalAlone() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = + startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); + + // Change an option away from the original. + ActivityExecutionOptions changed = + handle.updateOptions( + UpdateActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(90)) + .build()); + assertEquals(Duration.ofSeconds(90), changed.getStartToCloseTimeout()); + + // restore_original alone reverts to the value the activity was created with. + ActivityExecutionOptions restored = + handle.updateOptions(UpdateActivityOptions.newBuilder().setRestoreOriginal(true).build()); + assertEquals(Duration.ofSeconds(45), restored.getStartToCloseTimeout()); + 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("reason"); + assertPaused(handle); + handle.unpause(); + handle.updateOptions( + UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(90)).build()); + handle.reset(); + handle.terminate("cleanup"); + + assertTrue("pause should flow through the interceptor", events.contains("pause")); + assertTrue("unpause should flow through the interceptor", events.contains("unpause")); + assertTrue("reset should flow through the interceptor", events.contains("reset")); + assertTrue( + "updateOptions should flow through the interceptor", events.contains("updateOptions")); + } + + /** Records each operator command as it flows through the client interceptor chain. */ + private static class RecordingInterceptor extends ActivityClientInterceptorBase { + private final List events; + + 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"); + return super.pauseActivity(input); + } + + @Override + public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { + events.add("unpause"); + return super.unpauseActivity(input); + } + + @Override + public ResetActivityOutput resetActivity(ResetActivityInput input) { + events.add("reset"); + return super.resetActivity(input); + } + + @Override + public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsInput input) { + events.add("updateOptions"); + return super.updateActivityOptions(input); + } + }; + } + } +} 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 6b97e75ebf..5658170a73 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 @@ -77,7 +77,7 @@ public UpdateActivityOptionsOutput updateActivityOptions( } @Test - public void interceptorInvokesEachOperatorCommand() { + public void operatorCommandsBuildExpectedRequests() { when(genericClient.updateActivityOptions(any())) .thenReturn( UpdateActivityExecutionOptionsResponse.newBuilder() From d5a73eed09b9ee0b8de3a8190299c27fa6156ba6 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 24 Jun 2026 11:06:15 -0400 Subject: [PATCH 03/53] wip --- ...tandaloneActivityOperatorCommandsTest.java | 290 +++++++++++++++++- .../ActivityHandleOperatorCommandsTest.java | 118 ++----- 2 files changed, 312 insertions(+), 96 deletions(-) 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 index f105629d0e..0eba5442bf 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -13,8 +13,11 @@ import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.ActivityExecutionOptions; import io.temporal.client.ActivityHandle; +import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; +import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UpdateActivityOptions; +import io.temporal.common.Priority; import io.temporal.common.RetryOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.common.interceptors.ActivityClientCallsInterceptor.*; @@ -98,6 +101,52 @@ public String run() { } } + /** Always fails (every attempt) so the attempt counter keeps climbing while it retries. */ + @ActivityInterface + public interface AlwaysFailActivity { + @ActivityMethod(name = "AlwaysFail") + String run(); + } + + public static class AlwaysFailActivityImpl implements AlwaysFailActivity { + @Override + public String run() { + throw ApplicationFailure.newFailure( + "always fails on attempt " + Activity.getExecutionContext().getInfo().getAttempt(), + "retry-type"); + } + } + + /** + * Records heartbeat details on the first attempt then fails, so the details are persisted and the + * activity backs off (observable + pausable while scheduled). Later attempts just run without + * heartbeating, so once the details are cleared by reset_heartbeat they stay cleared (no running + * attempt re-populates them). + */ + @ActivityInterface + public interface HeartbeatThenStopActivity { + @ActivityMethod(name = "HeartbeatThenStop") + void run(); + } + + public static class HeartbeatThenStopActivityImpl implements HeartbeatThenStopActivity { + @Override + public void run() { + if (Activity.getExecutionContext().getInfo().getAttempt() == 1) { + Activity.getExecutionContext().heartbeat("hb-details"); + throw ApplicationFailure.newFailure("force retry", "retry-type"); + } + while (true) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + } + // --------------------------------------------------------------------------- // Rule + helpers // --------------------------------------------------------------------------- @@ -106,7 +155,11 @@ public String run() { public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() .setActivityImplementations( - new SlowActivityImpl(), new QuickActivityImpl(), new FailThenSucceedActivityImpl()) + new SlowActivityImpl(), + new QuickActivityImpl(), + new FailThenSucceedActivityImpl(), + new AlwaysFailActivityImpl(), + new HeartbeatThenStopActivityImpl()) .build(); /** @@ -152,6 +205,41 @@ private ActivityHandle startRunningSlowActivity(StartActivityOptions.Build return handle; } + /** + * Start a HeartbeatDetailsActivity and wait until its heartbeat details are visible via describe, + * so a subsequent reset_heartbeat has something observable to clear. + */ + /** + * Start a HeartbeatThenStopActivity and wait until its first attempt has recorded heartbeat + * details and the activity is backing off, so it can be paused into a true PAUSED state. + */ + private ActivityHandle startBackedOffHeartbeatActivity() { + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setHeartbeatTimeout(Duration.ofSeconds(30)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(10)) + .setBackoffCoefficient(1.0) + .setMaximumInterval(Duration.ofSeconds(10)) + .setMaximumAttempts(50) + .build()) + .build(); + ActivityHandle handle = + newActivityClient() + .start(HeartbeatThenStopActivity.class, HeartbeatThenStopActivity::run, opts); + assertEventually( + Duration.ofSeconds(30), + () -> + assertTrue( + "expected heartbeat details to be recorded", + handle.describe().hasHeartbeatDetails())); + return handle; + } + private StartActivityOptions.Builder slowOpts() { return StartActivityOptions.newBuilder() .setId(uniqueId()) @@ -267,6 +355,62 @@ public void updateOptionsRespectsMask() { 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); + + // task_queue is intentionally omitted: the server does not apply a task_queue change to a + // standalone activity via UpdateActivityExecutionOptions (it silently preserves the original), + // so it isn't observable here. + ActivityExecutionOptions updated = + handle.updateOptions( + UpdateActivityOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(200)) + .setScheduleToStartTimeout(Duration.ofSeconds(15)) + .setStartToCloseTimeout(Duration.ofSeconds(90)) + .setHeartbeatTimeout(Duration.ofSeconds(25)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(2.0) + .setMaximumAttempts(7) + .build()) + .setPriority(Priority.newBuilder().setPriorityKey(3).build()) + .build()); + + // Every field is settable and lands: the returned options reflect each new value. + 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()); + + // And describe reflects them server-side. + ActivityExecutionDescription desc = handle.describe(); + 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()); + + handle.terminate("cleanup"); + } + @Test public void updateOptionsRestoreOriginalExclusive() { assumeTrue(SDKTestWorkflowRule.useExternalService); @@ -306,6 +450,150 @@ public void updateOptionsRestoreOriginalAlone() { handle.terminate("cleanup"); } + // Overrides the rule's default 10s global timeout: driving retries + unpause takes longer. + @Test(timeout = 60_000) + public void unpauseResetsAttempts() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityClient client = newActivityClient(); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setRetryOptions( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofMillis(200)) + .setBackoffCoefficient(1.0) + .setMaximumInterval(Duration.ofMillis(200)) + .setMaximumAttempts(50) + .build()) + .build(); + ActivityHandle handle = + client.start(AlwaysFailActivity.class, AlwaysFailActivity::run, opts); + + // Wait until the activity has retried past its first attempt. + assertEventually( + Duration.ofSeconds(30), + () -> + assertTrue("expected attempt > 1 before unpause", handle.describe().getAttempt() > 1)); + + handle.pause("hold"); + assertPaused(handle); + + handle.unpause(UnpauseActivityOptions.newBuilder().setResetAttempts(true).build()); + + // reset_attempts rewinds the attempt counter back to 1. + assertEventually( + Duration.ofSeconds(30), + () -> assertEquals("attempt should be reset to 1", 1, handle.describe().getAttempt())); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void resetKeepsPaused() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + // Start delayed so the activity sits SCHEDULED and pauses to a true PAUSED state (not the + // PAUSE_REQUESTED of a running activity), which is what keep_paused must preserve across reset. + 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); + + handle.pause("hold"); + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, + handle.describe().getRunState())); + + handle.reset(ResetActivityOptions.newBuilder().setKeepPaused(true).build()); + + // keep_paused keeps the activity paused across the reset. + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + "expected activity to stay paused after reset", + PendingActivityState.PENDING_ACTIVITY_STATE_PAUSED, + handle.describe().getRunState())); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void resetRestoresOriginalOptions() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = + startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); + + ActivityExecutionOptions updated = + handle.updateOptions( + UpdateActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(90)) + .build()); + assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); + + handle.reset(ResetActivityOptions.newBuilder().setRestoreOriginalOptions(true).build()); + + // restore_original_options reverts start_to_close back to the value the activity started with. + assertEventually( + Duration.ofSeconds(30), + () -> + assertEquals( + "start_to_close should be restored to original", + Duration.ofSeconds(45), + handle.describe().getStartToCloseTimeout())); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void unpauseResetsHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startBackedOffHeartbeatActivity(); + + handle.pause("hold"); + assertPaused(handle); + + // Unpause re-dispatches the next attempt with heartbeat details cleared; that attempt does not + // heartbeat, so the details stay cleared and are observable. + handle.unpause(UnpauseActivityOptions.newBuilder().setResetHeartbeat(true).build()); + + assertEventually( + Duration.ofSeconds(30), + () -> + assertFalse( + "heartbeat details should be cleared after unpause(reset_heartbeat)", + handle.describe().hasHeartbeatDetails())); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void resetResetsHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startBackedOffHeartbeatActivity(); + + handle.pause("hold"); + assertPaused(handle); + + // keep_paused so no new attempt runs to re-record details; reset_heartbeat clears them in + // place. + handle.reset( + ResetActivityOptions.newBuilder().setResetHeartbeat(true).setKeepPaused(true).build()); + + assertEventually( + Duration.ofSeconds(30), + () -> + assertFalse( + "heartbeat details should be cleared after reset(reset_heartbeat, keep_paused)", + handle.describe().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() { 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 5658170a73..860120877c 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 @@ -2,34 +2,28 @@ import static org.junit.Assert.assertEquals; 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.activity.v1.ActivityOptions; import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; import io.temporal.api.workflowservice.v1.ResetActivityExecutionRequest; 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.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; -import io.temporal.client.UpdateActivityOptions; -import io.temporal.common.interceptors.ActivityClientCallsInterceptor; -import io.temporal.common.interceptors.ActivityClientCallsInterceptorBase; import io.temporal.internal.client.external.GenericWorkflowClient; import java.time.Duration; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; import org.junit.Test; +import org.mockito.ArgumentCaptor; /** - * Unit test verifying that each operator command on the activity handle flows through the - * interceptor chain and reaches the gRPC client. + * Unit test for the operator-command request fields the server does not surface back, so they can't + * be asserted against a real server: the pause/unpause reason, the unpause/reset jitter, and the + * pause request_id (a dedup UUID). Everything else the commands build — target ids, reset_attempts, + * reset_heartbeat, keep_paused, restore_original_options, and the update options/mask — is + * observable via describe and is covered by the real-server tests in {@link + * io.temporal.client.functional.StandaloneActivityOperatorCommandsTest}. */ public class ActivityHandleOperatorCommandsTest { @@ -41,123 +35,57 @@ public class ActivityHandleOperatorCommandsTest { .setIdentity("test-identity") .build(); - private final List recorded = new ArrayList<>(); - private UntypedActivityHandle newHandle() { - ActivityClientCallsInterceptor root = - new RootActivityClientInvoker(genericClient, clientOptions); - ActivityClientCallsInterceptor recording = - new ActivityClientCallsInterceptorBase(root) { - @Override - public PauseActivityOutput pauseActivity(PauseActivityInput input) { - recorded.add("pause"); - return super.pauseActivity(input); - } - - @Override - public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { - recorded.add("unpause"); - return super.unpauseActivity(input); - } - - @Override - public ResetActivityOutput resetActivity(ResetActivityInput input) { - recorded.add("reset"); - return super.resetActivity(input); - } - - @Override - public UpdateActivityOptionsOutput updateActivityOptions( - UpdateActivityOptionsInput input) { - recorded.add("updateOptions"); - return super.updateActivityOptions(input); - } - }; - return new ActivityHandleImpl("act-1", "run-1", recording); + return new ActivityHandleImpl( + "act-1", "run-1", new RootActivityClientInvoker(genericClient, clientOptions)); } @Test - public void operatorCommandsBuildExpectedRequests() { - when(genericClient.updateActivityOptions(any())) - .thenReturn( - UpdateActivityExecutionOptionsResponse.newBuilder() - .setActivityOptions( - ActivityOptions.newBuilder() - .setStartToCloseTimeout( - com.google.protobuf.Duration.newBuilder().setSeconds(30).build())) - .build()); - + public void unobservableRequestFields() { UntypedActivityHandle handle = newHandle(); handle.pause("because"); handle.unpause( UnpauseActivityOptions.newBuilder() - .setResetAttempts(true) - .setResetHeartbeat(true) - .setJitter(Duration.ofSeconds(5)) .setReason("go") + .setJitter(Duration.ofSeconds(5)) .build()); - handle.reset( - ResetActivityOptions.newBuilder() - .setResetHeartbeat(true) - .setKeepPaused(true) - .setJitter(Duration.ofSeconds(2)) - .build()); - handle.updateOptions( - UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(30)).build()); - - // Each command flowed through the interceptor. - assertEquals(Arrays.asList("pause", "unpause", "reset", "updateOptions"), recorded); + handle.reset(ResetActivityOptions.newBuilder().setJitter(Duration.ofSeconds(2)).build()); - // Each command reached the gRPC client with the expected fields. + // pause carries the reason and an auto-generated dedup request_id; neither is returned by + // describe. PauseActivityExecutionRequest pauseReq = capturePause(); - assertEquals("act-1", pauseReq.getActivityId()); - assertEquals("run-1", pauseReq.getRunId()); assertEquals("because", pauseReq.getReason()); - assertEquals("", pauseReq.getWorkflowId()); - assertTrue(!pauseReq.getRequestId().isEmpty()); + assertTrue("request_id should be set", !pauseReq.getRequestId().isEmpty()); + // unpause carries the reason and jitter; neither is observable on the server. UnpauseActivityExecutionRequest unpauseReq = captureUnpause(); - assertTrue(unpauseReq.getResetAttempts()); - assertTrue(unpauseReq.getResetHeartbeat()); assertEquals("go", unpauseReq.getReason()); assertEquals(5, unpauseReq.getJitter().getSeconds()); + // reset carries the jitter. ResetActivityExecutionRequest resetReq = captureReset(); - assertTrue(resetReq.getResetHeartbeat()); - assertTrue(resetReq.getKeepPaused()); assertEquals(2, resetReq.getJitter().getSeconds()); - - UpdateActivityExecutionOptionsRequest updateReq = captureUpdate(); - assertEquals(Arrays.asList("start_to_close_timeout"), updateReq.getUpdateMask().getPathsList()); - assertEquals(30, updateReq.getActivityOptions().getStartToCloseTimeout().getSeconds()); } private PauseActivityExecutionRequest capturePause() { - org.mockito.ArgumentCaptor captor = - org.mockito.ArgumentCaptor.forClass(PauseActivityExecutionRequest.class); + ArgumentCaptor captor = + ArgumentCaptor.forClass(PauseActivityExecutionRequest.class); verify(genericClient).pauseActivity(captor.capture()); return captor.getValue(); } private UnpauseActivityExecutionRequest captureUnpause() { - org.mockito.ArgumentCaptor captor = - org.mockito.ArgumentCaptor.forClass(UnpauseActivityExecutionRequest.class); + ArgumentCaptor captor = + ArgumentCaptor.forClass(UnpauseActivityExecutionRequest.class); verify(genericClient).unpauseActivity(captor.capture()); return captor.getValue(); } private ResetActivityExecutionRequest captureReset() { - org.mockito.ArgumentCaptor captor = - org.mockito.ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); + ArgumentCaptor captor = + ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); verify(genericClient).resetActivity(captor.capture()); return captor.getValue(); } - - private UpdateActivityExecutionOptionsRequest captureUpdate() { - org.mockito.ArgumentCaptor captor = - org.mockito.ArgumentCaptor.forClass(UpdateActivityExecutionOptionsRequest.class); - verify(genericClient).updateActivityOptions(captor.capture()); - return captor.getValue(); - } } From 9d99a8ed60916d9a9ed573fa255cd01cff25f5e8 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 24 Jun 2026 11:58:38 -0400 Subject: [PATCH 04/53] wip --- .../temporal/client/ResetActivityOptions.java | 2 ++ .../ActivityClientCallsInterceptor.java | 21 ++++++++++--------- ...tandaloneActivityOperatorCommandsTest.java | 19 ++++++----------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java index ec2a63053f..a0a77718a0 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java @@ -71,6 +71,8 @@ public Builder setJitter(@Nullable Duration jitter) { /** * If set, the activity options are restored to the originals the activity was created with (the * options recorded in the first schedule event). + * + *

This flag may be combined with other reset settings. */ public Builder setRestoreOriginalOptions(boolean restoreOriginalOptions) { this.restoreOriginalOptions = 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 1f0ce68431..d83db7a501 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 @@ -1,5 +1,7 @@ package io.temporal.common.interceptors; +import com.google.protobuf.FieldMask; +import io.temporal.api.activity.v1.ActivityOptions; import io.temporal.client.ActivityAlreadyStartedException; import io.temporal.client.ActivityExecutionCount; import io.temporal.client.ActivityExecutionDescription; @@ -519,15 +521,15 @@ final class ResetActivityOutput {} final class UpdateActivityOptionsInput { private final String id; private final @Nullable String runId; - private final io.temporal.api.activity.v1.ActivityOptions activityOptions; - private final com.google.protobuf.FieldMask updateMask; + private final ActivityOptions activityOptions; + private final FieldMask updateMask; private final boolean restoreOriginal; public UpdateActivityOptionsInput( String id, @Nullable String runId, - io.temporal.api.activity.v1.ActivityOptions activityOptions, - com.google.protobuf.FieldMask updateMask, + ActivityOptions activityOptions, + FieldMask updateMask, boolean restoreOriginal) { this.id = id; this.runId = runId; @@ -545,11 +547,11 @@ public String getRunId() { return runId; } - public io.temporal.api.activity.v1.ActivityOptions getActivityOptions() { + public ActivityOptions getActivityOptions() { return activityOptions; } - public com.google.protobuf.FieldMask getUpdateMask() { + public FieldMask getUpdateMask() { return updateMask; } @@ -560,15 +562,14 @@ public boolean isRestoreOriginal() { @Experimental final class UpdateActivityOptionsOutput { - private final io.temporal.api.activity.v1.ActivityOptions activityOptions; + private final ActivityOptions activityOptions; - public UpdateActivityOptionsOutput( - io.temporal.api.activity.v1.ActivityOptions activityOptions) { + public UpdateActivityOptionsOutput(ActivityOptions activityOptions) { this.activityOptions = activityOptions; } /** The activity options as resolved by the server after the update. */ - public io.temporal.api.activity.v1.ActivityOptions getActivityOptions() { + public ActivityOptions getActivityOptions() { return activityOptions; } } 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 index 0eba5442bf..cbd01d6381 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -183,7 +183,7 @@ private ActivityClient newActivityClient() { ActivityClientOptions.newBuilder().setNamespace(SDKTestWorkflowRule.NAMESPACE).build()); } - private void assertPaused(ActivityHandle handle) { + private void assertEventuallyPaused(ActivityHandle handle) { assertEventually( Duration.ofSeconds(30), () -> @@ -205,10 +205,6 @@ private ActivityHandle startRunningSlowActivity(StartActivityOptions.Build return handle; } - /** - * Start a HeartbeatDetailsActivity and wait until its heartbeat details are visible via describe, - * so a subsequent reset_heartbeat has something observable to clear. - */ /** * Start a HeartbeatThenStopActivity and wait until its first attempt has recorded heartbeat * details and the activity is backing off, so it can be paused into a true PAUSED state. @@ -257,7 +253,7 @@ public void pauseShowsPaused() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startRunningSlowActivity(slowOpts()); handle.pause("test-pause-reason"); - assertPaused(handle); + assertEventuallyPaused(handle); handle.terminate("cleanup"); } @@ -372,9 +368,6 @@ public void updateOptionsAllFields() { ActivityHandle handle = newActivityClient().start(QuickActivity.class, QuickActivity::run, opts); - // task_queue is intentionally omitted: the server does not apply a task_queue change to a - // standalone activity via UpdateActivityExecutionOptions (it silently preserves the original), - // so it isn't observable here. ActivityExecutionOptions updated = handle.updateOptions( UpdateActivityOptions.newBuilder() @@ -478,7 +471,7 @@ public void unpauseResetsAttempts() { assertTrue("expected attempt > 1 before unpause", handle.describe().getAttempt() > 1)); handle.pause("hold"); - assertPaused(handle); + assertEventuallyPaused(handle); handle.unpause(UnpauseActivityOptions.newBuilder().setResetAttempts(true).build()); @@ -557,7 +550,7 @@ public void unpauseResetsHeartbeat() { ActivityHandle handle = startBackedOffHeartbeatActivity(); handle.pause("hold"); - assertPaused(handle); + assertEventuallyPaused(handle); // Unpause re-dispatches the next attempt with heartbeat details cleared; that attempt does not // heartbeat, so the details stay cleared and are observable. @@ -578,7 +571,7 @@ public void resetResetsHeartbeat() { ActivityHandle handle = startBackedOffHeartbeatActivity(); handle.pause("hold"); - assertPaused(handle); + assertEventuallyPaused(handle); // keep_paused so no new attempt runs to re-record details; reset_heartbeat clears them in // place. @@ -617,7 +610,7 @@ public void interceptorInvokesEachOperatorCommand() { handle.describe().getRunState())); handle.pause("reason"); - assertPaused(handle); + assertEventuallyPaused(handle); handle.unpause(); handle.updateOptions( UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(90)).build()); From 68d48cac3be2cc0c9d2f60abc793ae16352f6977 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 24 Jun 2026 12:08:58 -0400 Subject: [PATCH 05/53] wip --- .../client/ActivityHandleOperatorCommandsTest.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) 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 860120877c..504ea7638a 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 @@ -18,12 +18,7 @@ import org.mockito.ArgumentCaptor; /** - * Unit test for the operator-command request fields the server does not surface back, so they can't - * be asserted against a real server: the pause/unpause reason, the unpause/reset jitter, and the - * pause request_id (a dedup UUID). Everything else the commands build — target ids, reset_attempts, - * reset_heartbeat, keep_paused, restore_original_options, and the update options/mask — is - * observable via describe and is covered by the real-server tests in {@link - * io.temporal.client.functional.StandaloneActivityOperatorCommandsTest}. + * Unit test for the operator-command request fields that the server does not surface back. */ public class ActivityHandleOperatorCommandsTest { From 8c4e9ac48f9628e120ac30f2d39b86854fe236f0 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 25 Jun 2026 12:55:14 -0400 Subject: [PATCH 06/53] Consistent test naming --- .../functional/StandaloneActivityOperatorCommandsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index cbd01d6381..f49185842a 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -423,7 +423,7 @@ public void updateOptionsRestoreOriginalExclusive() { } @Test - public void updateOptionsRestoreOriginalAlone() { + public void updateOptionsRestoreOriginal() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); From fde3769b22de2c6634ffcc36aa0ea636644fda1f Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 25 Jun 2026 13:01:46 -0400 Subject: [PATCH 07/53] Extra assertions --- .../internal/client/ActivityHandleOperatorCommandsTest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 504ea7638a..97f3a5d718 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 @@ -17,9 +17,7 @@ import org.junit.Test; import org.mockito.ArgumentCaptor; -/** - * Unit test for the operator-command request fields that the server does not surface back. - */ +/** 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); @@ -57,10 +55,12 @@ public void unobservableRequestFields() { UnpauseActivityExecutionRequest unpauseReq = captureUnpause(); assertEquals("go", unpauseReq.getReason()); assertEquals(5, unpauseReq.getJitter().getSeconds()); + assertEquals(0, unpauseReq.getJitter().getNanos()); // reset carries the jitter. ResetActivityExecutionRequest resetReq = captureReset(); assertEquals(2, resetReq.getJitter().getSeconds()); + assertEquals(0, resetReq.getJitter().getNanos()); } private PauseActivityExecutionRequest capturePause() { From bf9526fac0f0f52ade906685f416f6cef6f6f5ca Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 25 Jun 2026 13:57:33 -0400 Subject: [PATCH 08/53] Redundant test --- .../StandaloneActivityOperatorCommandsTest.java | 9 --------- 1 file changed, 9 deletions(-) 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 index f49185842a..c4598d7664 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -248,15 +248,6 @@ private StartActivityOptions.Builder slowOpts() { // Tests // --------------------------------------------------------------------------- - @Test - public void pauseShowsPaused() { - assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startRunningSlowActivity(slowOpts()); - handle.pause("test-pause-reason"); - assertEventuallyPaused(handle); - handle.terminate("cleanup"); - } - // Overrides the rule's default 10s global timeout: the start delay makes this take longer. @Test(timeout = 60_000) public void unpauseResumes() { From 8b1fbf95338cfc41d92711db3e3e0e66e27dd02b Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 26 Jun 2026 13:57:53 -0400 Subject: [PATCH 09/53] Task queue update fix --- .../java/io/temporal/internal/client/ActivityHandleImpl.java | 2 +- .../functional/StandaloneActivityOperatorCommandsTest.java | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) 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 bb574ae962..82b85f2068 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 @@ -199,7 +199,7 @@ public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { if (options.getTaskQueue() != null) { activityOptions.setTaskQueue( TaskQueue.newBuilder().setName(options.getTaskQueue()).build()); - maskPaths.add("task_queue"); + maskPaths.add("task_queue.name"); } if (options.getScheduleToCloseTimeout() != null) { activityOptions.setScheduleToCloseTimeout( 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 index c4598d7664..76417d1c8c 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -362,6 +362,7 @@ public void updateOptionsAllFields() { ActivityExecutionOptions updated = handle.updateOptions( UpdateActivityOptions.newBuilder() + .setTaskQueue("updated-tq") .setScheduleToCloseTimeout(Duration.ofSeconds(200)) .setScheduleToStartTimeout(Duration.ofSeconds(15)) .setStartToCloseTimeout(Duration.ofSeconds(90)) @@ -376,6 +377,7 @@ public void updateOptionsAllFields() { .build()); // 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()); @@ -385,6 +387,7 @@ public void updateOptionsAllFields() { // 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()); From cfb18283455d9a498f4411b65ce6962cac95885b Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 31 Jul 2026 10:57:02 -0400 Subject: [PATCH 10/53] Update server deps --- .../temporal/client/ResetActivityOptions.java | 25 ++++------------ .../ActivityClientCallsInterceptor.java | 7 ----- .../internal/client/ActivityHandleImpl.java | 1 - .../client/RootActivityClientInvoker.java | 6 ++-- ...tandaloneActivityOperatorCommandsTest.java | 11 ++++--- .../ActivityHandleOperatorCommandsTest.java | 29 +++++++++++++++++-- temporal-serviceclient/src/main/proto | 2 +- 7 files changed, 41 insertions(+), 40 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java index a0a77718a0..2380a8887a 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java @@ -10,6 +10,8 @@ * *

All fields are optional. An instance with no fields set resets the activity with default * behavior. + * + *

Reset always clears recorded heartbeat details. */ @Experimental public final class ResetActivityOptions { @@ -30,7 +32,6 @@ public static ResetActivityOptions getDefaultInstance() { ResetActivityOptions.newBuilder().build(); public static final class Builder { - private boolean resetHeartbeat; private boolean keepPaused; private @Nullable Duration jitter; private boolean restoreOriginalOptions; @@ -41,18 +42,11 @@ private Builder(ResetActivityOptions options) { if (options == null) { return; } - this.resetHeartbeat = options.resetHeartbeat; this.keepPaused = options.keepPaused; this.jitter = options.jitter; this.restoreOriginalOptions = options.restoreOriginalOptions; } - /** If set, the reset activity will clear its recorded heartbeat details. */ - public Builder setResetHeartbeat(boolean resetHeartbeat) { - this.resetHeartbeat = resetHeartbeat; - return this; - } - /** If set and the activity is paused, it will remain paused after the reset. */ public Builder setKeepPaused(boolean keepPaused) { this.keepPaused = keepPaused; @@ -84,13 +78,11 @@ public ResetActivityOptions build() { } } - private final boolean resetHeartbeat; private final boolean keepPaused; private final @Nullable Duration jitter; private final boolean restoreOriginalOptions; private ResetActivityOptions(Builder builder) { - this.resetHeartbeat = builder.resetHeartbeat; this.keepPaused = builder.keepPaused; this.jitter = builder.jitter; this.restoreOriginalOptions = builder.restoreOriginalOptions; @@ -100,10 +92,6 @@ public Builder toBuilder() { return new Builder(this); } - public boolean isResetHeartbeat() { - return resetHeartbeat; - } - public boolean isKeepPaused() { return keepPaused; } @@ -122,23 +110,20 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; ResetActivityOptions that = (ResetActivityOptions) o; - return resetHeartbeat == that.resetHeartbeat - && keepPaused == that.keepPaused + return keepPaused == that.keepPaused && restoreOriginalOptions == that.restoreOriginalOptions && Objects.equals(jitter, that.jitter); } @Override public int hashCode() { - return Objects.hash(resetHeartbeat, keepPaused, jitter, restoreOriginalOptions); + return Objects.hash(keepPaused, jitter, restoreOriginalOptions); } @Override public String toString() { return "ResetActivityOptions{" - + "resetHeartbeat=" - + resetHeartbeat - + ", keepPaused=" + + "keepPaused=" + keepPaused + ", jitter=" + jitter 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 d83db7a501..bf8ecb64a2 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 @@ -467,7 +467,6 @@ final class UnpauseActivityOutput {} final class ResetActivityInput { private final String id; private final @Nullable String runId; - private final boolean resetHeartbeat; private final boolean keepPaused; private final @Nullable Duration jitter; private final boolean restoreOriginalOptions; @@ -475,13 +474,11 @@ final class ResetActivityInput { public ResetActivityInput( String id, @Nullable String runId, - boolean resetHeartbeat, boolean keepPaused, @Nullable Duration jitter, boolean restoreOriginalOptions) { this.id = id; this.runId = runId; - this.resetHeartbeat = resetHeartbeat; this.keepPaused = keepPaused; this.jitter = jitter; this.restoreOriginalOptions = restoreOriginalOptions; @@ -496,10 +493,6 @@ public String getRunId() { return runId; } - public boolean isResetHeartbeat() { - return resetHeartbeat; - } - public boolean isKeepPaused() { return keepPaused; } 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 82b85f2068..748dbd1673 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 @@ -184,7 +184,6 @@ public void reset(ResetActivityOptions options) { new ActivityClientCallsInterceptor.ResetActivityInput( activityId, activityRunId, - options.isResetHeartbeat(), options.isKeepPaused(), options.getJitter(), options.isRestoreOriginalOptions())); 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 8d71aa0af0..2a468f245b 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 @@ -361,6 +361,7 @@ public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { .setNamespace(clientOptions.getNamespace()) .setIdentity(clientOptions.getIdentity()) .setActivityId(input.getId()) + .setRequestId(UUID.randomUUID().toString()) .setResetAttempts(input.isResetAttempts()) .setResetHeartbeat(input.isResetHeartbeat()); if (input.getRunId() != null) { @@ -383,7 +384,7 @@ public ResetActivityOutput resetActivity(ResetActivityInput input) { .setNamespace(clientOptions.getNamespace()) .setIdentity(clientOptions.getIdentity()) .setActivityId(input.getId()) - .setResetHeartbeat(input.isResetHeartbeat()) + .setRequestId(UUID.randomUUID().toString()) .setKeepPaused(input.isKeepPaused()) .setRestoreOriginalOptions(input.isRestoreOriginalOptions()); if (input.getRunId() != null) { @@ -402,7 +403,8 @@ public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsIn UpdateActivityExecutionOptionsRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) .setIdentity(clientOptions.getIdentity()) - .setActivityId(input.getId()); + .setActivityId(input.getId()) + .setRequestId(UUID.randomUUID().toString()); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } 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 index 76417d1c8c..01204515a2 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -560,23 +560,22 @@ public void unpauseResetsHeartbeat() { } @Test(timeout = 60_000) - public void resetResetsHeartbeat() { + public void resetClearsHeartbeatByDefault() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startBackedOffHeartbeatActivity(); handle.pause("hold"); assertEventuallyPaused(handle); - // keep_paused so no new attempt runs to re-record details; reset_heartbeat clears them in - // place. - handle.reset( - ResetActivityOptions.newBuilder().setResetHeartbeat(true).setKeepPaused(true).build()); + // reset always clears heartbeat details (there is no opt-in flag as of api#820); + // keep_paused so no new attempt runs to re-record them. + handle.reset(ResetActivityOptions.newBuilder().setKeepPaused(true).build()); assertEventually( Duration.ofSeconds(30), () -> assertFalse( - "heartbeat details should be cleared after reset(reset_heartbeat, keep_paused)", + "heartbeat details should be cleared after reset(keep_paused)", handle.describe().hasHeartbeatDetails())); handle.terminate("cleanup"); } 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 97f3a5d718..7014932949 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 @@ -2,16 +2,21 @@ import static org.junit.Assert.assertEquals; 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.workflowservice.v1.PauseActivityExecutionRequest; import io.temporal.api.workflowservice.v1.ResetActivityExecutionRequest; 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.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; +import io.temporal.client.UpdateActivityOptions; import io.temporal.internal.client.external.GenericWorkflowClient; import java.time.Duration; import org.junit.Test; @@ -35,6 +40,10 @@ private UntypedActivityHandle newHandle() { @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("because"); @@ -44,23 +53,30 @@ public void unobservableRequestFields() { .setJitter(Duration.ofSeconds(5)) .build()); handle.reset(ResetActivityOptions.newBuilder().setJitter(Duration.ofSeconds(2)).build()); + handle.updateOptions(UpdateActivityOptions.newBuilder().setRestoreOriginal(true).build()); // pause carries the reason and an auto-generated dedup request_id; neither is returned by // describe. PauseActivityExecutionRequest pauseReq = capturePause(); assertEquals("because", pauseReq.getReason()); - assertTrue("request_id should be set", !pauseReq.getRequestId().isEmpty()); + assertTrue("pause request_id should be set", !pauseReq.getRequestId().isEmpty()); - // unpause carries the reason and jitter; neither is observable on the server. + // unpause carries the reason, jitter, and an auto-generated dedup request_id (api#844). UnpauseActivityExecutionRequest unpauseReq = captureUnpause(); assertEquals("go", unpauseReq.getReason()); assertEquals(5, unpauseReq.getJitter().getSeconds()); assertEquals(0, unpauseReq.getJitter().getNanos()); + assertTrue("unpause request_id should be set", !unpauseReq.getRequestId().isEmpty()); - // reset carries the jitter. + // reset carries jitter and an auto-generated dedup request_id (api#844). ResetActivityExecutionRequest resetReq = captureReset(); assertEquals(2, resetReq.getJitter().getSeconds()); assertEquals(0, resetReq.getJitter().getNanos()); + assertTrue("reset request_id should be set", !resetReq.getRequestId().isEmpty()); + + // updateOptions carries an auto-generated dedup request_id (api#844). + UpdateActivityExecutionOptionsRequest updateReq = captureUpdate(); + assertTrue("updateOptions request_id should be set", !updateReq.getRequestId().isEmpty()); } private PauseActivityExecutionRequest capturePause() { @@ -83,4 +99,11 @@ private ResetActivityExecutionRequest captureReset() { verify(genericClient).resetActivity(captor.capture()); return captor.getValue(); } + + private UpdateActivityExecutionOptionsRequest captureUpdate() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(UpdateActivityExecutionOptionsRequest.class); + verify(genericClient).updateActivityOptions(captor.capture()); + return captor.getValue(); + } } diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index d2fc34ab84..5304b54b93 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit d2fc34ab844603f50e41365f46c7fb82bdedffe6 +Subproject commit 5304b54b931f584c0c2d9a710256472ecc4fbf2a From ba784687edd1fbc803a5b88e7028f76ac5e38c45 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 31 Jul 2026 13:30:12 -0400 Subject: [PATCH 11/53] Fix heartbeat tests --- .../client/RootActivityClientInvoker.java | 4 +- ...tandaloneActivityOperatorCommandsTest.java | 107 +++++++++++++----- 2 files changed, 82 insertions(+), 29 deletions(-) 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 2a468f245b..10029a4f68 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 @@ -290,7 +290,9 @@ public DescribeActivityOutput describeActivity(DescribeActivityInput input) { DescribeActivityExecutionRequest.Builder req = DescribeActivityExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) - .setActivityId(input.getId()); + .setActivityId(input.getId()) + .setIncludeHeartbeatDetails(true) + .setIncludeLastFailure(true); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } 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 index 01204515a2..fdc0e7bb8f 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -5,6 +5,8 @@ import static org.junit.Assume.assumeTrue; import io.temporal.activity.Activity; +import io.temporal.activity.ActivityCancellationToken; +import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityMethod; import io.temporal.api.enums.v1.PendingActivityState; @@ -118,25 +120,26 @@ public String run() { } /** - * Records heartbeat details on the first attempt then fails, so the details are persisted and the - * activity backs off (observable + pausable while scheduled). Later attempts just run without - * heartbeating, so once the details are cleared by reset_heartbeat they stay cleared (no running - * attempt re-populates them). + * 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 a reset or an unpause that spawns a new attempt) + * do not heartbeat, so any operator-driven clearing of the details stays observable. */ @ActivityInterface - public interface HeartbeatThenStopActivity { - @ActivityMethod(name = "HeartbeatThenStop") + public interface HeartbeatOnceActivity { + @ActivityMethod(name = "HeartbeatOnce") void run(); } - public static class HeartbeatThenStopActivityImpl implements HeartbeatThenStopActivity { + public static class HeartbeatOnceActivityImpl implements HeartbeatOnceActivity { @Override public void run() { - if (Activity.getExecutionContext().getInfo().getAttempt() == 1) { - Activity.getExecutionContext().heartbeat("hb-details"); - throw ApplicationFailure.newFailure("force retry", "retry-type"); + ActivityExecutionContext ctx = Activity.getExecutionContext(); + if (ctx.getInfo().getAttempt() == 1) { + ctx.heartbeat("hb-details"); } - while (true) { + ActivityCancellationToken token = ctx.getCancellationToken(); + while (!token.isCancellationRequested()) { try { Thread.sleep(100); } catch (InterruptedException e) { @@ -159,7 +162,7 @@ public void run() { new QuickActivityImpl(), new FailThenSucceedActivityImpl(), new AlwaysFailActivityImpl(), - new HeartbeatThenStopActivityImpl()) + new HeartbeatOnceActivityImpl()) .build(); /** @@ -206,27 +209,21 @@ private ActivityHandle startRunningSlowActivity(StartActivityOptions.Build } /** - * Start a HeartbeatThenStopActivity and wait until its first attempt has recorded heartbeat - * details and the activity is backing off, so it can be paused into a true PAUSED state. + * 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 startBackedOffHeartbeatActivity() { + private ActivityHandle startHeartbeatReadyActivity() { StartActivityOptions opts = StartActivityOptions.newBuilder() .setId(uniqueId()) .setTaskQueue(testWorkflowRule.getTaskQueue()) .setStartToCloseTimeout(Duration.ofSeconds(60)) .setHeartbeatTimeout(Duration.ofSeconds(30)) - .setRetryOptions( - RetryOptions.newBuilder() - .setInitialInterval(Duration.ofSeconds(10)) - .setBackoffCoefficient(1.0) - .setMaximumInterval(Duration.ofSeconds(10)) - .setMaximumAttempts(50) - .build()) .build(); ActivityHandle handle = - newActivityClient() - .start(HeartbeatThenStopActivity.class, HeartbeatThenStopActivity::run, opts); + newActivityClient().start(HeartbeatOnceActivity.class, HeartbeatOnceActivity::run, opts); assertEventually( Duration.ofSeconds(30), () -> @@ -538,16 +535,52 @@ public void resetRestoresOriginalOptions() { handle.terminate("cleanup"); } + @Test(timeout = 60_000) + public void pausePreservesHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + handle.pause("hold"); + assertEventuallyPaused(handle); + + // Pause never touches heartbeat details — they persist across the transition. + assertTrue( + "heartbeat details should be preserved across pause", + handle.describe().hasHeartbeatDetails()); + handle.terminate("cleanup"); + } + + @Test(timeout = 60_000) + public void unpausePreservesHeartbeatByDefault() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + handle.pause("hold"); + assertEventuallyPaused(handle); + + // Default unpause (no reset_heartbeat flag) preserves 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 default unpause", + handle.describe().hasHeartbeatDetails())); + handle.terminate("cleanup"); + } + @Test(timeout = 60_000) public void unpauseResetsHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startBackedOffHeartbeatActivity(); + ActivityHandle handle = startHeartbeatReadyActivity(); handle.pause("hold"); assertEventuallyPaused(handle); - // Unpause re-dispatches the next attempt with heartbeat details cleared; that attempt does not - // heartbeat, so the details stay cleared and are observable. + // Opt-in flag clears details. The re-dispatched attempt doesn't heartbeat, so cleared stays + // cleared and is observable. handle.unpause(UnpauseActivityOptions.newBuilder().setResetHeartbeat(true).build()); assertEventually( @@ -562,7 +595,7 @@ public void unpauseResetsHeartbeat() { @Test(timeout = 60_000) public void resetClearsHeartbeatByDefault() { assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startBackedOffHeartbeatActivity(); + ActivityHandle handle = startHeartbeatReadyActivity(); handle.pause("hold"); assertEventuallyPaused(handle); @@ -580,6 +613,24 @@ public void resetClearsHeartbeatByDefault() { handle.terminate("cleanup"); } + @Test(timeout = 60_000) + public void updateOptionsPreservesHeartbeat() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + handle.pause("hold"); + assertEventuallyPaused(handle); + + // UpdateOptions changes activity options only; it never touches heartbeat details. + handle.updateOptions( + UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(90)).build()); + + assertTrue( + "heartbeat details should be preserved after updateOptions", + handle.describe().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() { From 6fc294df66c586db00a8844a35b9ed07df4985a2 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 31 Jul 2026 14:23:50 -0400 Subject: [PATCH 12/53] Confirm UpdateOptions surface handles start_delay --- .../client/ActivityExecutionOptions.java | 18 ++++++++++--- .../client/UpdateActivityOptions.java | 27 ++++++++++++++++--- .../internal/client/ActivityHandleImpl.java | 7 ++++- ...tandaloneActivityOperatorCommandsTest.java | 4 +++ .../ActivityHandleOperatorCommandsTest.java | 12 +++++++-- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java index 1730ec8339..67b3e1d3d5 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java @@ -23,6 +23,7 @@ public final class ActivityExecutionOptions { private final @Nullable Duration heartbeatTimeout; private final @Nullable RetryOptions retryOptions; private final @Nullable Priority priority; + private final @Nullable Duration startDelay; public ActivityExecutionOptions( @Nullable String taskQueue, @@ -31,7 +32,8 @@ public ActivityExecutionOptions( @Nullable Duration startToCloseTimeout, @Nullable Duration heartbeatTimeout, @Nullable RetryOptions retryOptions, - @Nullable Priority priority) { + @Nullable Priority priority, + @Nullable Duration startDelay) { this.taskQueue = taskQueue; this.scheduleToCloseTimeout = scheduleToCloseTimeout; this.scheduleToStartTimeout = scheduleToStartTimeout; @@ -39,6 +41,7 @@ public ActivityExecutionOptions( this.heartbeatTimeout = heartbeatTimeout; this.retryOptions = retryOptions; this.priority = priority; + this.startDelay = startDelay; } @Nullable @@ -76,6 +79,11 @@ public Priority getPriority() { return priority; } + @Nullable + public Duration getStartDelay() { + return startDelay; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -87,7 +95,8 @@ public boolean equals(Object o) { && Objects.equals(startToCloseTimeout, that.startToCloseTimeout) && Objects.equals(heartbeatTimeout, that.heartbeatTimeout) && Objects.equals(retryOptions, that.retryOptions) - && Objects.equals(priority, that.priority); + && Objects.equals(priority, that.priority) + && Objects.equals(startDelay, that.startDelay); } @Override @@ -99,7 +108,8 @@ public int hashCode() { startToCloseTimeout, heartbeatTimeout, retryOptions, - priority); + priority, + startDelay); } @Override @@ -119,6 +129,8 @@ public String toString() { + retryOptions + ", priority=" + priority + + ", startDelay=" + + startDelay + '}'; } } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java index 432bc477ab..132333a96b 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java @@ -37,6 +37,7 @@ public static final class Builder { private @Nullable Duration heartbeatTimeout; private @Nullable RetryOptions retryOptions; private @Nullable Priority priority; + private @Nullable Duration startDelay; private boolean restoreOriginal; private Builder() {} @@ -52,6 +53,7 @@ private Builder(UpdateActivityOptions options) { this.heartbeatTimeout = options.heartbeatTimeout; this.retryOptions = options.retryOptions; this.priority = options.priority; + this.startDelay = options.startDelay; this.restoreOriginal = options.restoreOriginal; } @@ -97,6 +99,12 @@ public Builder setPriority(@Nullable Priority priority) { return this; } + /** New start delay for the first attempt. */ + public Builder setStartDelay(@Nullable Duration startDelay) { + this.startDelay = startDelay; + return this; + } + /** * If set, the activity options are restored to the originals the activity was created with. * This flag cannot be combined with any other field. @@ -115,7 +123,8 @@ public UpdateActivityOptions build() { && startToCloseTimeout == null && heartbeatTimeout == null && retryOptions == null - && priority == null, + && priority == null + && startDelay == null, "restoreOriginal cannot be combined with any other option"); } else { Preconditions.checkArgument( @@ -125,7 +134,8 @@ public UpdateActivityOptions build() { || startToCloseTimeout != null || heartbeatTimeout != null || retryOptions != null - || priority != null, + || priority != null + || startDelay != null, "At least one option must be set, or restoreOriginal must be used"); } return new UpdateActivityOptions(this); @@ -139,6 +149,7 @@ public UpdateActivityOptions build() { private final @Nullable Duration heartbeatTimeout; private final @Nullable RetryOptions retryOptions; private final @Nullable Priority priority; + private final @Nullable Duration startDelay; private final boolean restoreOriginal; private UpdateActivityOptions(Builder builder) { @@ -149,6 +160,7 @@ private UpdateActivityOptions(Builder builder) { this.heartbeatTimeout = builder.heartbeatTimeout; this.retryOptions = builder.retryOptions; this.priority = builder.priority; + this.startDelay = builder.startDelay; this.restoreOriginal = builder.restoreOriginal; } @@ -191,6 +203,11 @@ public Priority getPriority() { return priority; } + @Nullable + public Duration getStartDelay() { + return startDelay; + } + public boolean isRestoreOriginal() { return restoreOriginal; } @@ -207,7 +224,8 @@ public boolean equals(Object o) { && Objects.equals(startToCloseTimeout, that.startToCloseTimeout) && Objects.equals(heartbeatTimeout, that.heartbeatTimeout) && Objects.equals(retryOptions, that.retryOptions) - && Objects.equals(priority, that.priority); + && Objects.equals(priority, that.priority) + && Objects.equals(startDelay, that.startDelay); } @Override @@ -220,6 +238,7 @@ public int hashCode() { heartbeatTimeout, retryOptions, priority, + startDelay, restoreOriginal); } @@ -240,6 +259,8 @@ public String toString() { + retryOptions + ", priority=" + priority + + ", startDelay=" + + startDelay + ", restoreOriginal=" + restoreOriginal + '}'; 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 748dbd1673..bc94cf70bb 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 @@ -228,6 +228,10 @@ public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { activityOptions.setPriority(ProtoConverters.toProto(options.getPriority())); maskPaths.add("priority"); } + if (options.getStartDelay() != null) { + activityOptions.setStartDelay(ProtobufTimeUtils.toProtoDuration(options.getStartDelay())); + maskPaths.add("start_delay"); + } } FieldMask updateMask = FieldMask.newBuilder().addAllPaths(maskPaths).build(); @@ -260,6 +264,7 @@ private static ActivityExecutionOptions fromProto(ActivityOptions proto) { ? ProtobufTimeUtils.toJavaDuration(proto.getHeartbeatTimeout()) : null, proto.hasRetryPolicy() ? RetryOptionsUtils.toRetryOptions(proto.getRetryPolicy()) : null, - proto.hasPriority() ? ProtoConverters.fromProto(proto.getPriority()) : null); + proto.hasPriority() ? ProtoConverters.fromProto(proto.getPriority()) : null, + proto.hasStartDelay() ? ProtobufTimeUtils.toJavaDuration(proto.getStartDelay()) : null); } } 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 index fdc0e7bb8f..4ad82dc641 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -371,6 +371,7 @@ public void updateOptionsAllFields() { .setMaximumAttempts(7) .build()) .setPriority(Priority.newBuilder().setPriorityKey(3).build()) + .setStartDelay(Duration.ofSeconds(500)) .build()); // Every field is settable and lands: the returned options reflect each new value. @@ -381,6 +382,7 @@ public void updateOptionsAllFields() { 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(); @@ -391,6 +393,8 @@ public void updateOptionsAllFields() { assertEquals(Duration.ofSeconds(25), desc.getHeartbeatTimeout()); assertEquals(7, desc.getRetryOptions().getMaximumAttempts()); assertEquals(3, desc.getPriority().getPriorityKey()); + // start_delay isn't surfaced by ActivityExecutionDescription today; read via raw info. + assertEquals(500, desc.getRawInfo().getStartDelay().getSeconds()); handle.terminate("cleanup"); } 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 7014932949..e2a90b3994 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 @@ -53,7 +53,8 @@ public void unobservableRequestFields() { .setJitter(Duration.ofSeconds(5)) .build()); handle.reset(ResetActivityOptions.newBuilder().setJitter(Duration.ofSeconds(2)).build()); - handle.updateOptions(UpdateActivityOptions.newBuilder().setRestoreOriginal(true).build()); + handle.updateOptions( + UpdateActivityOptions.newBuilder().setStartDelay(Duration.ofSeconds(7)).build()); // pause carries the reason and an auto-generated dedup request_id; neither is returned by // describe. @@ -74,8 +75,15 @@ public void unobservableRequestFields() { assertEquals(0, resetReq.getJitter().getNanos()); assertTrue("reset request_id should be set", !resetReq.getRequestId().isEmpty()); - // updateOptions carries an auto-generated dedup request_id (api#844). + // updateOptions carries start_delay in activity_options with a matching update_mask path, plus + // an auto-generated dedup request_id (api#844). start_delay is applied server-side but not + // otherwise observable from the request. 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")); assertTrue("updateOptions request_id should be set", !updateReq.getRequestId().isEmpty()); } From b7d94ece76ba9e5a97875393378cae46eff40337 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 31 Jul 2026 16:12:15 -0400 Subject: [PATCH 13/53] upstream update --- .../temporal/client/ActivityExecutionDescription.java | 11 +++++++++++ .../StandaloneActivityOperatorCommandsTest.java | 6 ++++++ 2 files changed, 17 insertions(+) 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..a018887ff5 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -126,6 +126,17 @@ public Instant 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 info.hasExecutionTime() + ? ProtobufTimeUtils.toJavaInstant(info.getExecutionTime()) + : null; + } + /** Failure details from the last failed attempt. {@code null} if no failure has occurred. */ @Nullable public Exception getLastFailure() { 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 index 4ad82dc641..f7f66fc3d4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -395,6 +395,12 @@ public void updateOptionsAllFields() { assertEquals(3, desc.getPriority().getPriorityKey()); // start_delay isn't surfaced by ActivityExecutionDescription today; read via raw info. assertEquals(500, desc.getRawInfo().getStartDelay().getSeconds()); + // execution_time (api#807 + temporal#11017): reflects the updated start_delay. Server + // recomputes it on UpdateActivityOptions, so it lands at schedule_time + 500s (the new value), + // not schedule_time + 300s (the value at start). + assertEquals( + desc.getScheduledTime().plus(Duration.ofSeconds(500)).getEpochSecond(), + desc.getExecutionTime().getEpochSecond()); handle.terminate("cleanup"); } From a4e6483c3796d1c8939e4887483ad9567c5d0e74 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 11 Aug 2026 14:41:00 -0400 Subject: [PATCH 14/53] upstream update --- .../temporal/client/ResetActivityOptions.java | 22 ++++- .../client/UnpauseActivityOptions.java | 46 +-------- .../ActivityClientCallsInterceptor.java | 28 ++---- .../internal/client/ActivityHandleImpl.java | 10 +- .../client/RootActivityClientInvoker.java | 7 +- ...tandaloneActivityOperatorCommandsTest.java | 95 ++++--------------- .../ActivityHandleOperatorCommandsTest.java | 10 +- temporal-serviceclient/src/main/proto | 2 +- 8 files changed, 65 insertions(+), 155 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java index 2380a8887a..d959b2ce5e 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ResetActivityOptions.java @@ -11,7 +11,8 @@ *

All fields are optional. An instance with no fields set resets the activity with default * behavior. * - *

Reset always clears recorded heartbeat details. + *

Reset does not clear recorded heartbeat details by default; set {@link + * Builder#setResetHeartbeat(boolean)} to additionally discard them. */ @Experimental public final class ResetActivityOptions { @@ -35,6 +36,7 @@ public static final class Builder { private boolean keepPaused; private @Nullable Duration jitter; private boolean restoreOriginalOptions; + private boolean resetHeartbeat; private Builder() {} @@ -45,6 +47,7 @@ private Builder(ResetActivityOptions options) { this.keepPaused = options.keepPaused; this.jitter = options.jitter; this.restoreOriginalOptions = options.restoreOriginalOptions; + this.resetHeartbeat = options.resetHeartbeat; } /** If set and the activity is paused, it will remain paused after the reset. */ @@ -73,6 +76,12 @@ public Builder setRestoreOriginalOptions(boolean restoreOriginalOptions) { return this; } + /** If set, reset additionally discards any persisted heartbeat details. */ + public Builder setResetHeartbeat(boolean resetHeartbeat) { + this.resetHeartbeat = resetHeartbeat; + return this; + } + public ResetActivityOptions build() { return new ResetActivityOptions(this); } @@ -81,11 +90,13 @@ public ResetActivityOptions build() { private final boolean keepPaused; private final @Nullable Duration jitter; private final boolean restoreOriginalOptions; + private final boolean resetHeartbeat; private ResetActivityOptions(Builder builder) { this.keepPaused = builder.keepPaused; this.jitter = builder.jitter; this.restoreOriginalOptions = builder.restoreOriginalOptions; + this.resetHeartbeat = builder.resetHeartbeat; } public Builder toBuilder() { @@ -105,6 +116,10 @@ public boolean isRestoreOriginalOptions() { return restoreOriginalOptions; } + public boolean isResetHeartbeat() { + return resetHeartbeat; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -112,12 +127,13 @@ public boolean equals(Object o) { ResetActivityOptions that = (ResetActivityOptions) o; return keepPaused == that.keepPaused && restoreOriginalOptions == that.restoreOriginalOptions + && resetHeartbeat == that.resetHeartbeat && Objects.equals(jitter, that.jitter); } @Override public int hashCode() { - return Objects.hash(keepPaused, jitter, restoreOriginalOptions); + return Objects.hash(keepPaused, jitter, restoreOriginalOptions, resetHeartbeat); } @Override @@ -129,6 +145,8 @@ public String toString() { + jitter + ", restoreOriginalOptions=" + restoreOriginalOptions + + ", resetHeartbeat=" + + resetHeartbeat + '}'; } } 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 0c26be3346..c60da6a698 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UnpauseActivityOptions.java @@ -31,8 +31,6 @@ public static UnpauseActivityOptions getDefaultInstance() { public static final class Builder { private @Nullable String reason; - private boolean resetAttempts; - private boolean resetHeartbeat; private @Nullable Duration jitter; private Builder() {} @@ -42,8 +40,6 @@ private Builder(UnpauseActivityOptions options) { return; } this.reason = options.reason; - this.resetAttempts = options.resetAttempts; - this.resetHeartbeat = options.resetHeartbeat; this.jitter = options.jitter; } @@ -53,18 +49,6 @@ public Builder setReason(@Nullable String reason) { return this; } - /** If set, also resets the activity's attempt counter back to 1. */ - public Builder setResetAttempts(boolean resetAttempts) { - this.resetAttempts = resetAttempts; - return this; - } - - /** If set, also clears the activity's recorded heartbeat details. */ - public Builder setResetHeartbeat(boolean resetHeartbeat) { - this.resetHeartbeat = resetHeartbeat; - 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; @@ -77,14 +61,10 @@ public UnpauseActivityOptions build() { } private final @Nullable String reason; - private final boolean resetAttempts; - private final boolean resetHeartbeat; private final @Nullable Duration jitter; private UnpauseActivityOptions(Builder builder) { this.reason = builder.reason; - this.resetAttempts = builder.resetAttempts; - this.resetHeartbeat = builder.resetHeartbeat; this.jitter = builder.jitter; } @@ -97,14 +77,6 @@ public String getReason() { return reason; } - public boolean isResetAttempts() { - return resetAttempts; - } - - public boolean isResetHeartbeat() { - return resetHeartbeat; - } - @Nullable public Duration getJitter() { return jitter; @@ -115,28 +87,16 @@ public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UnpauseActivityOptions that = (UnpauseActivityOptions) o; - return resetAttempts == that.resetAttempts - && resetHeartbeat == that.resetHeartbeat - && Objects.equals(reason, that.reason) - && Objects.equals(jitter, that.jitter); + return Objects.equals(reason, that.reason) && Objects.equals(jitter, that.jitter); } @Override public int hashCode() { - return Objects.hash(reason, resetAttempts, resetHeartbeat, jitter); + return Objects.hash(reason, jitter); } @Override public String toString() { - return "UnpauseActivityOptions{" - + "reason='" - + reason - + "', resetAttempts=" - + resetAttempts - + ", resetHeartbeat=" - + resetHeartbeat - + ", jitter=" - + jitter - + '}'; + return "UnpauseActivityOptions{" + "reason='" + reason + "', jitter=" + jitter + '}'; } } 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 bf8ecb64a2..19aa7fc060 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 @@ -413,22 +413,13 @@ final class UnpauseActivityInput { private final String id; private final @Nullable String runId; private final @Nullable String reason; - private final boolean resetAttempts; - private final boolean resetHeartbeat; private final @Nullable Duration jitter; public UnpauseActivityInput( - String id, - @Nullable String runId, - @Nullable String reason, - boolean resetAttempts, - boolean resetHeartbeat, - @Nullable Duration jitter) { + String id, @Nullable String runId, @Nullable String reason, @Nullable Duration jitter) { this.id = id; this.runId = runId; this.reason = reason; - this.resetAttempts = resetAttempts; - this.resetHeartbeat = resetHeartbeat; this.jitter = jitter; } @@ -446,14 +437,6 @@ public String getReason() { return reason; } - public boolean isResetAttempts() { - return resetAttempts; - } - - public boolean isResetHeartbeat() { - return resetHeartbeat; - } - @Nullable public Duration getJitter() { return jitter; @@ -470,18 +453,21 @@ final class ResetActivityInput { private final boolean keepPaused; private final @Nullable Duration jitter; private final boolean restoreOriginalOptions; + private final boolean resetHeartbeat; public ResetActivityInput( String id, @Nullable String runId, boolean keepPaused, @Nullable Duration jitter, - boolean restoreOriginalOptions) { + boolean restoreOriginalOptions, + boolean resetHeartbeat) { this.id = id; this.runId = runId; this.keepPaused = keepPaused; this.jitter = jitter; this.restoreOriginalOptions = restoreOriginalOptions; + this.resetHeartbeat = resetHeartbeat; } public String getId() { @@ -505,6 +491,10 @@ public Duration getJitter() { public boolean isRestoreOriginalOptions() { return restoreOriginalOptions; } + + public boolean isResetHeartbeat() { + return resetHeartbeat; + } } @Experimental 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 bc94cf70bb..74a12e0b78 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 @@ -165,12 +165,7 @@ public void unpause() { public void unpause(UnpauseActivityOptions options) { clientCallsInterceptor.unpauseActivity( new ActivityClientCallsInterceptor.UnpauseActivityInput( - activityId, - activityRunId, - options.getReason(), - options.isResetAttempts(), - options.isResetHeartbeat(), - options.getJitter())); + activityId, activityRunId, options.getReason(), options.getJitter())); } @Override @@ -186,7 +181,8 @@ public void reset(ResetActivityOptions options) { activityRunId, options.isKeepPaused(), options.getJitter(), - options.isRestoreOriginalOptions())); + options.isRestoreOriginalOptions(), + options.isResetHeartbeat())); } @Override 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 10029a4f68..e0c2ae137d 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 @@ -363,9 +363,7 @@ public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { .setNamespace(clientOptions.getNamespace()) .setIdentity(clientOptions.getIdentity()) .setActivityId(input.getId()) - .setRequestId(UUID.randomUUID().toString()) - .setResetAttempts(input.isResetAttempts()) - .setResetHeartbeat(input.isResetHeartbeat()); + .setRequestId(UUID.randomUUID().toString()); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } @@ -388,7 +386,8 @@ public ResetActivityOutput resetActivity(ResetActivityInput input) { .setActivityId(input.getId()) .setRequestId(UUID.randomUUID().toString()) .setKeepPaused(input.isKeepPaused()) - .setRestoreOriginalOptions(input.isRestoreOriginalOptions()); + .setRestoreOriginalOptions(input.isRestoreOriginalOptions()) + .setResetHeartbeat(input.isResetHeartbeat()); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } 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 index f7f66fc3d4..c145f5ecc4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -17,7 +17,6 @@ import io.temporal.client.ActivityHandle; import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; -import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UpdateActivityOptions; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; @@ -103,22 +102,6 @@ public String run() { } } - /** Always fails (every attempt) so the attempt counter keeps climbing while it retries. */ - @ActivityInterface - public interface AlwaysFailActivity { - @ActivityMethod(name = "AlwaysFail") - String run(); - } - - public static class AlwaysFailActivityImpl implements AlwaysFailActivity { - @Override - public String run() { - throw ApplicationFailure.newFailure( - "always fails on attempt " + Activity.getExecutionContext().getInfo().getAttempt(), - "retry-type"); - } - } - /** * 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 @@ -161,7 +144,6 @@ public void run() { new SlowActivityImpl(), new QuickActivityImpl(), new FailThenSucceedActivityImpl(), - new AlwaysFailActivityImpl(), new HeartbeatOnceActivityImpl()) .build(); @@ -444,45 +426,6 @@ public void updateOptionsRestoreOriginal() { handle.terminate("cleanup"); } - // Overrides the rule's default 10s global timeout: driving retries + unpause takes longer. - @Test(timeout = 60_000) - public void unpauseResetsAttempts() { - assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityClient client = newActivityClient(); - StartActivityOptions opts = - StartActivityOptions.newBuilder() - .setId(uniqueId()) - .setTaskQueue(testWorkflowRule.getTaskQueue()) - .setStartToCloseTimeout(Duration.ofSeconds(60)) - .setRetryOptions( - RetryOptions.newBuilder() - .setInitialInterval(Duration.ofMillis(200)) - .setBackoffCoefficient(1.0) - .setMaximumInterval(Duration.ofMillis(200)) - .setMaximumAttempts(50) - .build()) - .build(); - ActivityHandle handle = - client.start(AlwaysFailActivity.class, AlwaysFailActivity::run, opts); - - // Wait until the activity has retried past its first attempt. - assertEventually( - Duration.ofSeconds(30), - () -> - assertTrue("expected attempt > 1 before unpause", handle.describe().getAttempt() > 1)); - - handle.pause("hold"); - assertEventuallyPaused(handle); - - handle.unpause(UnpauseActivityOptions.newBuilder().setResetAttempts(true).build()); - - // reset_attempts rewinds the attempt counter back to 1. - assertEventually( - Duration.ofSeconds(30), - () -> assertEquals("attempt should be reset to 1", 1, handle.describe().getAttempt())); - handle.terminate("cleanup"); - } - @Test(timeout = 60_000) public void resetKeepsPaused() { assumeTrue(SDKTestWorkflowRule.useExternalService); @@ -561,64 +504,62 @@ public void pausePreservesHeartbeat() { } @Test(timeout = 60_000) - public void unpausePreservesHeartbeatByDefault() { + public void unpausePreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); handle.pause("hold"); assertEventuallyPaused(handle); - // Default unpause (no reset_heartbeat flag) preserves details. The re-dispatched attempt - // doesn't heartbeat (only attempt 1 does), so the persisted details are stable and observable. + // 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 default unpause", + "heartbeat details should be preserved after unpause", handle.describe().hasHeartbeatDetails())); handle.terminate("cleanup"); } @Test(timeout = 60_000) - public void unpauseResetsHeartbeat() { + public void resetPreservesHeartbeatByDefault() throws InterruptedException { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); handle.pause("hold"); assertEventuallyPaused(handle); - // Opt-in flag clears details. The re-dispatched attempt doesn't heartbeat, so cleared stays - // cleared and is observable. - handle.unpause(UnpauseActivityOptions.newBuilder().setResetHeartbeat(true).build()); - - assertEventually( - Duration.ofSeconds(30), - () -> - assertFalse( - "heartbeat details should be cleared after unpause(reset_heartbeat)", - handle.describe().hasHeartbeatDetails())); + // As of api#848 / temporal#11417, reset does NOT clear heartbeat details by default — + // you must pass resetHeartbeat=true. keep_paused so no new attempt reshapes state. + handle.reset(ResetActivityOptions.newBuilder().setKeepPaused(true).build()); + // Give the server time to persist any state change, then confirm details survive. + Thread.sleep(2000); + assertTrue( + "heartbeat details should be preserved after default reset", + handle.describe().hasHeartbeatDetails()); handle.terminate("cleanup"); } @Test(timeout = 60_000) - public void resetClearsHeartbeatByDefault() { + public void resetClearsHeartbeatWhenFlagSet() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); handle.pause("hold"); assertEventuallyPaused(handle); - // reset always clears heartbeat details (there is no opt-in flag as of api#820); - // keep_paused so no new attempt runs to re-record them. - handle.reset(ResetActivityOptions.newBuilder().setKeepPaused(true).build()); + // Opt-in flag clears details. + handle.reset( + ResetActivityOptions.newBuilder().setKeepPaused(true).setResetHeartbeat(true).build()); assertEventually( Duration.ofSeconds(30), () -> assertFalse( - "heartbeat details should be cleared after reset(keep_paused)", + "heartbeat details should be cleared after reset(reset_heartbeat)", handle.describe().hasHeartbeatDetails())); handle.terminate("cleanup"); } 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 e2a90b3994..af4052b166 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 @@ -52,7 +52,11 @@ public void unobservableRequestFields() { .setReason("go") .setJitter(Duration.ofSeconds(5)) .build()); - handle.reset(ResetActivityOptions.newBuilder().setJitter(Duration.ofSeconds(2)).build()); + handle.reset( + ResetActivityOptions.newBuilder() + .setJitter(Duration.ofSeconds(2)) + .setResetHeartbeat(true) + .build()); handle.updateOptions( UpdateActivityOptions.newBuilder().setStartDelay(Duration.ofSeconds(7)).build()); @@ -69,11 +73,13 @@ public void unobservableRequestFields() { assertEquals(0, unpauseReq.getJitter().getNanos()); assertTrue("unpause request_id should be set", !unpauseReq.getRequestId().isEmpty()); - // reset carries jitter and an auto-generated dedup request_id (api#844). + // reset carries jitter, an auto-generated dedup request_id (api#844), and reset_heartbeat + // (api#848). ResetActivityExecutionRequest resetReq = captureReset(); assertEquals(2, resetReq.getJitter().getSeconds()); assertEquals(0, resetReq.getJitter().getNanos()); assertTrue("reset request_id should be set", !resetReq.getRequestId().isEmpty()); + assertTrue("reset should carry reset_heartbeat=true", resetReq.getResetHeartbeat()); // updateOptions carries start_delay in activity_options with a matching update_mask path, plus // an auto-generated dedup request_id (api#844). start_delay is applied server-side but not diff --git a/temporal-serviceclient/src/main/proto b/temporal-serviceclient/src/main/proto index 5304b54b93..3ebdff42a9 160000 --- a/temporal-serviceclient/src/main/proto +++ b/temporal-serviceclient/src/main/proto @@ -1 +1 @@ -Subproject commit 5304b54b931f584c0c2d9a710256472ecc4fbf2a +Subproject commit 3ebdff42a9f07ac484b415fe8ff0b483b4ce3340 From fd866af48c22fd34c48172edf41058228f44d4dc Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 13 Aug 2026 11:04:43 -0400 Subject: [PATCH 15/53] Use CancellationToken --- .../functional/StandaloneActivityOperatorCommandsTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index c145f5ecc4..1373577364 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -5,11 +5,11 @@ import static org.junit.Assume.assumeTrue; import io.temporal.activity.Activity; -import io.temporal.activity.ActivityCancellationToken; import io.temporal.activity.ActivityExecutionContext; import io.temporal.activity.ActivityInterface; import io.temporal.activity.ActivityMethod; 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; @@ -18,6 +18,7 @@ import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.client.UpdateActivityOptions; +import io.temporal.common.CancellationToken; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; @@ -121,7 +122,7 @@ public void run() { if (ctx.getInfo().getAttempt() == 1) { ctx.heartbeat("hb-details"); } - ActivityCancellationToken token = ctx.getCancellationToken(); + CancellationToken token = ctx.getCancellationToken(); while (!token.isCancellationRequested()) { try { Thread.sleep(100); From c5ddde158c82fa95c7f92e12d3f6f90c04bb69be Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 14 Aug 2026 14:49:43 -0400 Subject: [PATCH 16/53] test: update options requires at least one option --- .../StandaloneActivityOperatorCommandsTest.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 index 1373577364..c188069524 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -406,6 +406,19 @@ public void updateOptionsRestoreOriginalExclusive() { handle.terminate("cleanup"); } + @Test + public void updateOptionsRequiresAtLeastOneOption() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startRunningSlowActivity(slowOpts()); + // Building the request with no options and no restore_original is rejected before any RPC. + IllegalArgumentException err = + assertThrows( + IllegalArgumentException.class, + () -> handle.updateOptions(UpdateActivityOptions.newBuilder().build())); + assertTrue(err.getMessage().toLowerCase().contains("at least one option")); + handle.terminate("cleanup"); + } + @Test public void updateOptionsRestoreOriginal() { assumeTrue(SDKTestWorkflowRule.useExternalService); From 67677cd84e1d7f94f7a2f604956b5960aeec691b Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 14 Aug 2026 15:35:23 -0400 Subject: [PATCH 17/53] test_update_options_on_paused_activity --- ...tandaloneActivityOperatorCommandsTest.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) 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 index c188069524..8249c831dd 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -8,6 +8,7 @@ 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; @@ -440,6 +441,50 @@ public void updateOptionsRestoreOriginal() { 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("hold"); + 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( + UpdateActivityOptions.newBuilder() + .setStartToCloseTimeout(Duration.ofSeconds(90)) + .build()); + 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 resetKeepsPaused() { assumeTrue(SDKTestWorkflowRule.useExternalService); From d2eccb2029b71dcc02a9c265f69b04d1c97c00f5 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Mon, 17 Aug 2026 13:34:18 -0400 Subject: [PATCH 18/53] Round out implementation of four payload details fields, default false. --- .../client/ActivityExecutionDescription.java | 24 ++++++++++- .../client/ActivityExecutionMetadata.java | 18 ++++----- .../temporal/client/ActivityHandleImpl.java | 5 +++ .../client/UntypedActivityHandle.java | 12 +++++- .../ActivityClientCallsInterceptor.java | 10 ++++- .../internal/client/ActivityHandleImpl.java | 9 ++++- .../client/RootActivityClientInvoker.java | 6 ++- .../ActivityExecutionDescriptionTest.java | 2 +- ...tandaloneActivityOperatorCommandsTest.java | 40 ++++++++++++++----- .../functional/StandaloneActivityTest.java | 12 +++--- ...ctivityClientCallsInterceptorBaseTest.java | 4 +- 11 files changed, 111 insertions(+), 31 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 a018887ff5..6dac58164d 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -137,6 +137,24 @@ public Instant 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 info.hasStartDelay() ? ProtobufTimeUtils.toJavaDuration(info.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 info.hasLastFailure(); + } + /** Failure details from the last failed attempt. {@code null} if no failure has occurred. */ @Nullable public Exception getLastFailure() { @@ -197,7 +215,11 @@ public Duration 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(); } 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..1cfa3e8977 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java @@ -25,7 +25,7 @@ public class ActivityExecutionMetadata { private final String activityType; private final @Nullable Instant closeTime; private final @Nullable Duration executionDuration; - private final Instant scheduledTime; + private final Instant scheduleTime; private final ActivityExecutionStatus status; private final String taskQueue; private final SearchAttributes searchAttributes; @@ -37,7 +37,7 @@ public class ActivityExecutionMetadata { String activityType, @Nullable Instant closeTime, @Nullable Duration executionDuration, - Instant scheduledTime, + Instant scheduleTime, ActivityExecutionStatus status, String taskQueue, SearchAttributes searchAttributes) { @@ -47,7 +47,7 @@ public class ActivityExecutionMetadata { this.activityType = activityType; this.closeTime = closeTime; this.executionDuration = executionDuration; - this.scheduledTime = scheduledTime; + this.scheduleTime = scheduleTime; this.status = status; this.taskQueue = taskQueue; this.searchAttributes = searchAttributes; @@ -120,8 +120,8 @@ public Duration getExecutionDuration() { /** Time when the activity was originally scheduled. */ @Nonnull - public Instant getScheduledTime() { - return scheduledTime; + public Instant getScheduleTime() { + return scheduleTime; } /** General status of the activity execution. */ @@ -152,7 +152,7 @@ public boolean equals(Object o) { && Objects.equals(activityType, that.activityType) && Objects.equals(closeTime, that.closeTime) && Objects.equals(executionDuration, that.executionDuration) - && Objects.equals(scheduledTime, that.scheduledTime) + && Objects.equals(scheduleTime, that.scheduleTime) && status == that.status && Objects.equals(taskQueue, that.taskQueue) && Objects.equals(searchAttributes, that.searchAttributes); @@ -166,7 +166,7 @@ public int hashCode() { activityType, closeTime, executionDuration, - scheduledTime, + scheduleTime, status, taskQueue, searchAttributes); @@ -183,8 +183,8 @@ public String toString() { + activityType + "', status=" + status - + ", scheduledTime=" - + scheduledTime + + ", scheduleTime=" + + scheduleTime + ", closeTime=" + closeTime + ", executionDuration=" 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 bd127935da..55e2bae179 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(); 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 5e49ec0f91..3122c4f79b 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)}. 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 19aa7fc060..aca1fba657 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 @@ -7,6 +7,7 @@ import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.ActivityExecutionMetadata; import io.temporal.client.ActivityFailedException; +import io.temporal.client.DescribeActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.common.Experimental; import java.lang.reflect.Type; @@ -289,10 +290,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() { @@ -303,6 +307,10 @@ public String getId() { public String getRunId() { return runId; } + + public DescribeActivityOptions getOptions() { + return options; + } } @Experimental 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 74a12e0b78..fbdcc89ee5 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 @@ -7,6 +7,7 @@ import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.ActivityExecutionOptions; +import io.temporal.client.DescribeActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; @@ -116,9 +117,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(); } 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 2c31a890a5..1c7490c506 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 @@ -340,8 +340,10 @@ public DescribeActivityOutput describeActivity(DescribeActivityInput input) { DescribeActivityExecutionRequest.newBuilder() .setNamespace(clientOptions.getNamespace()) .setActivityId(input.getId()) - .setIncludeHeartbeatDetails(true) - .setIncludeLastFailure(true); + .setIncludeInput(input.getOptions().isIncludeInput()) + .setIncludeOutcome(input.getOptions().isIncludeOutcome()) + .setIncludeHeartbeatDetails(input.getOptions().isIncludeHeartbeatDetails()) + .setIncludeLastFailure(input.getOptions().isIncludeLastFailure()); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } 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..a9214b5b51 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java @@ -46,7 +46,7 @@ public void testNullRunIdWhenEmpty() { public void testScheduledTime() { ActivityExecutionDescription desc = new ActivityExecutionDescription(buildInfo("act-id", ""), CONVERTER, "test-ns"); - assertEquals(Instant.ofEpochMilli(1000), desc.getScheduledTime()); + assertEquals(Instant.ofEpochMilli(1000), desc.getScheduleTime()); } @Test 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 index 8249c831dd..71a936e69b 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -16,6 +16,7 @@ import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.ActivityExecutionOptions; import io.temporal.client.ActivityHandle; +import io.temporal.client.DescribeActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.client.UpdateActivityOptions; @@ -46,6 +47,10 @@ */ 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 // --------------------------------------------------------------------------- @@ -213,7 +218,7 @@ private ActivityHandle startHeartbeatReadyActivity() { () -> assertTrue( "expected heartbeat details to be recorded", - handle.describe().hasHeartbeatDetails())); + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails())); return handle; } @@ -377,13 +382,12 @@ public void updateOptionsAllFields() { assertEquals(Duration.ofSeconds(25), desc.getHeartbeatTimeout()); assertEquals(7, desc.getRetryOptions().getMaximumAttempts()); assertEquals(3, desc.getPriority().getPriorityKey()); - // start_delay isn't surfaced by ActivityExecutionDescription today; read via raw info. - assertEquals(500, desc.getRawInfo().getStartDelay().getSeconds()); + assertEquals(Duration.ofSeconds(500), desc.getStartDelay()); // execution_time (api#807 + temporal#11017): reflects the updated start_delay. Server // recomputes it on UpdateActivityOptions, so it lands at schedule_time + 500s (the new value), // not schedule_time + 300s (the value at start). assertEquals( - desc.getScheduledTime().plus(Duration.ofSeconds(500)).getEpochSecond(), + desc.getScheduleTime().plus(Duration.ofSeconds(500)).getEpochSecond(), desc.getExecutionTime().getEpochSecond()); handle.terminate("cleanup"); @@ -547,6 +551,24 @@ public void resetRestoresOriginalOptions() { handle.terminate("cleanup"); } + /** + * The payload-bearing describe fields are opt-in (api#792). Assert the default really is "off" + * rather than the SDK quietly requesting everything: same activity, same moment, two describes. + */ + @Test(timeout = 60_000) + public void describePayloadFieldsAreOptIn() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + ActivityHandle handle = startHeartbeatReadyActivity(); + + assertFalse(handle.describe().hasHeartbeatDetails()); + assertFalse(handle.describe().getHeartbeatDetails(String.class).isPresent()); + assertTrue(handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); + assertEquals( + "hb-details", + handle.describe(WITH_HEARTBEAT_DETAILS).getHeartbeatDetails(String.class).orElse(null)); + handle.terminate("cleanup"); + } + @Test(timeout = 60_000) public void pausePreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); @@ -558,7 +580,7 @@ public void pausePreservesHeartbeat() { // Pause never touches heartbeat details — they persist across the transition. assertTrue( "heartbeat details should be preserved across pause", - handle.describe().hasHeartbeatDetails()); + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); handle.terminate("cleanup"); } @@ -579,7 +601,7 @@ public void unpausePreservesHeartbeat() { () -> assertTrue( "heartbeat details should be preserved after unpause", - handle.describe().hasHeartbeatDetails())); + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails())); handle.terminate("cleanup"); } @@ -598,7 +620,7 @@ public void resetPreservesHeartbeatByDefault() throws InterruptedException { Thread.sleep(2000); assertTrue( "heartbeat details should be preserved after default reset", - handle.describe().hasHeartbeatDetails()); + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); handle.terminate("cleanup"); } @@ -619,7 +641,7 @@ public void resetClearsHeartbeatWhenFlagSet() { () -> assertFalse( "heartbeat details should be cleared after reset(reset_heartbeat)", - handle.describe().hasHeartbeatDetails())); + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails())); handle.terminate("cleanup"); } @@ -637,7 +659,7 @@ public void updateOptionsPreservesHeartbeat() { assertTrue( "heartbeat details should be preserved after updateOptions", - handle.describe().hasHeartbeatDetails()); + handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); handle.terminate("cleanup"); } 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..77d3b044bd 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 @@ -383,7 +383,7 @@ public void testDescribeRunningAndTerminatedIsAccurate() { assertEquals(activityId, desc.getActivityId()); assertEquals("WaitForCancel", desc.getActivityType()); assertEquals(testWorkflowRule.getTaskQueue(), desc.getTaskQueue()); - assertNotNull(desc.getScheduledTime()); + assertNotNull(desc.getScheduleTime()); assertEquals(1, desc.getAttempt()); assertNotNull(desc.getScheduleToCloseTimeout()); assertNotNull(desc.getStartToCloseTimeout()); @@ -854,7 +854,9 @@ public void testDescribeLastFailureIsPopulatedDuringRetryBackoff() { assertEventually( Duration.ofSeconds(60), () -> { - ActivityExecutionDescription desc = handle.describe(); + ActivityExecutionDescription desc = + 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)); @@ -1027,9 +1029,9 @@ public void testStartDelayDelaysFirstDispatch() { assertEquals("echo:hello", handle.getResult()); ActivityExecutionDescription desc = handle.describe(); - Duration between = Duration.between(desc.getScheduledTime(), desc.getLastStartedTime()); + Duration between = Duration.between(desc.getScheduleTime(), desc.getLastStartedTime()); assertTrue( - "lastStartedTime - scheduledTime should be >= startDelay - 500ms, was " + between, + "lastStartedTime - scheduleTime should be >= startDelay - 500ms, was " + between, between.compareTo(delay.minusMillis(500)) >= 0); } @@ -1159,7 +1161,7 @@ public void testZeroStartDelayBehavesAsUnset() { assertEquals("echo:x", handle.getResult()); ActivityExecutionDescription desc = handle.describe(); - Duration between = Duration.between(desc.getScheduledTime(), desc.getLastStartedTime()); + Duration between = Duration.between(desc.getScheduleTime(), desc.getLastStartedTime()); assertTrue( "Duration.ZERO should not introduce dispatch latency, was " + between, between.compareTo(Duration.ofSeconds(1)) < 0); 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); From ea3efacdd604f4f3da39f12a75880651f28bf46d Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Mon, 17 Aug 2026 16:15:38 -0400 Subject: [PATCH 19/53] getInput/Result --- .../client/ActivityExecutionDescription.java | 156 ++++++++++++++++-- .../client/RootActivityClientInvoker.java | 2 +- .../ActivityExecutionDescriptionTest.java | 128 ++++++++++++-- ...tandaloneActivityOperatorCommandsTest.java | 82 +++++++++ 4 files changed, 340 insertions(+), 28 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 6dac58164d..7b765a6863 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -3,6 +3,7 @@ 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; @@ -27,28 +28,32 @@ @Experimental public final class ActivityExecutionDescription extends ActivityExecutionMetadata { + private final DescribeActivityExecutionResponse response; private final ActivityExecutionInfo info; 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; + response.getInfo().getStatus(), + response.getInfo().getTaskQueue(), + SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes())); + this.response = response; + this.info = response.getInfo(); this.dataConverter = dataConverter; this.namespace = namespace; } @@ -57,6 +62,12 @@ public ActivityExecutionDescription( 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() { @@ -250,6 +261,125 @@ public Optional getHeartbeatDetails(Class valueType, Type genericType) 0, Optional.of(info.getHeartbeatDetails()), valueType, genericType)); } + /** + * 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(); + } + + /** + * The number of input arguments the activity was started with. {@code 0} if no input is present + * (the activity took no arguments, or {@code includeInput} was false). + */ + public int getInputCount() { + return response.hasInput() ? response.getInput().getPayloadsCount() : 0; + } + + /** + * Deserializes the activity's first input argument. Returns {@link Optional#empty()} if no input + * is present (the activity took no arguments, or {@code includeInput} was false). + * + *

For a multi-argument activity this returns only the first argument; use {@link + * #getInput(int, Class)} to read the rest, and {@link #getInputCount()} for how many there are. + * + * @param valueType the class to deserialize the input into + */ + public Optional getInput(Class valueType) { + return getInput(0, valueType, valueType); + } + + /** + * Deserializes the activity's first input argument 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) { + return getInput(0, valueType, genericType); + } + + /** + * Deserializes the activity's input argument at the given position. Returns {@link + * Optional#empty()} if no input is present or {@code index} is past the last argument. + * + * @param index zero-based position of the argument, in declaration order + * @param valueType the class to deserialize the argument into + */ + public Optional getInput(int index, Class valueType) { + return getInput(index, valueType, valueType); + } + + /** + * Deserializes the activity's input argument at the given position into the given generic type. + * Returns {@link Optional#empty()} if no input is present or {@code index} is past the last + * argument. + * + * @param index zero-based position of the argument, in declaration order + * @param valueType the class to deserialize the argument into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getInput(int index, Class valueType, Type genericType) { + if (index < 0 || index >= getInputCount()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayloads( + index, Optional.of(response.getInput()), valueType, genericType)); + } + + /** + * 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.hasOutcome() && 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 result into + */ + public Optional getResult(Class valueType) { + return getResult(valueType, valueType); + } + + /** + * 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 result into + * @param genericType the generic type for deserialization; may equal {@code valueType} + */ + public Optional getResult(Class valueType, Type genericType) { + if (!hasResult()) { + return Optional.empty(); + } + return Optional.ofNullable( + dataConverter.fromPayloads( + 0, Optional.of(response.getOutcome().getResult()), valueType, genericType)); + } + + /** + * 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 Exception getFailure() { + if (!response.hasOutcome() || !response.getOutcome().hasFailure()) { + return null; + } + return dataConverter.failureToException(response.getOutcome().getFailure()); + } + /** * The deployment version of the worker that last processed this activity. {@code null} if not * available. 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 1c7490c506..2e6ecfdd30 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 @@ -350,7 +350,7 @@ public DescribeActivityOutput describeActivity(DescribeActivityInput input) { DescribeActivityExecutionResponse response = genericClient.describeActivity(req.build()); return new DescribeActivityOutput( new ActivityExecutionDescription( - response.getInfo(), clientOptions.getDataConverter(), clientOptions.getNamespace())); + response, clientOptions.getDataConverter(), clientOptions.getNamespace())); } @Override 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 a9214b5b51..37f716bcb2 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java @@ -4,13 +4,16 @@ 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; @@ -35,24 +38,29 @@ 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.getScheduleTime()); } @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()); } @@ -62,8 +70,7 @@ 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); @@ -78,8 +85,7 @@ 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; @@ -97,8 +103,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 +111,109 @@ public void testGetWorkerDeploymentVersionPresent() { assertEquals("build-42", version.getBuildId()); } + @Test + public void testInputAbsentUnlessRequested() { + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); + assertFalse(desc.hasInput()); + assertFalse(desc.getInput(String.class).isPresent()); + } + + @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("hello-input", desc.getInput(String.class).orElse(null)); + } + + @Test + public void testGetInputByIndexDecodesEveryArgument() { + DescribeActivityExecutionResponse response = + DescribeActivityExecutionResponse.newBuilder() + .setInfo(buildInfo("id", "run")) + .setInput(CONVERTER.toPayloads("first", 42).get()) + .build(); + ActivityExecutionDescription desc = describe(response); + + assertEquals(2, desc.getInputCount()); + assertEquals("first", desc.getInput(0, String.class).orElse(null)); + assertEquals(Integer.valueOf(42), desc.getInput(1, Integer.class).orElse(null)); + // The no-index accessor still reads the first argument. + assertEquals("first", desc.getInput(String.class).orElse(null)); + // Out-of-range indexes are empty rather than throwing. + assertFalse(desc.getInput(2, String.class).isPresent()); + assertFalse(desc.getInput(-1, String.class).isPresent()); + } + + @Test + public void testInputCountZeroWhenInputAbsent() { + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); + assertEquals(0, desc.getInputCount()); + assertFalse(desc.getInput(0, String.class).isPresent()); + } + + @Test + public void testOutcomeAbsentUnlessRequested() { + ActivityExecutionDescription desc = describe(buildInfo("id", "run")); + assertFalse(desc.hasResult()); + assertFalse(desc.getResult(String.class).isPresent()); + assertNull(desc.getFailure()); + } + + @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.getFailure()); + } + + @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()); + + Exception failure = desc.getFailure(); + 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 index 71a936e69b..3cb521b6a4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -78,6 +78,20 @@ public void run() { } } + /** 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 { @@ -151,6 +165,7 @@ public void run() { new SlowActivityImpl(), new QuickActivityImpl(), new FailThenSucceedActivityImpl(), + new TwoArgActivityImpl(), new HeartbeatOnceActivityImpl()) .build(); @@ -569,6 +584,73 @@ public void describePayloadFieldsAreOptIn() { handle.terminate("cleanup"); } + /** + * Input and outcome are opt-in like the other payload fields. Uses a two-argument activity so + * {@link ActivityExecutionDescription#getInput(int, Class)} has more than one argument to read. + */ + @Test(timeout = 60_000) + public void describeReadsInputAndOutcome() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .build(); + ActivityHandle handle = + newActivityClient().start(TwoArgActivity.class, TwoArgActivity::run, opts, "ping", 7); + assertEquals("ping-7", handle.getResult(String.class)); + + // Default describe omits both. + ActivityExecutionDescription bare = handle.describe(); + assertFalse(bare.hasInput()); + assertEquals(0, bare.getInputCount()); + assertFalse(bare.hasResult()); + assertNull(bare.getFailure()); + + ActivityExecutionDescription desc = + handle.describe( + DescribeActivityOptions.newBuilder() + .setIncludeInput(true) + .setIncludeOutcome(true) + .build()); + assertTrue(desc.hasInput()); + assertEquals(2, desc.getInputCount()); + assertEquals("ping", desc.getInput(0, String.class).orElse(null)); + assertEquals(Integer.valueOf(7), desc.getInput(1, Integer.class).orElse(null)); + assertTrue(desc.hasResult()); + assertEquals("ping-7", desc.getResult(String.class).orElse(null)); + // A successful outcome has no failure arm. + assertNull(desc.getFailure()); + } + + /** The other arm of the outcome oneof: a terminally failed activity has a failure, no result. */ + @Test(timeout = 60_000) + public void describeReadsFailureOutcome() { + assumeTrue(SDKTestWorkflowRule.useExternalService); + StartActivityOptions opts = + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(60)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) + .build(); + ActivityHandle handle = + newActivityClient() + .start(FailThenSucceedActivity.class, FailThenSucceedActivity::run, opts); + assertThrows(Exception.class, () -> handle.getResult(String.class)); + + ActivityExecutionDescription desc = + handle.describe(DescribeActivityOptions.newBuilder().setIncludeOutcome(true).build()); + assertFalse(desc.hasResult()); + assertFalse(desc.getResult(String.class).isPresent()); + + Exception failure = desc.getFailure(); + assertNotNull(failure); + assertTrue(failure instanceof ApplicationFailure); + assertEquals("retryable failure", ((ApplicationFailure) failure).getOriginalMessage()); + } + @Test(timeout = 60_000) public void pausePreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); From a5e67d7b326990fea78fac7cf934a41b34ff455f Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Mon, 17 Aug 2026 21:04:23 -0400 Subject: [PATCH 20/53] Experimental --- .../common/interceptors/ActivityClientCallsInterceptorBase.java | 2 ++ 1 file changed, 2 insertions(+) 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 73b0899a0a..25256f7ce4 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; From 37d6d1a5c8b31c57d2c21a6830738cf7fbed491b Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Mon, 17 Aug 2026 21:18:17 -0400 Subject: [PATCH 21/53] Revert scheduleTime change --- .../client/ActivityExecutionMetadata.java | 18 +++++++++--------- .../ActivityExecutionDescriptionTest.java | 2 +- ...StandaloneActivityOperatorCommandsTest.java | 2 +- .../functional/StandaloneActivityTest.java | 8 ++++---- 4 files changed, 15 insertions(+), 15 deletions(-) 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 1cfa3e8977..b741fdc431 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionMetadata.java @@ -25,7 +25,7 @@ public class ActivityExecutionMetadata { private final String activityType; private final @Nullable Instant closeTime; private final @Nullable Duration executionDuration; - private final Instant scheduleTime; + private final Instant scheduledTime; private final ActivityExecutionStatus status; private final String taskQueue; private final SearchAttributes searchAttributes; @@ -37,7 +37,7 @@ public class ActivityExecutionMetadata { String activityType, @Nullable Instant closeTime, @Nullable Duration executionDuration, - Instant scheduleTime, + Instant scheduledTime, ActivityExecutionStatus status, String taskQueue, SearchAttributes searchAttributes) { @@ -47,7 +47,7 @@ public class ActivityExecutionMetadata { this.activityType = activityType; this.closeTime = closeTime; this.executionDuration = executionDuration; - this.scheduleTime = scheduleTime; + this.scheduledTime = scheduledTime; this.status = status; this.taskQueue = taskQueue; this.searchAttributes = searchAttributes; @@ -120,8 +120,8 @@ public Duration getExecutionDuration() { /** Time when the activity was originally scheduled. */ @Nonnull - public Instant getScheduleTime() { - return scheduleTime; + public Instant getScheduledTime() { + return scheduledTime; } /** General status of the activity execution. */ @@ -152,7 +152,7 @@ public boolean equals(Object o) { && Objects.equals(activityType, that.activityType) && Objects.equals(closeTime, that.closeTime) && Objects.equals(executionDuration, that.executionDuration) - && Objects.equals(scheduleTime, that.scheduleTime) + && Objects.equals(scheduledTime, that.scheduledTime) && status == that.status && Objects.equals(taskQueue, that.taskQueue) && Objects.equals(searchAttributes, that.searchAttributes); @@ -166,7 +166,7 @@ public int hashCode() { activityType, closeTime, executionDuration, - scheduleTime, + scheduledTime, status, taskQueue, searchAttributes); @@ -183,8 +183,8 @@ public String toString() { + activityType + "', status=" + status - + ", scheduleTime=" - + scheduleTime + + ", scheduledTime=" + + scheduledTime + ", closeTime=" + closeTime + ", executionDuration=" 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 37f716bcb2..8ef399cced 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java @@ -55,7 +55,7 @@ public void testNullRunIdWhenEmpty() { @Test public void testScheduledTime() { ActivityExecutionDescription desc = describe(buildInfo("act-id", "")); - assertEquals(Instant.ofEpochMilli(1000), desc.getScheduleTime()); + assertEquals(Instant.ofEpochMilli(1000), desc.getScheduledTime()); } @Test 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 index 3cb521b6a4..2a1be1cdac 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -402,7 +402,7 @@ public void updateOptionsAllFields() { // recomputes it on UpdateActivityOptions, so it lands at schedule_time + 500s (the new value), // not schedule_time + 300s (the value at start). assertEquals( - desc.getScheduleTime().plus(Duration.ofSeconds(500)).getEpochSecond(), + desc.getScheduledTime().plus(Duration.ofSeconds(500)).getEpochSecond(), desc.getExecutionTime().getEpochSecond()); handle.terminate("cleanup"); 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 77d3b044bd..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 @@ -383,7 +383,7 @@ public void testDescribeRunningAndTerminatedIsAccurate() { assertEquals(activityId, desc.getActivityId()); assertEquals("WaitForCancel", desc.getActivityType()); assertEquals(testWorkflowRule.getTaskQueue(), desc.getTaskQueue()); - assertNotNull(desc.getScheduleTime()); + assertNotNull(desc.getScheduledTime()); assertEquals(1, desc.getAttempt()); assertNotNull(desc.getScheduleToCloseTimeout()); assertNotNull(desc.getStartToCloseTimeout()); @@ -1029,9 +1029,9 @@ public void testStartDelayDelaysFirstDispatch() { assertEquals("echo:hello", handle.getResult()); ActivityExecutionDescription desc = handle.describe(); - Duration between = Duration.between(desc.getScheduleTime(), desc.getLastStartedTime()); + Duration between = Duration.between(desc.getScheduledTime(), desc.getLastStartedTime()); assertTrue( - "lastStartedTime - scheduleTime should be >= startDelay - 500ms, was " + between, + "lastStartedTime - scheduledTime should be >= startDelay - 500ms, was " + between, between.compareTo(delay.minusMillis(500)) >= 0); } @@ -1161,7 +1161,7 @@ public void testZeroStartDelayBehavesAsUnset() { assertEquals("echo:x", handle.getResult()); ActivityExecutionDescription desc = handle.describe(); - Duration between = Duration.between(desc.getScheduleTime(), desc.getLastStartedTime()); + Duration between = Duration.between(desc.getScheduledTime(), desc.getLastStartedTime()); assertTrue( "Duration.ZERO should not introduce dispatch latency, was " + between, between.compareTo(Duration.ofSeconds(1)) < 0); From 7c690fc3edf90f5847c7d255d39a99661cb81a76 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 18 Aug 2026 11:16:58 -0400 Subject: [PATCH 22/53] DescribeActivityOptions --- .../client/DescribeActivityOptions.java | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/DescribeActivityOptions.java 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 + + '}'; + } +} From 8ed02128ca87da2eaf6e0e0d7af06d0079eff3f7 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 19 Aug 2026 16:26:54 -0400 Subject: [PATCH 23/53] - ActivityExecutionDescription: drop the redundant `info` field and read `response.getInfo()` throughout. - ActivityExecutionDescription: attach ActivitySerializationContext to the data converter once in the constructor, instead of rebuilding it on every user-metadata read. - ActivityExecutionDescription: drop parent-presence guards that protobuf's null-coalescing getters make redundant. - ActivityExecutionDescription: getResult(Class) now passes a null generic type, matching ActivityClient.startActivity; the two-arg overload accepts null and normalizes it (previously it threw). - ActivityExecutionDescription: rename getFailure to getOutcomeFailure to distinguish the terminal outcome from getLastFailure; both now return RuntimeException. - ActivityExecutionDescription: getInput() and getHeartbeatDetails() return EncodedValues; getInputCount() and the typed overloads are gone. BREAKING: getHeartbeatDetails shipped in v1.35.0-v1.38.0. - ActivityClientCallsInterceptor: UnpauseActivityInput and ResetActivityInput carry the options object rather than exploded fields. - RootActivityClientInvoker: clear payload fields the caller did not request, so an older or buggy server cannot make the description's has* accessors disagree with what was asked for. - Delete ActivityExecutionOptions and return UpdateActivityOptions from updateOptions; the update request and response share one proto options type, so the field sets cannot diverge. - UpdateActivityOptionsOutput holds the final options object; the proto-to-options conversion moved into the root interceptor, so interceptors see the public type rather than the wire type. - Move restoreOriginal off UpdateActivityOptions into ActivityHandle.restoreOriginalOptions(), removing a builder state the server rejects outright. This also drops the "at least one option must be set" guard, which no longer holds now that the type serves as both request and response. --- .../client/ActivityExecutionDescription.java | 213 ++++++------------ .../client/ActivityExecutionOptions.java | 136 ----------- .../temporal/client/ActivityHandleImpl.java | 11 +- .../temporal/client/PauseActivityOptions.java | 86 +++++++ .../client/UntypedActivityHandle.java | 20 +- .../client/UpdateActivityOptions.java | 53 +---- .../ActivityClientCallsInterceptor.java | 80 ++----- .../internal/client/ActivityHandleImpl.java | 131 +++++------ .../client/RootActivityClientInvoker.java | 90 ++++++-- .../ActivityExecutionDescriptionTest.java | 43 ++-- ...tandaloneActivityOperatorCommandsTest.java | 82 ++----- .../ActivityHandleOperatorCommandsTest.java | 3 +- 12 files changed, 384 insertions(+), 564 deletions(-) delete mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/PauseActivityOptions.java 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 7b765a6863..cf40c0aca9 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -9,6 +9,7 @@ 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; @@ -29,9 +30,7 @@ public final class ActivityExecutionDescription extends ActivityExecutionMetadata { private final DescribeActivityExecutionResponse response; - private final ActivityExecutionInfo info; private final DataConverter dataConverter; - private final String namespace; public ActivityExecutionDescription( DescribeActivityExecutionResponse response, DataConverter dataConverter, String namespace) { @@ -53,9 +52,10 @@ public ActivityExecutionDescription( response.getInfo().getTaskQueue(), SearchAttributesUtil.decodeTyped(response.getInfo().getSearchAttributes())); this.response = response; - this.info = response.getInfo(); - this.dataConverter = dataConverter; - this.namespace = namespace; + this.dataConverter = + dataConverter.withContext( + new ActivitySerializationContext( + namespace, null, null, getActivityType(), getTaskQueue(), false)); } private static @Nullable String nullIfEmpty(String s) { @@ -71,12 +71,12 @@ public DescribeActivityExecutionResponse getRawResponse() { /** 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(); } /** @@ -85,55 +85,55 @@ 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; } @@ -143,8 +143,8 @@ public Instant getLastStartedTime() { */ @Nullable public Instant getExecutionTime() { - return info.hasExecutionTime() - ? ProtobufTimeUtils.toJavaInstant(info.getExecutionTime()) + return response.getInfo().hasExecutionTime() + ? ProtobufTimeUtils.toJavaInstant(response.getInfo().getExecutionTime()) : null; } @@ -154,7 +154,9 @@ public Instant getExecutionTime() { */ @Nullable public Duration getStartDelay() { - return info.hasStartDelay() ? ProtobufTimeUtils.toJavaDuration(info.getStartDelay()) : null; + return response.getInfo().hasStartDelay() + ? ProtobufTimeUtils.toJavaDuration(response.getInfo().getStartDelay()) + : null; } /** @@ -163,34 +165,38 @@ public Duration getStartDelay() { * DescribeActivityOptions.Builder#setIncludeLastFailure(boolean)}. */ public boolean hasLastFailure() { - return info.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; } /** @@ -199,30 +205,30 @@ 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; } @@ -232,33 +238,16 @@ public Duration getStartToCloseTimeout() { * DescribeActivityOptions.Builder#setIncludeHeartbeatDetails(boolean)}. */ public boolean hasHeartbeatDetails() { - return info.hasHeartbeatDetails(); + return response.getInfo().hasHeartbeatDetails(); } /** - * Deserializes the last heartbeat details into the given type. Returns {@link Optional#empty()} - * if no heartbeat details are present. - * - * @param valueType the class to deserialize the heartbeat details into - */ - public Optional getHeartbeatDetails(Class valueType) { - return getHeartbeatDetails(valueType, valueType); - } - - /** - * Deserializes the last heartbeat details into the given generic type. Returns {@link - * Optional#empty()} if no heartbeat details are present. - * - * @param valueType the class to deserialize the heartbeat details into - * @param genericType the generic type for deserialization; may equal {@code valueType} + * 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 Optional getHeartbeatDetails(Class valueType, Type genericType) { - if (!info.hasHeartbeatDetails()) { - return Optional.empty(); - } - return Optional.ofNullable( - dataConverter.fromPayloads( - 0, Optional.of(info.getHeartbeatDetails()), valueType, genericType)); + public EncodedValues getHeartbeatDetails() { + return new EncodedValues(Optional.of(response.getInfo().getHeartbeatDetails()), dataConverter); } /** @@ -270,64 +259,12 @@ public boolean hasInput() { } /** - * The number of input arguments the activity was started with. {@code 0} if no input is present - * (the activity took no arguments, or {@code includeInput} was false). - */ - public int getInputCount() { - return response.hasInput() ? response.getInput().getPayloadsCount() : 0; - } - - /** - * Deserializes the activity's first input argument. Returns {@link Optional#empty()} if no input - * is present (the activity took no arguments, or {@code includeInput} was false). - * - *

For a multi-argument activity this returns only the first argument; use {@link - * #getInput(int, Class)} to read the rest, and {@link #getInputCount()} for how many there are. - * - * @param valueType the class to deserialize the input into - */ - public Optional getInput(Class valueType) { - return getInput(0, valueType, valueType); - } - - /** - * Deserializes the activity's first input argument 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) { - return getInput(0, valueType, genericType); - } - - /** - * Deserializes the activity's input argument at the given position. Returns {@link - * Optional#empty()} if no input is present or {@code index} is past the last argument. - * - * @param index zero-based position of the argument, in declaration order - * @param valueType the class to deserialize the argument into - */ - public Optional getInput(int index, Class valueType) { - return getInput(index, valueType, valueType); - } - - /** - * Deserializes the activity's input argument at the given position into the given generic type. - * Returns {@link Optional#empty()} if no input is present or {@code index} is past the last - * argument. - * - * @param index zero-based position of the argument, in declaration order - * @param valueType the class to deserialize the argument into - * @param genericType the generic type for deserialization; may equal {@code valueType} + * 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 Optional getInput(int index, Class valueType, Type genericType) { - if (index < 0 || index >= getInputCount()) { - return Optional.empty(); - } - return Optional.ofNullable( - dataConverter.fromPayloads( - index, Optional.of(response.getInput()), valueType, genericType)); + public EncodedValues getInput() { + return new EncodedValues(Optional.of(response.getInput()), dataConverter); } /** @@ -336,7 +273,7 @@ public Optional getInput(int index, Class valueType, Type genericType) * DescribeActivityOptions.Builder#setIncludeOutcome(boolean)}. */ public boolean hasResult() { - return response.hasOutcome() && response.getOutcome().hasResult(); + return response.getOutcome().hasResult(); } /** @@ -346,7 +283,7 @@ public boolean hasResult() { * @param valueType the class to deserialize the result into */ public Optional getResult(Class valueType) { - return getResult(valueType, valueType); + return getResult(valueType, null); } /** @@ -356,13 +293,16 @@ public Optional getResult(Class valueType) { * @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) { + public Optional getResult(Class valueType, @Nullable Type genericType) { if (!hasResult()) { return Optional.empty(); } return Optional.ofNullable( dataConverter.fromPayloads( - 0, Optional.of(response.getOutcome().getResult()), valueType, genericType)); + 0, + Optional.of(response.getOutcome().getResult()), + valueType, + genericType != null ? genericType : valueType)); } /** @@ -373,8 +313,8 @@ public Optional getResult(Class valueType, Type genericType) { * attempt, which may be set while the activity is still retrying. */ @Nullable - public Exception getFailure() { - if (!response.hasOutcome() || !response.getOutcome().hasFailure()) { + public RuntimeException getOutcomeFailure() { + if (!response.getOutcome().hasFailure()) { return null; } return dataConverter.failureToException(response.getOutcome().getFailure()); @@ -386,20 +326,21 @@ public Exception getFailure() { */ @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()); } /** @@ -408,14 +349,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); } /** @@ -424,13 +362,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 deleted file mode 100644 index 67b3e1d3d5..0000000000 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java +++ /dev/null @@ -1,136 +0,0 @@ -package io.temporal.client; - -import io.temporal.common.Experimental; -import io.temporal.common.Priority; -import io.temporal.common.RetryOptions; -import java.time.Duration; -import java.util.Objects; -import javax.annotation.Nullable; - -/** - * The resolved options of a standalone activity execution, returned by {@link - * UntypedActivityHandle#updateOptions(UpdateActivityOptions)}. - * - *

Reflects the activity's options as the server resolved them after the update was applied. - */ -@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; - - public ActivityExecutionOptions( - @Nullable String taskQueue, - @Nullable Duration scheduleToCloseTimeout, - @Nullable Duration scheduleToStartTimeout, - @Nullable Duration startToCloseTimeout, - @Nullable Duration heartbeatTimeout, - @Nullable RetryOptions retryOptions, - @Nullable Priority priority, - @Nullable Duration startDelay) { - this.taskQueue = taskQueue; - this.scheduleToCloseTimeout = scheduleToCloseTimeout; - this.scheduleToStartTimeout = scheduleToStartTimeout; - this.startToCloseTimeout = startToCloseTimeout; - this.heartbeatTimeout = heartbeatTimeout; - this.retryOptions = retryOptions; - this.priority = priority; - this.startDelay = startDelay; - } - - @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 55e2bae179..dd8864b60d 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java @@ -133,8 +133,8 @@ public void pause() { } @Override - public void pause(@Nullable String reason) { - delegate.pause(reason); + public void pause(PauseActivityOptions options) { + delegate.pause(options); } @Override @@ -158,7 +158,12 @@ public void reset(ResetActivityOptions options) { } @Override - public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { + public UpdateActivityOptions updateOptions(UpdateActivityOptions options) { return delegate.updateOptions(options); } + + @Override + public UpdateActivityOptions restoreOriginalOptions() { + return delegate.restoreOriginalOptions(); + } } 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..f529839833 --- /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/UntypedActivityHandle.java b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java index 3122c4f79b..0fefbbf95e 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -163,11 +163,11 @@ CompletableFuture getResultAsync( void pause(); /** - * Pauses the activity with an optional reason. + * Pauses the activity with the given options. * - * @param reason human-readable reason for pausing, may be {@code null} + * @param options pause options (reason) */ - void pause(@Nullable String reason); + void pause(PauseActivityOptions options); /** Unpauses the activity with default options, allowing it to be dispatched again. */ void unpause(); @@ -191,12 +191,18 @@ CompletableFuture getResultAsync( /** * Updates the activity's options. Only the fields explicitly set in {@code options} are changed; - * a derived field mask leaves the rest untouched. Alternatively, {@link - * UpdateActivityOptions.Builder#setRestoreOriginal(boolean)} reverts the options to the values - * the activity was created with. + * a derived field mask leaves the rest untouched. To revert to the options the activity was + * created with, use {@link #restoreOriginalOptions()}. * * @param options the options to apply * @return the activity options as resolved by the server after the update */ - ActivityExecutionOptions updateOptions(UpdateActivityOptions options); + UpdateActivityOptions updateOptions(UpdateActivityOptions options); + + /** + * Restores the activity's options to the ones it was created with. + * + * @return the activity options as resolved by the server after the restore + */ + UpdateActivityOptions restoreOriginalOptions(); } diff --git a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java index 132333a96b..ba51df1fe5 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java @@ -1,6 +1,5 @@ package io.temporal.client; -import com.google.common.base.Preconditions; import io.temporal.common.Experimental; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; @@ -13,10 +12,6 @@ * *

Only the fields that are explicitly set are sent to the server; a derived field mask ensures * that unset fields are left unchanged (a partial update). - * - *

{@link Builder#setRestoreOriginal(boolean)} is mutually exclusive with every other field: an - * instance that sets {@code restoreOriginal} together with any other option is rejected by {@link - * Builder#build()} before any request is sent. */ @Experimental public final class UpdateActivityOptions { @@ -38,7 +33,6 @@ public static final class Builder { private @Nullable RetryOptions retryOptions; private @Nullable Priority priority; private @Nullable Duration startDelay; - private boolean restoreOriginal; private Builder() {} @@ -54,7 +48,6 @@ private Builder(UpdateActivityOptions options) { this.retryOptions = options.retryOptions; this.priority = options.priority; this.startDelay = options.startDelay; - this.restoreOriginal = options.restoreOriginal; } /** New task queue for the activity. */ @@ -105,39 +98,7 @@ public Builder setStartDelay(@Nullable Duration startDelay) { return this; } - /** - * If set, the activity options are restored to the originals the activity was created with. - * This flag cannot be combined with any other field. - */ - public Builder setRestoreOriginal(boolean restoreOriginal) { - this.restoreOriginal = restoreOriginal; - return this; - } - public UpdateActivityOptions build() { - if (restoreOriginal) { - Preconditions.checkArgument( - taskQueue == null - && scheduleToCloseTimeout == null - && scheduleToStartTimeout == null - && startToCloseTimeout == null - && heartbeatTimeout == null - && retryOptions == null - && priority == null - && startDelay == null, - "restoreOriginal cannot be combined with any other option"); - } else { - Preconditions.checkArgument( - taskQueue != null - || scheduleToCloseTimeout != null - || scheduleToStartTimeout != null - || startToCloseTimeout != null - || heartbeatTimeout != null - || retryOptions != null - || priority != null - || startDelay != null, - "At least one option must be set, or restoreOriginal must be used"); - } return new UpdateActivityOptions(this); } } @@ -150,7 +111,6 @@ public UpdateActivityOptions build() { private final @Nullable RetryOptions retryOptions; private final @Nullable Priority priority; private final @Nullable Duration startDelay; - private final boolean restoreOriginal; private UpdateActivityOptions(Builder builder) { this.taskQueue = builder.taskQueue; @@ -161,7 +121,6 @@ private UpdateActivityOptions(Builder builder) { this.retryOptions = builder.retryOptions; this.priority = builder.priority; this.startDelay = builder.startDelay; - this.restoreOriginal = builder.restoreOriginal; } public Builder toBuilder() { @@ -208,17 +167,12 @@ public Duration getStartDelay() { return startDelay; } - public boolean isRestoreOriginal() { - return restoreOriginal; - } - @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UpdateActivityOptions that = (UpdateActivityOptions) o; - return restoreOriginal == that.restoreOriginal - && Objects.equals(taskQueue, that.taskQueue) + return Objects.equals(taskQueue, that.taskQueue) && Objects.equals(scheduleToCloseTimeout, that.scheduleToCloseTimeout) && Objects.equals(scheduleToStartTimeout, that.scheduleToStartTimeout) && Objects.equals(startToCloseTimeout, that.startToCloseTimeout) @@ -238,8 +192,7 @@ public int hashCode() { heartbeatTimeout, retryOptions, priority, - startDelay, - restoreOriginal); + startDelay); } @Override @@ -261,8 +214,6 @@ public String toString() { + priority + ", startDelay=" + startDelay - + ", restoreOriginal=" - + restoreOriginal + '}'; } } 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 aca1fba657..9b167be3bf 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 @@ -8,10 +8,13 @@ import io.temporal.client.ActivityExecutionMetadata; import io.temporal.client.ActivityFailedException; import io.temporal.client.DescribeActivityOptions; +import io.temporal.client.PauseActivityOptions; +import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; +import io.temporal.client.UnpauseActivityOptions; +import io.temporal.client.UpdateActivityOptions; import io.temporal.common.Experimental; import java.lang.reflect.Type; -import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; @@ -390,12 +393,12 @@ final class TerminateActivityOutput {} final class PauseActivityInput { private final String id; private final @Nullable String runId; - private final @Nullable String reason; + private final PauseActivityOptions options; - public PauseActivityInput(String id, @Nullable String runId, @Nullable String reason) { + public PauseActivityInput(String id, @Nullable String runId, PauseActivityOptions options) { this.id = id; this.runId = runId; - this.reason = reason; + this.options = options; } public String getId() { @@ -407,9 +410,8 @@ public String getRunId() { return runId; } - @Nullable - public String getReason() { - return reason; + public PauseActivityOptions getOptions() { + return options; } } @@ -420,15 +422,12 @@ final class PauseActivityOutput {} final class UnpauseActivityInput { private final String id; private final @Nullable String runId; - private final @Nullable String reason; - private final @Nullable Duration jitter; + private final UnpauseActivityOptions options; - public UnpauseActivityInput( - String id, @Nullable String runId, @Nullable String reason, @Nullable Duration jitter) { + public UnpauseActivityInput(String id, @Nullable String runId, UnpauseActivityOptions options) { this.id = id; this.runId = runId; - this.reason = reason; - this.jitter = jitter; + this.options = options; } public String getId() { @@ -440,14 +439,8 @@ public String getRunId() { return runId; } - @Nullable - public String getReason() { - return reason; - } - - @Nullable - public Duration getJitter() { - return jitter; + public UnpauseActivityOptions getOptions() { + return options; } } @@ -458,24 +451,12 @@ final class UnpauseActivityOutput {} final class ResetActivityInput { private final String id; private final @Nullable String runId; - private final boolean keepPaused; - private final @Nullable Duration jitter; - private final boolean restoreOriginalOptions; - private final boolean resetHeartbeat; + private final ResetActivityOptions options; - public ResetActivityInput( - String id, - @Nullable String runId, - boolean keepPaused, - @Nullable Duration jitter, - boolean restoreOriginalOptions, - boolean resetHeartbeat) { + public ResetActivityInput(String id, @Nullable String runId, ResetActivityOptions options) { this.id = id; this.runId = runId; - this.keepPaused = keepPaused; - this.jitter = jitter; - this.restoreOriginalOptions = restoreOriginalOptions; - this.resetHeartbeat = resetHeartbeat; + this.options = options; } public String getId() { @@ -487,21 +468,8 @@ public String getRunId() { return runId; } - public boolean isKeepPaused() { - return keepPaused; - } - - @Nullable - public Duration getJitter() { - return jitter; - } - - public boolean isRestoreOriginalOptions() { - return restoreOriginalOptions; - } - - public boolean isResetHeartbeat() { - return resetHeartbeat; + public ResetActivityOptions getOptions() { + return options; } } @@ -553,15 +521,15 @@ public boolean isRestoreOriginal() { @Experimental final class UpdateActivityOptionsOutput { - private final ActivityOptions activityOptions; + private final UpdateActivityOptions options; - public UpdateActivityOptionsOutput(ActivityOptions activityOptions) { - this.activityOptions = activityOptions; + public UpdateActivityOptionsOutput(UpdateActivityOptions options) { + this.options = options; } /** The activity options as resolved by the server after the update. */ - public ActivityOptions getActivityOptions() { - return activityOptions; + public UpdateActivityOptions getOptions() { + return options; } } 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 fbdcc89ee5..85103c8397 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 @@ -6,8 +6,8 @@ import io.temporal.api.activity.v1.ActivityOptions; import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.client.ActivityExecutionDescription; -import io.temporal.client.ActivityExecutionOptions; import io.temporal.client.DescribeActivityOptions; +import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; @@ -15,7 +15,6 @@ import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.internal.common.ProtoConverters; import io.temporal.internal.common.ProtobufTimeUtils; -import io.temporal.internal.common.RetryOptionsUtils; import java.lang.reflect.Type; import java.util.ArrayList; import java.util.List; @@ -154,13 +153,13 @@ public void terminate(@Nullable String reason) { @Override public void pause() { - pause(null); + pause(PauseActivityOptions.getDefaultInstance()); } @Override - public void pause(@Nullable String reason) { + public void pause(PauseActivityOptions options) { clientCallsInterceptor.pauseActivity( - new ActivityClientCallsInterceptor.PauseActivityInput(activityId, activityRunId, reason)); + new ActivityClientCallsInterceptor.PauseActivityInput(activityId, activityRunId, options)); } @Override @@ -172,7 +171,7 @@ public void unpause() { public void unpause(UnpauseActivityOptions options) { clientCallsInterceptor.unpauseActivity( new ActivityClientCallsInterceptor.UnpauseActivityInput( - activityId, activityRunId, options.getReason(), options.getJitter())); + activityId, activityRunId, options)); } @Override @@ -183,58 +182,49 @@ public void reset() { @Override public void reset(ResetActivityOptions options) { clientCallsInterceptor.resetActivity( - new ActivityClientCallsInterceptor.ResetActivityInput( - activityId, - activityRunId, - options.isKeepPaused(), - options.getJitter(), - options.isRestoreOriginalOptions(), - options.isResetHeartbeat())); + new ActivityClientCallsInterceptor.ResetActivityInput(activityId, activityRunId, options)); } @Override - public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { + public UpdateActivityOptions updateOptions(UpdateActivityOptions options) { ActivityOptions.Builder activityOptions = ActivityOptions.newBuilder(); List maskPaths = new ArrayList<>(); - if (!options.isRestoreOriginal()) { - if (options.getTaskQueue() != null) { - activityOptions.setTaskQueue( - TaskQueue.newBuilder().setName(options.getTaskQueue()).build()); - maskPaths.add("task_queue.name"); - } - if (options.getScheduleToCloseTimeout() != null) { - activityOptions.setScheduleToCloseTimeout( - ProtobufTimeUtils.toProtoDuration(options.getScheduleToCloseTimeout())); - maskPaths.add("schedule_to_close_timeout"); - } - if (options.getScheduleToStartTimeout() != null) { - activityOptions.setScheduleToStartTimeout( - ProtobufTimeUtils.toProtoDuration(options.getScheduleToStartTimeout())); - maskPaths.add("schedule_to_start_timeout"); - } - if (options.getStartToCloseTimeout() != null) { - activityOptions.setStartToCloseTimeout( - ProtobufTimeUtils.toProtoDuration(options.getStartToCloseTimeout())); - maskPaths.add("start_to_close_timeout"); - } - if (options.getHeartbeatTimeout() != null) { - activityOptions.setHeartbeatTimeout( - ProtobufTimeUtils.toProtoDuration(options.getHeartbeatTimeout())); - maskPaths.add("heartbeat_timeout"); - } - if (options.getRetryOptions() != null) { - activityOptions.setRetryPolicy(toRetryPolicy(options.getRetryOptions())); - maskPaths.add("retry_policy"); - } - if (options.getPriority() != null) { - activityOptions.setPriority(ProtoConverters.toProto(options.getPriority())); - maskPaths.add("priority"); - } - if (options.getStartDelay() != null) { - activityOptions.setStartDelay(ProtobufTimeUtils.toProtoDuration(options.getStartDelay())); - maskPaths.add("start_delay"); - } + if (options.getTaskQueue() != null) { + activityOptions.setTaskQueue(TaskQueue.newBuilder().setName(options.getTaskQueue()).build()); + maskPaths.add("task_queue.name"); + } + if (options.getScheduleToCloseTimeout() != null) { + activityOptions.setScheduleToCloseTimeout( + ProtobufTimeUtils.toProtoDuration(options.getScheduleToCloseTimeout())); + maskPaths.add("schedule_to_close_timeout"); + } + if (options.getScheduleToStartTimeout() != null) { + activityOptions.setScheduleToStartTimeout( + ProtobufTimeUtils.toProtoDuration(options.getScheduleToStartTimeout())); + maskPaths.add("schedule_to_start_timeout"); + } + if (options.getStartToCloseTimeout() != null) { + activityOptions.setStartToCloseTimeout( + ProtobufTimeUtils.toProtoDuration(options.getStartToCloseTimeout())); + maskPaths.add("start_to_close_timeout"); + } + if (options.getHeartbeatTimeout() != null) { + activityOptions.setHeartbeatTimeout( + ProtobufTimeUtils.toProtoDuration(options.getHeartbeatTimeout())); + maskPaths.add("heartbeat_timeout"); + } + if (options.getRetryOptions() != null) { + activityOptions.setRetryPolicy(toRetryPolicy(options.getRetryOptions())); + maskPaths.add("retry_policy"); + } + if (options.getPriority() != null) { + activityOptions.setPriority(ProtoConverters.toProto(options.getPriority())); + maskPaths.add("priority"); + } + if (options.getStartDelay() != null) { + activityOptions.setStartDelay(ProtobufTimeUtils.toProtoDuration(options.getStartDelay())); + maskPaths.add("start_delay"); } FieldMask updateMask = FieldMask.newBuilder().addAllPaths(maskPaths).build(); @@ -242,32 +232,21 @@ public ActivityExecutionOptions updateOptions(UpdateActivityOptions options) { ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = clientCallsInterceptor.updateActivityOptions( new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( - activityId, - activityRunId, - activityOptions.build(), - updateMask, - options.isRestoreOriginal())); + activityId, activityRunId, activityOptions.build(), updateMask, false)); - return fromProto(output.getActivityOptions()); + return output.getOptions(); } - private static ActivityExecutionOptions fromProto(ActivityOptions proto) { - return new ActivityExecutionOptions( - proto.hasTaskQueue() ? proto.getTaskQueue().getName() : null, - proto.hasScheduleToCloseTimeout() - ? ProtobufTimeUtils.toJavaDuration(proto.getScheduleToCloseTimeout()) - : null, - proto.hasScheduleToStartTimeout() - ? ProtobufTimeUtils.toJavaDuration(proto.getScheduleToStartTimeout()) - : null, - proto.hasStartToCloseTimeout() - ? ProtobufTimeUtils.toJavaDuration(proto.getStartToCloseTimeout()) - : null, - proto.hasHeartbeatTimeout() - ? ProtobufTimeUtils.toJavaDuration(proto.getHeartbeatTimeout()) - : null, - proto.hasRetryPolicy() ? RetryOptionsUtils.toRetryOptions(proto.getRetryPolicy()) : null, - proto.hasPriority() ? ProtoConverters.fromProto(proto.getPriority()) : null, - proto.hasStartDelay() ? ProtobufTimeUtils.toJavaDuration(proto.getStartDelay()) : null); + @Override + public UpdateActivityOptions restoreOriginalOptions() { + ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = + clientCallsInterceptor.updateActivityOptions( + new ActivityClientCallsInterceptor.UpdateActivityOptionsInput( + activityId, + activityRunId, + ActivityOptions.getDefaultInstance(), + FieldMask.getDefaultInstance(), + 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 2e6ecfdd30..cb5f3209c4 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 @@ -9,6 +9,7 @@ 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; @@ -25,6 +26,7 @@ import io.temporal.internal.common.InternalUtils; import io.temporal.internal.common.ProtoConverters; import io.temporal.internal.common.ProtobufTimeUtils; +import io.temporal.internal.common.RetryOptionsUtils; import io.temporal.internal.common.SearchAttributesUtil; import io.temporal.internal.nexus.CurrentNexusOperationContext; import io.temporal.internal.nexus.InternalNexusOperationContext; @@ -347,7 +349,8 @@ public DescribeActivityOutput describeActivity(DescribeActivityInput input) { if (input.getRunId() != null) { req.setRunId(input.getRunId()); } - DescribeActivityExecutionResponse response = genericClient.describeActivity(req.build()); + DescribeActivityExecutionResponse response = + stripUnrequestedPayloads(genericClient.describeActivity(req.build()), input.getOptions()); return new DescribeActivityOutput( new ActivityExecutionDescription( response, clientOptions.getDataConverter(), clientOptions.getNamespace())); @@ -400,8 +403,8 @@ public PauseActivityOutput pauseActivity(PauseActivityInput input) { if (input.getRunId() != null) { req.setRunId(input.getRunId()); } - if (input.getReason() != null) { - req.setReason(input.getReason()); + if (input.getOptions().getReason() != null) { + req.setReason(input.getOptions().getReason()); } genericClient.pauseActivity(req.build()); return new PauseActivityOutput(); @@ -418,11 +421,11 @@ public UnpauseActivityOutput unpauseActivity(UnpauseActivityInput input) { if (input.getRunId() != null) { req.setRunId(input.getRunId()); } - if (input.getReason() != null) { - req.setReason(input.getReason()); + if (input.getOptions().getReason() != null) { + req.setReason(input.getOptions().getReason()); } - if (input.getJitter() != null) { - req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getJitter())); + if (input.getOptions().getJitter() != null) { + req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getOptions().getJitter())); } genericClient.unpauseActivity(req.build()); return new UnpauseActivityOutput(); @@ -436,14 +439,14 @@ public ResetActivityOutput resetActivity(ResetActivityInput input) { .setIdentity(clientOptions.getIdentity()) .setActivityId(input.getId()) .setRequestId(UUID.randomUUID().toString()) - .setKeepPaused(input.isKeepPaused()) - .setRestoreOriginalOptions(input.isRestoreOriginalOptions()) - .setResetHeartbeat(input.isResetHeartbeat()); + .setKeepPaused(input.getOptions().isKeepPaused()) + .setRestoreOriginalOptions(input.getOptions().isRestoreOriginalOptions()) + .setResetHeartbeat(input.getOptions().isResetHeartbeat()); if (input.getRunId() != null) { req.setRunId(input.getRunId()); } - if (input.getJitter() != null) { - req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getJitter())); + if (input.getOptions().getJitter() != null) { + req.setJitter(ProtobufTimeUtils.toProtoDuration(input.getOptions().getJitter())); } genericClient.resetActivity(req.build()); return new ResetActivityOutput(); @@ -467,7 +470,7 @@ public UpdateActivityOptionsOutput updateActivityOptions(UpdateActivityOptionsIn } UpdateActivityExecutionOptionsResponse response = genericClient.updateActivityOptions(req.build()); - return new UpdateActivityOptionsOutput(response.getActivityOptions()); + return new UpdateActivityOptionsOutput(toUpdateActivityOptions(response.getActivityOptions())); } @Override @@ -495,4 +498,65 @@ public CountActivitiesOutput countActivities(CountActivitiesInput input) { CountActivityExecutionsResponse resp = genericClient.countActivities(req.build()); return new CountActivitiesOutput(new ActivityExecutionCount(resp)); } + + /** + * Clears payload-bearing fields the caller did not ask for, in case an older or buggy server sent + * them anyway. + */ + private static DescribeActivityExecutionResponse stripUnrequestedPayloads( + DescribeActivityExecutionResponse response, DescribeActivityOptions options) { + if (options.isIncludeInput() + && options.isIncludeOutcome() + && options.isIncludeHeartbeatDetails() + && options.isIncludeLastFailure()) { + return response; + } + DescribeActivityExecutionResponse.Builder builder = response.toBuilder(); + if (!options.isIncludeInput()) { + builder.clearInput(); + } + if (!options.isIncludeOutcome()) { + builder.clearOutcome(); + } + if (!options.isIncludeHeartbeatDetails()) { + builder.getInfoBuilder().clearHeartbeatDetails(); + } + if (!options.isIncludeLastFailure()) { + builder.getInfoBuilder().clearLastFailure(); + } + return builder.build(); + } + + /** Converts the server's resolved activity options into the public options type. */ + private static UpdateActivityOptions toUpdateActivityOptions(ActivityOptions proto) { + UpdateActivityOptions.Builder builder = UpdateActivityOptions.newBuilder(); + if (proto.hasTaskQueue()) { + builder.setTaskQueue(proto.getTaskQueue().getName()); + } + if (proto.hasScheduleToCloseTimeout()) { + builder.setScheduleToCloseTimeout( + ProtobufTimeUtils.toJavaDuration(proto.getScheduleToCloseTimeout())); + } + if (proto.hasScheduleToStartTimeout()) { + builder.setScheduleToStartTimeout( + ProtobufTimeUtils.toJavaDuration(proto.getScheduleToStartTimeout())); + } + if (proto.hasStartToCloseTimeout()) { + builder.setStartToCloseTimeout( + ProtobufTimeUtils.toJavaDuration(proto.getStartToCloseTimeout())); + } + if (proto.hasHeartbeatTimeout()) { + builder.setHeartbeatTimeout(ProtobufTimeUtils.toJavaDuration(proto.getHeartbeatTimeout())); + } + if (proto.hasRetryPolicy()) { + builder.setRetryOptions(RetryOptionsUtils.toRetryOptions(proto.getRetryPolicy())); + } + if (proto.hasPriority()) { + builder.setPriority(ProtoConverters.fromProto(proto.getPriority())); + } + if (proto.hasStartDelay()) { + builder.setStartDelay(ProtobufTimeUtils.toJavaDuration(proto.getStartDelay())); + } + return builder.build(); + } } 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 8ef399cced..6c965c45bc 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/ActivityExecutionDescriptionTest.java @@ -19,7 +19,6 @@ import java.time.Instant; import java.util.Arrays; import java.util.List; -import java.util.Optional; import org.junit.Test; public class ActivityExecutionDescriptionTest { @@ -62,7 +61,7 @@ public void testScheduledTime() { public void testHasHeartbeatDetailsAbsent() { ActivityExecutionDescription desc = describe(buildInfo("id", "run")); assertFalse(desc.hasHeartbeatDetails()); - assertFalse(desc.getHeartbeatDetails(String.class).isPresent()); + assertEquals(0, desc.getHeartbeatDetails().getSize()); } @Test @@ -73,9 +72,8 @@ public void testGetHeartbeatDetailsPresent() { 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 @@ -89,9 +87,9 @@ public void testGetHeartbeatDetailsWithExplicitGenericType() { 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 @@ -115,7 +113,7 @@ public void testGetWorkerDeploymentVersionPresent() { public void testInputAbsentUnlessRequested() { ActivityExecutionDescription desc = describe(buildInfo("id", "run")); assertFalse(desc.hasInput()); - assertFalse(desc.getInput(String.class).isPresent()); + assertEquals(0, desc.getInput().getSize()); } @Test @@ -128,11 +126,12 @@ public void testGetInputPresent() { ActivityExecutionDescription desc = describe(response); assertTrue(desc.hasInput()); - assertEquals("hello-input", desc.getInput(String.class).orElse(null)); + assertEquals(1, desc.getInput().getSize()); + assertEquals("hello-input", desc.getInput().get(0, String.class)); } @Test - public void testGetInputByIndexDecodesEveryArgument() { + public void testGetInputDecodesEveryArgument() { DescribeActivityExecutionResponse response = DescribeActivityExecutionResponse.newBuilder() .setInfo(buildInfo("id", "run")) @@ -140,21 +139,15 @@ public void testGetInputByIndexDecodesEveryArgument() { .build(); ActivityExecutionDescription desc = describe(response); - assertEquals(2, desc.getInputCount()); - assertEquals("first", desc.getInput(0, String.class).orElse(null)); - assertEquals(Integer.valueOf(42), desc.getInput(1, Integer.class).orElse(null)); - // The no-index accessor still reads the first argument. - assertEquals("first", desc.getInput(String.class).orElse(null)); - // Out-of-range indexes are empty rather than throwing. - assertFalse(desc.getInput(2, String.class).isPresent()); - assertFalse(desc.getInput(-1, String.class).isPresent()); + 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 testInputCountZeroWhenInputAbsent() { + public void testInputEmptyWhenInputAbsent() { ActivityExecutionDescription desc = describe(buildInfo("id", "run")); - assertEquals(0, desc.getInputCount()); - assertFalse(desc.getInput(0, String.class).isPresent()); + assertEquals(0, desc.getInput().getSize()); } @Test @@ -162,7 +155,7 @@ public void testOutcomeAbsentUnlessRequested() { ActivityExecutionDescription desc = describe(buildInfo("id", "run")); assertFalse(desc.hasResult()); assertFalse(desc.getResult(String.class).isPresent()); - assertNull(desc.getFailure()); + assertNull(desc.getOutcomeFailure()); } @Test @@ -180,7 +173,7 @@ public void testGetResultPresentOnSuccessfulOutcome() { assertTrue(desc.hasResult()); assertEquals("hello-result", desc.getResult(String.class).orElse(null)); // A successful outcome has no failure arm. - assertNull(desc.getFailure()); + assertNull(desc.getOutcomeFailure()); } @Test @@ -201,7 +194,7 @@ public void testGetFailurePresentOnFailedOutcome() { assertFalse(desc.hasResult()); assertFalse(desc.getResult(String.class).isPresent()); - Exception failure = desc.getFailure(); + RuntimeException failure = desc.getOutcomeFailure(); assertNotNull(failure); assertTrue(failure instanceof ApplicationFailure); assertEquals("boom", ((ApplicationFailure) failure).getOriginalMessage()); 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 index 2a1be1cdac..acf1f944da 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -14,9 +14,9 @@ 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.DescribeActivityOptions; +import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.client.UpdateActivityOptions; @@ -264,7 +264,7 @@ public void unpauseResumes() { .build(); ActivityHandle handle = client.start(QuickActivity.class, QuickActivity::run, opts); - handle.pause("pause-before-unpause"); + handle.pause(PauseActivityOptions.newBuilder().setReason("pause-before-unpause").build()); // A not-yet-started (scheduled) activity transitions fully to PAUSED. assertEventually( Duration.ofSeconds(30), @@ -322,7 +322,7 @@ public void updateOptionsRespectsMask() { .setStartToCloseTimeout(Duration.ofSeconds(45)) .setScheduleToCloseTimeout(Duration.ofSeconds(120))); - ActivityExecutionOptions updated = + UpdateActivityOptions updated = handle.updateOptions( UpdateActivityOptions.newBuilder() .setStartToCloseTimeout(Duration.ofSeconds(90)) @@ -360,7 +360,7 @@ public void updateOptionsAllFields() { ActivityHandle handle = newActivityClient().start(QuickActivity.class, QuickActivity::run, opts); - ActivityExecutionOptions updated = + UpdateActivityOptions updated = handle.updateOptions( UpdateActivityOptions.newBuilder() .setTaskQueue("updated-tq") @@ -408,37 +408,6 @@ public void updateOptionsAllFields() { handle.terminate("cleanup"); } - @Test - public void updateOptionsRestoreOriginalExclusive() { - assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startRunningSlowActivity(slowOpts()); - // Building the request with restore_original AND another option is rejected before any RPC. - IllegalArgumentException err = - assertThrows( - IllegalArgumentException.class, - () -> - handle.updateOptions( - UpdateActivityOptions.newBuilder() - .setRestoreOriginal(true) - .setStartToCloseTimeout(Duration.ofSeconds(5)) - .build())); - assertTrue(err.getMessage().toLowerCase().contains("restore")); - handle.terminate("cleanup"); - } - - @Test - public void updateOptionsRequiresAtLeastOneOption() { - assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startRunningSlowActivity(slowOpts()); - // Building the request with no options and no restore_original is rejected before any RPC. - IllegalArgumentException err = - assertThrows( - IllegalArgumentException.class, - () -> handle.updateOptions(UpdateActivityOptions.newBuilder().build())); - assertTrue(err.getMessage().toLowerCase().contains("at least one option")); - handle.terminate("cleanup"); - } - @Test public void updateOptionsRestoreOriginal() { assumeTrue(SDKTestWorkflowRule.useExternalService); @@ -446,7 +415,7 @@ public void updateOptionsRestoreOriginal() { startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); // Change an option away from the original. - ActivityExecutionOptions changed = + UpdateActivityOptions changed = handle.updateOptions( UpdateActivityOptions.newBuilder() .setStartToCloseTimeout(Duration.ofSeconds(90)) @@ -454,8 +423,7 @@ public void updateOptionsRestoreOriginal() { assertEquals(Duration.ofSeconds(90), changed.getStartToCloseTimeout()); // restore_original alone reverts to the value the activity was created with. - ActivityExecutionOptions restored = - handle.updateOptions(UpdateActivityOptions.newBuilder().setRestoreOriginal(true).build()); + UpdateActivityOptions restored = handle.restoreOriginalOptions(); assertEquals(Duration.ofSeconds(45), restored.getStartToCloseTimeout()); handle.terminate("cleanup"); } @@ -477,7 +445,7 @@ public void updateOptionsOnPausedActivity() { .setScheduleToCloseTimeout(Duration.ofSeconds(120)) .setStartDelay(Duration.ofSeconds(60)) .build()); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventually( Duration.ofSeconds(30), () -> @@ -486,7 +454,7 @@ public void updateOptionsOnPausedActivity() { handle.describe().getRunState())); // Updating options is legal while paused, and the new value lands. - ActivityExecutionOptions updated = + UpdateActivityOptions updated = handle.updateOptions( UpdateActivityOptions.newBuilder() .setStartToCloseTimeout(Duration.ofSeconds(90)) @@ -519,7 +487,7 @@ public void resetKeepsPaused() { ActivityHandle handle = newActivityClient().start(QuickActivity.class, QuickActivity::run, opts); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventually( Duration.ofSeconds(30), () -> @@ -546,7 +514,7 @@ public void resetRestoresOriginalOptions() { ActivityHandle handle = startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); - ActivityExecutionOptions updated = + UpdateActivityOptions updated = handle.updateOptions( UpdateActivityOptions.newBuilder() .setStartToCloseTimeout(Duration.ofSeconds(90)) @@ -576,11 +544,11 @@ public void describePayloadFieldsAreOptIn() { ActivityHandle handle = startHeartbeatReadyActivity(); assertFalse(handle.describe().hasHeartbeatDetails()); - assertFalse(handle.describe().getHeartbeatDetails(String.class).isPresent()); + assertEquals(0, handle.describe().getHeartbeatDetails().getSize()); assertTrue(handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); assertEquals( "hb-details", - handle.describe(WITH_HEARTBEAT_DETAILS).getHeartbeatDetails(String.class).orElse(null)); + handle.describe(WITH_HEARTBEAT_DETAILS).getHeartbeatDetails().get(0, String.class)); handle.terminate("cleanup"); } @@ -604,9 +572,9 @@ public void describeReadsInputAndOutcome() { // Default describe omits both. ActivityExecutionDescription bare = handle.describe(); assertFalse(bare.hasInput()); - assertEquals(0, bare.getInputCount()); + assertEquals(0, bare.getInput().getSize()); assertFalse(bare.hasResult()); - assertNull(bare.getFailure()); + assertNull(bare.getOutcomeFailure()); ActivityExecutionDescription desc = handle.describe( @@ -615,13 +583,13 @@ public void describeReadsInputAndOutcome() { .setIncludeOutcome(true) .build()); assertTrue(desc.hasInput()); - assertEquals(2, desc.getInputCount()); - assertEquals("ping", desc.getInput(0, String.class).orElse(null)); - assertEquals(Integer.valueOf(7), desc.getInput(1, Integer.class).orElse(null)); + assertEquals(2, desc.getInput().getSize()); + assertEquals("ping", desc.getInput().get(0, String.class)); + assertEquals(Integer.valueOf(7), desc.getInput().get(1, Integer.class)); assertTrue(desc.hasResult()); assertEquals("ping-7", desc.getResult(String.class).orElse(null)); // A successful outcome has no failure arm. - assertNull(desc.getFailure()); + assertNull(desc.getOutcomeFailure()); } /** The other arm of the outcome oneof: a terminally failed activity has a failure, no result. */ @@ -645,7 +613,7 @@ public void describeReadsFailureOutcome() { assertFalse(desc.hasResult()); assertFalse(desc.getResult(String.class).isPresent()); - Exception failure = desc.getFailure(); + RuntimeException failure = desc.getOutcomeFailure(); assertNotNull(failure); assertTrue(failure instanceof ApplicationFailure); assertEquals("retryable failure", ((ApplicationFailure) failure).getOriginalMessage()); @@ -656,7 +624,7 @@ public void pausePreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventuallyPaused(handle); // Pause never touches heartbeat details — they persist across the transition. @@ -671,7 +639,7 @@ public void unpausePreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventuallyPaused(handle); // Unpause preserves heartbeat details. The re-dispatched attempt doesn't heartbeat (only @@ -692,7 +660,7 @@ public void resetPreservesHeartbeatByDefault() throws InterruptedException { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventuallyPaused(handle); // As of api#848 / temporal#11417, reset does NOT clear heartbeat details by default — @@ -711,7 +679,7 @@ public void resetClearsHeartbeatWhenFlagSet() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventuallyPaused(handle); // Opt-in flag clears details. @@ -732,7 +700,7 @@ public void updateOptionsPreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); ActivityHandle handle = startHeartbeatReadyActivity(); - handle.pause("hold"); + handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); assertEventuallyPaused(handle); // UpdateOptions changes activity options only; it never touches heartbeat details. @@ -767,7 +735,7 @@ public void interceptorInvokesEachOperatorCommand() { PendingActivityState.PENDING_ACTIVITY_STATE_STARTED, handle.describe().getRunState())); - handle.pause("reason"); + handle.pause(PauseActivityOptions.newBuilder().setReason("reason").build()); assertEventuallyPaused(handle); handle.unpause(); handle.updateOptions( 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 af4052b166..298742392a 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 @@ -13,6 +13,7 @@ import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsRequest; import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsResponse; import io.temporal.client.ActivityClientOptions; +import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; @@ -46,7 +47,7 @@ public void unobservableRequestFields() { UntypedActivityHandle handle = newHandle(); - handle.pause("because"); + handle.pause(PauseActivityOptions.newBuilder().setReason("because").build()); handle.unpause( UnpauseActivityOptions.newBuilder() .setReason("go") From eec348ccdb0d3ba673e3d673dc3e46f67890b5c4 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 20 Aug 2026 13:52:27 -0400 Subject: [PATCH 24/53] Fix DescribeActivityInput call site in temporal-opentracing tests The review fix that added DescribeActivityOptions to DescribeActivityInput missed a third caller outside temporal-sdk, breaking CI: contrib/.../StandaloneActivityClientTracingTest.java:111: error: constructor DescribeActivityInput in class DescribeActivityInput cannot be applied to given types Local verification had only run :temporal-sdk:compileTestJava, which never touches contrib/. A bare `compileTestJava` covers all 16 modules. --- .../opentracing/StandaloneActivityClientTracingTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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( From 550edb39051f7be6d4b547d952bcef4d78f384f3 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 21 Aug 2026 16:10:39 -0400 Subject: [PATCH 25/53] Add a describe paused-status test The paused execution status was only asserted incidentally at the tail of updateOptionsOnPausedActivity. Cover the transition on its own: the same handle reports RUNNING before the pause and PAUSED after, on both the execution status and the run state. Matches the equivalent test in the Ruby, Python and Go suites. --- ...tandaloneActivityOperatorCommandsTest.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) 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 index acf1f944da..b4c889bc9e 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -534,6 +534,42 @@ public void resetRestoresOriginalOptions() { handle.terminate("cleanup"); } + /** + * Describe reports a paused activity as PAUSED (api#834), on both the execution status and the + * run state. Asserts the transition, not just the end state: the same handle reports RUNNING + * before the pause. + */ + @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 payload-bearing describe fields are opt-in (api#792). Assert the default really is "off" * rather than the SDK quietly requesting everything: same activity, same moment, two describes. From 135c6a3d963001f91dad4a3e2e5770089b8bf295 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 21 Aug 2026 16:10:39 -0400 Subject: [PATCH 26/53] Reject an updateOptions call that sets no options An update naming nothing sent an empty field mask and silently changed nothing. Throw IllegalArgumentException before the round trip instead, pointing at restoreOriginalOptions for reverting. Matches Ruby, which raises ArgumentError, and Go and Python, which reject the same call. --- .../internal/client/ActivityHandleImpl.java | 8 +++++ .../ActivityHandleOperatorCommandsTest.java | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+) 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 85103c8397..4557056d47 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 @@ -227,6 +227,14 @@ public UpdateActivityOptions updateOptions(UpdateActivityOptions options) { maskPaths.add("start_delay"); } + // 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 (maskPaths.isEmpty()) { + throw new IllegalArgumentException( + "UpdateActivityOptions must set at least one option to update"); + } + FieldMask updateMask = FieldMask.newBuilder().addAllPaths(maskPaths).build(); ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = 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 298742392a..c76694ca3c 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 @@ -1,10 +1,12 @@ package io.temporal.internal.client; import static org.junit.Assert.assertEquals; +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; @@ -108,6 +110,40 @@ private UnpauseActivityExecutionRequest captureUnpause() { 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(UpdateActivityOptions.newBuilder().build())); + assertTrue(e.getMessage().contains("at least one option")); + + verifyNoInteractions(genericClient); + } + + /** A single set option is enough; the mask names exactly it. */ + @Test + public void updateOptionsAcceptsASingleOption() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle() + .updateOptions( + UpdateActivityOptions.newBuilder().setHeartbeatTimeout(Duration.ofSeconds(25)).build()); + + assertEquals( + java.util.Collections.singletonList("heartbeat_timeout"), + captureUpdate().getUpdateMask().getPathsList()); + } + private ResetActivityExecutionRequest captureReset() { ArgumentCaptor captor = ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); From eb3e02cc8f2de40973142efcb274d7de226f0dab Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 25 Aug 2026 15:56:19 -0400 Subject: [PATCH 27/53] Remove reset/heartbeat tests --- ...tandaloneActivityOperatorCommandsTest.java | 40 ------------------- 1 file changed, 40 deletions(-) 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 index b4c889bc9e..41fda7489b 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -691,46 +691,6 @@ public void unpausePreservesHeartbeat() { handle.terminate("cleanup"); } - @Test(timeout = 60_000) - public void resetPreservesHeartbeatByDefault() throws InterruptedException { - assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startHeartbeatReadyActivity(); - - handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); - assertEventuallyPaused(handle); - - // As of api#848 / temporal#11417, reset does NOT clear heartbeat details by default — - // you must pass resetHeartbeat=true. keep_paused so no new attempt reshapes state. - handle.reset(ResetActivityOptions.newBuilder().setKeepPaused(true).build()); - // Give the server time to persist any state change, then confirm details survive. - Thread.sleep(2000); - assertTrue( - "heartbeat details should be preserved after default reset", - handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); - handle.terminate("cleanup"); - } - - @Test(timeout = 60_000) - public void resetClearsHeartbeatWhenFlagSet() { - assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startHeartbeatReadyActivity(); - - handle.pause(PauseActivityOptions.newBuilder().setReason("hold").build()); - assertEventuallyPaused(handle); - - // Opt-in flag clears details. - handle.reset( - ResetActivityOptions.newBuilder().setKeepPaused(true).setResetHeartbeat(true).build()); - - assertEventually( - Duration.ofSeconds(30), - () -> - assertFalse( - "heartbeat details should be cleared after reset(reset_heartbeat)", - handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails())); - handle.terminate("cleanup"); - } - @Test(timeout = 60_000) public void updateOptionsPreservesHeartbeat() { assumeTrue(SDKTestWorkflowRule.useExternalService); From 8f4b443eafdd3261e08b970267ff29f9db877086 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 12:04:38 -0400 Subject: [PATCH 28/53] Add total_heartbeat_count and test --- .../client/ActivityExecutionDescription.java | 7 +++++++ ...StandaloneActivityOperatorCommandsTest.java | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) 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 cf40c0aca9..485f6ce7b2 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionDescription.java @@ -79,6 +79,13 @@ public int getAttempt() { return response.getInfo().getAttempt(); } + /** + * @return total number of heartbeats recorded across all attempts. + */ + public long getTotalHeartbeatCount() { + return response.getInfo().getTotalHeartbeatCount(); + } + /** * Reason that was provided when cancellation was requested. {@code null} if not cancelled or no * reason was given. 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 index 41fda7489b..3ce6d2ed1d 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -628,6 +628,24 @@ public void describeReadsInputAndOutcome() { assertNull(desc.getOutcomeFailure()); } + /** + * 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"); + } + /** The other arm of the outcome oneof: a terminally failed activity has a failure, no result. */ @Test(timeout = 60_000) public void describeReadsFailureOutcome() { From b251674f89e95766fd01bdc3aca3eed4dad51b5d Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 14:11:41 -0400 Subject: [PATCH 29/53] Update server release version --- .../functional/StandaloneActivityOperatorCommandsTest.java | 4 +--- .../testing/internal/devserver/SdkJavaTestServerProfile.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) 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 index 3ce6d2ed1d..dd27740f66 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -628,9 +628,7 @@ public void describeReadsInputAndOutcome() { assertNull(desc.getOutcomeFailure()); } - /** - * The count tracks heartbeats the server recorded. - */ + /** The count tracks heartbeats the server recorded. */ @Test(timeout = 60_000) public void describeReportsTotalHeartbeatCount() { assumeTrue(SDKTestWorkflowRule.useExternalService); 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 628b8669f0677ea17e6233425c52f5e5020b6df2 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 14:25:14 -0400 Subject: [PATCH 30/53] fix docstring --- .../src/main/java/io/temporal/client/UntypedActivityHandle.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0fefbbf95e..6f07628e96 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -175,7 +175,7 @@ CompletableFuture getResultAsync( /** * Unpauses the activity with the given options. * - * @param options unpause options (reset attempts, reset heartbeat, jitter, reason) + * @param options unpause options (reason, jitter) */ void unpause(UnpauseActivityOptions options); From 092461c146d42554e91b9639a7d0c7f3459a7b3a Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 16:06:19 -0400 Subject: [PATCH 31/53] Assert every reset flag reaches the request unobservableRequestFields checked only reset_heartbeat. Set keep_paused and restore_original_options too and assert all three, since none of them is visible in any observable server state. --- md-review-all-at-once.md | 93 +++++++++++ oprd | 63 ++++++++ pr-desc.md | 150 ++++++++++++++++++ run-all-tests.sh | 12 ++ run-tests.sh | 21 +++ squash-message.txt | 11 ++ .../ActivityHandleOperatorCommandsTest.java | 5 + 7 files changed, 355 insertions(+) create mode 100644 md-review-all-at-once.md create mode 100644 oprd create mode 100644 pr-desc.md create mode 100755 run-all-tests.sh create mode 100755 run-tests.sh create mode 100644 squash-message.txt diff --git a/md-review-all-at-once.md b/md-review-all-at-once.md new file mode 100644 index 0000000000..61d8be1e61 --- /dev/null +++ b/md-review-all-at-once.md @@ -0,0 +1,93 @@ +# Maciej's review of sdk-java#3013 — all fixes, applied at once + +Record of the 14 inline review comments on +https://github.com/temporalio/sdk-java/pull/3013 and the change each one drove. +13 were actionable; one ("lol") needed nothing. + +The Java fixes were applied together on branch `oc-md-review-all`, and are being +re-applied one commit per comment on `oc-md-review-pieces`. + +--- + +## Java + +**Comment A** — *`ActivityExecutionDescription.java:32`:* "`info` field is now redundant, remove it and change methods to use `response.getInfo()`" + +Dropped `private final ActivityExecutionInfo info;`; all ~20 accessors now read `response.getInfo()`. + +**Comment B** — *`ActivityExecutionDescription.java:57`:* "We should add `ActivitySerializationContext` to `dataConverter` here." + +Constructor now stores `dataConverter.withContext(new ActivitySerializationContext(namespace, null, null, getActivityType(), getTaskQueue(), false))`. This also let the `namespace` field go, and removed the ad-hoc `withContext(...)` that `getStaticSummary`/`getStaticDetails` were each building inline. + +**Comment C** — *`ActivityExecutionDescription.java:289`:* "`getInput` should have only one 0-arg overload that returns `EncodedValues`. After that change, we should also remove `getInputCount`. While we're at it, I think we should change `getHeartbeatDetails` to return `EncodedValues` too." + +Five `getInput` overloads plus `getInputCount` collapse to `EncodedValues getInput()`. Both `getHeartbeatDetails` overloads collapse to `EncodedValues getHeartbeatDetails()`. + +**Comment D** — *`ActivityExecutionDescription.java:350`:* "We should match what we do in `ActivityClient.startActivity`." (suggested `return getResult(valueType, null);`) + +Applied; the two-arg form takes `@Nullable Type` and normalizes null to `valueType`, the same way `decodeOutcome` does. + +**Comment E** — *`ActivityExecutionDescription.java:340`:* "Protobuf getters are null-coalescing. Fix other similar lines too." (suggested `return response.getOutcome().hasResult();`) + +Applied to `hasResult()`; the "other similar lines" were the `!response.hasOutcome() ||` guard in the outcome-failure getter and the ternary in the deleted `getInputCount`. + +**Comment F** — *`ActivityExecutionDescription.java:376`:* "I'd rename this method to `getOutcomeFailure` or something to better differentiate it from `getLastFailure`. We should also change return type of both this and `getLastFailure` to `RuntimeException`." + +`getFailure` → `getOutcomeFailure`; both it and `getLastFailure` now return `RuntimeException` (free — `DataConverter.failureToException` already returns that). + +**Comment G** — *`ActivityExecutionOptions.java:17`:* "`UpdateActivityExecutionOptionsRequest` and `UpdateActivityOptionsResponse` use the same type to store options, which means the available fields will always match. Instead of adding yet another class, we can return `UpdateActivityOptions`." + +Deleted `ActivityExecutionOptions.java`; `updateOptions` returns `UpdateActivityOptions`. + +**Comment H** — *`UpdateActivityOptions.java:19`:* "Consider alternative design: `UpdateActivityOptions` does not have `restoreOriginal` field. Instead, `ActivityHandle` has an additional method `restoreOriginalOptions`." + +Removed the field, setter, getter, and both `Preconditions` checks; added `restoreOriginalOptions()` to `UntypedActivityHandle` and both impls. Dropped the two tests that asserted the now-gone validation. + +**Comment I** — *`ActivityClientCallsInterceptor.java:424` and `:464`* (two suggestions): `private final UnpauseActivityOptions options;` / `private final ResetActivityOptions options;` + +Both inputs now carry the options object instead of exploded fields; the invoker reads `input.getOptions()`. + +**Comment J** — *`ActivityClientCallsInterceptor.java:556`:* "`UpdateActivityOptionsOutput` should have the final options object, not Proto object. The conversion should happen inside the root interceptor." + +Output holds `UpdateActivityOptions`; conversion moved from `ActivityHandleImpl.fromProto` into `RootActivityClientInvoker.toUpdateActivityOptions`. + +**Comment K** — *`RootActivityClientInvoker.java:349`:* "We should remove payload fields that were not requested (to support older/buggy servers)." + +New `stripUnrequestedPayloads` clears `input`/`outcome` on the response and `heartbeat_details`/`last_failure` on `info` when the corresponding flag was false. + +**Comment L** — *`UntypedActivityHandle.java:135`:* "I think `DescribeActivityOptions.java` file is missing." + +No code change — the file was committed locally but the branch had unpushed commits. Resolved by pushing. + +**Comment M** — *`ActivityHandleOperatorCommandsTest.java:49`:* "lol" — no action. + +--- + +## Ruby counterparts + +| Ruby change | Inspired by | +|---|---| +| `update_options` loses the `restore_original:` kwarg and both `ArgumentError` guards; new `ActivityHandle#restore_original_options(rpc_options:)` | **Comment H** (restoreOriginal → own method) | +| `implementation.rb#update_activity_options` returns `ActivityExecutionOptions._from_proto(resp.activity_options)` instead of the raw proto; `activity_handle.rb` no longer converts | **Comment J** (output holds final options, convert in root interceptor) | +| `describe_activity` clears `resp.input`, `resp.outcome`, `resp.info.heartbeat_details`, `resp.info.last_failure` when not requested | **Comment K** (strip un-requested payloads) | +| `Description#failure` → `#outcome_failure` | **Comment F** (rename to differentiate from `last_failure`) | +| RBS/RBI updated for both renames and the new method; tests updated, two obsolete validation tests deleted | consequences of **F** and **H** | + +Four Java comments have no Ruby counterpart, deliberately: + +- **Comment C (EncodedValues)** — Ruby has no `Values` type, and `input(hints:)` / `heartbeat_details(hints:)` already return the whole array, which is the shape the Java change moves toward. +- **Comment E (null-coalescing)** — Ruby's protobuf returns `nil` for unset message fields rather than a default instance, so `@raw_description.outcome&.value` is required, not redundant. +- **Comment B (serialization context)** — Ruby's `DataConverter` has no context mechanism; nothing to attach. +- **Comments A, D, G, I** — no Ruby analogue: Ruby's `Description` stores only `@raw_description`, hints are a single value, `ActivityExecutionOptions` isn't redundant there (Ruby's update input is kwargs, not a class), and Ruby's interceptor inputs have no options objects to hold. + +--- + +## Verification + +Java: 40 tests (17 operator-commands, 14 description unit, 8 interceptor, 1 handle-build), 0 failures, spotless clean. +Ruby: 17 + 1 + 1 runs across the three operator-command files, 411 assertions, 0 failures; steep clean, RuboCop clean across 191 files. + +## Notes + +- Removing `restoreOriginal` also removed the "at least one option must be set" guard, since `UpdateActivityOptions` is now both an input and a return type and that check would break the return direction. An empty update now reaches the server rather than failing locally. +- `UpdateActivityOptions` as a return type carries builder-validation semantics it does not need; it works, but a reviewer may notice the dual role. diff --git a/oprd b/oprd new file mode 100644 index 0000000000..5e33196500 --- /dev/null +++ b/oprd @@ -0,0 +1,63 @@ +# Standalone Activity operator commands: Pause, Unpause, Reset, UpdateOptions + +## Summary + +Adds client-side operator commands for standalone activities to the activity handle: +**Pause**, **Unpause**, **Reset**, and **UpdateOptions**. Each is a thin client call that +builds the corresponding `WorkflowService` request and dispatches it through the activity +client interceptor chain, mirroring the existing `cancel`/`terminate` operations on the +handle. All new surface is annotated `@Experimental`. + +## What's added + +### Handle methods (`ActivityHandle` / `UntypedActivityHandle`) +- `pause(String reason)` (and a no-arg `pause()`). +- `unpause(UnpauseActivityOptions)` (and a no-arg `unpause()`). +- `reset(ResetActivityOptions)` (and a no-arg `reset()`). +- `updateOptions(UpdateActivityOptions)` returning the updated `ActivityExecutionOptions`. + +### Option classes +- **`UnpauseActivityOptions`** — `reason`, `resetAttempts`, `resetHeartbeat`, `jitter`. +- **`ResetActivityOptions`** — `resetHeartbeat`, `keepPaused`, `jitter`, + `restoreOriginalOptions`. +- **`UpdateActivityOptions`** — `taskQueue`, `scheduleToCloseTimeout`, + `scheduleToStartTimeout`, `startToCloseTimeout`, `heartbeatTimeout`, `retryOptions`, + `priority`, and `restoreOriginal`. Only the fields explicitly set are sent (a derived + field mask drives the partial update). `build()` validates that `restoreOriginal` is not + combined with any other field. +- **`ActivityExecutionOptions`** — the value object returned by `updateOptions`, reflecting + the activity's options after the update. + +### Interceptor surface (`ActivityClientCallsInterceptor`) +- New methods `pauseActivity`, `unpauseActivity`, `resetActivity`, `updateActivityOptions`, + each with its own `Input` and `Output` class (the `Output` classes follow the existing + empty-placeholder convention). `ActivityClientCallsInterceptorBase` provides pass-through + defaults so existing interceptors keep compiling. + +### Plumbing +- `RootActivityClientInvoker` builds the four requests (setting the dedup `request_id` only + on `pause`, matching `cancel`/`terminate` and the proto), and the internal + `ActivityHandleImpl` wires the handle through the interceptor chain. +- `GenericWorkflowClient` / `GenericWorkflowClientImpl` gain the four service calls. + +## Testing + +- **Functional suite** (`StandaloneActivityOperatorCommandsTest`) — gated on + `SDKTestWorkflowRule.useExternalService` and run against a real dev server, since the + embedded test server does not implement the standalone-activity operator APIs. Covers each + command's observable server-state change: pause→paused, unpause→resumes, reset→attempt + reset to 1, updateOptions partial-mask + all-fields (including `task_queue`), the secondary + flags (`resetAttempts`, `resetHeartbeat`, `keepPaused`, `restoreOriginal`/ + `restoreOriginalOptions`), the `restoreOriginal` exclusivity validation, and an interceptor + flow-through test exercising all four commands. +- **No-server unit test** (`ActivityHandleOperatorCommandsTest`) — asserts the request fields + the server does not surface back (`reason`, `jitter`, the pause `request_id`) are built + correctly, via a mocked `GenericWorkflowClient` with `ArgumentCaptor`. + +## Dependency / merge ordering + +This depends on **server-side standalone-activity operator command support** +(server PR temporalio/temporal#10106), which is **not yet merged**. The functional tests +require a dev server that implements these RPCs. **This SDK PR should not merge until that +server work lands and ships in a released dev server**; until then the functional suite only +passes against a locally built PR server. diff --git a/pr-desc.md b/pr-desc.md new file mode 100644 index 0000000000..bf1a283cea --- /dev/null +++ b/pr-desc.md @@ -0,0 +1,150 @@ +# Add operator commands for standalone activities + +Adds pause, unpause, reset, and update-options to standalone activities, plus +the describe surface needed to observe their effects. + +Standalone activities already supported start, result, describe, cancel, and +terminate. This adds the four operator commands the server exposes for them, so +an operator can hold, resume, restart, and retune a running activity without +going through a workflow. + +## API + +On `ActivityHandle` / `UntypedActivityHandle`: + +```java +void pause(); +void pause(@Nullable String reason); +void unpause(); +void unpause(UnpauseActivityOptions options); +void reset(); +void reset(ResetActivityOptions options); +ActivityExecutionOptions updateOptions(UpdateActivityOptions options); +ActivityExecutionDescription describe(DescribeActivityOptions options); +``` + +New option types, all following the SDK's builder convention with +`newBuilder()`, `getDefaultInstance()`, `toBuilder()`, and value equality: + +- `UnpauseActivityOptions` — reason, jitter. +- `ResetActivityOptions` — keep-paused, jitter, restore-original-options, + reset-heartbeat. +- `UpdateActivityOptions` — task queue, the four timeouts, retry policy, + priority, start delay, restore-original. +- `ActivityExecutionOptions` — the server's post-update view, returned by + `updateOptions`. +- `DescribeActivityOptions` — the four payload opt-ins described below. + +`updateOptions` derives a field mask from exactly the options set on the +builder, so unset fields are left untouched server-side. + +## Describe: payload fields are opt-in + +`DescribeActivityExecutionRequest` gates four payload-bearing fields behind +per-call flags (api#792). All four are now plumbed through +`DescribeActivityOptions` and **default to false**, matching Rust's +`ActivityDescribeOptions`: + +```java +DescribeActivityOptions.newBuilder() + .setIncludeInput(true) + .setIncludeOutcome(true) + .setIncludeHeartbeatDetails(true) + .setIncludeLastFailure(true) + .build(); +``` + +This is a behavior change: `describe()` previously hard-coded +`includeHeartbeatDetails` and `includeLastFailure` to true. Callers that read +heartbeat details or the last failure must now ask for them. The rationale is +the one the proto gives — these fields carry arbitrarily large payloads and +shouldn't be fetched unless needed. + +`ActivityExecutionDescription` gained accessors for the newly reachable data: + +- `hasInput()`, `getInput(int, Class)`, `getInput(int, Class, Type)`, + `getInput(Class)`, `getInput(Class, Type)`, `getInputCount()` +- `hasResult()`, `getResult(Class)`, `getResult(Class, Type)`, `getFailure()` +- `hasLastFailure()`, `getStartDelay()`, `getExecutionTime()`, `getRawResponse()` + +The outcome is a result-or-failure oneof; it's flattened into `hasResult` / +`getResult` / `getFailure` rather than exposed as an outcome object, and none of +them throw — this follows `NexusOperationExecutionDescription`, which solves the +same problem for the same reason. `getFailure()` is the terminal outcome; +`getLastFailure()` remains the most recent attempt's failure and may be set +while the activity is still retrying. + +Activity input is a payload *list*, unlike a Nexus operation's single payload, +which is why the input accessors are indexed while the Nexus ones aren't. + +## Compatibility + +The one behavior change is `describe()` no longer returning heartbeat details +and the last failure unless asked, described above. + +`ActivityExecutionDescription`'s constructor signature changes from +`ActivityExecutionInfo` to `DescribeActivityExecutionResponse`. The `input` and +`outcome` fields live on the response, not on `info`, so the info alone can't +back the new accessors. This also brings the class in line with its siblings — +`WorkflowExecutionDescription` and `NexusOperationExecutionDescription` both +already take their full response. + +In practice this constructor is internal: the only callers in the tree are +`RootActivityClientInvoker` and a unit test. It is public only because the +invoker lives in `io.temporal.internal.client` and Java has no internal +visibility. The one way a user could be affected is an interceptor that +synthesizes a `DescribeActivityOutput` rather than delegating to `next`. + +Note that `ActivityExecutionMetadata.getScheduledTime()` is deliberately left +alone. It is the odd spelling out among the SDKs — the proto field is +`schedule_time`, and Ruby and Rust both expose `schedule_time` — but it has +shipped in every release since v1.35.0, and renaming it would be a compile break +bought for nothing this PR needs. Worth doing separately, most likely as a +deprecate-and-delegate rather than a rename. + +## Interceptors + +`ActivityClientCallsInterceptor` gains `pauseActivity`, `unpauseActivity`, +`resetActivity`, and `updateActivityOptions`, each with an `*Input`/`*Output` +pair, and `DescribeActivityInput` now carries the describe options. +`ActivityClientCallsInterceptorBase` picks up matching pass-through overrides +and a class-level `@Experimental` it was previously missing. + +## Tests + +- `StandaloneActivityOperatorCommandsTest` — functional coverage of each + command against a real server, each asserting an observable server-side state + change rather than just a successful RPC. Includes update-options on a paused + activity, describe reporting `PAUSED` for an activity paused while scheduled, + the heartbeat-preservation behavior of each command, and a test that describe's + payload fields really are off by default. +- `ActivityExecutionDescriptionTest` — unit coverage of the new accessors, + including both arms of the outcome oneof and multi-argument input. +- `ActivityHandleOperatorCommandsTest` — unit coverage that each command builds + the right request. +- `ActivityClientCallsInterceptorBaseTest` — pass-through coverage. + +Functional tests are gated on `SDKTestWorkflowRule.useExternalService`; the +embedded test server doesn't implement the standalone-activity APIs. + +## Notes for reviewers + +- `getInputs(Class[], Type[])` returning `Object[]` is deliberate, and worth + a look. It's the only user-facing `Object[]`-returning decode accessor in the + SDK, and it coexists with the indexed `getInput(int, ...)`, so it is partly + redundant. The SDK's existing abstraction for a typed argument list is + `Values`/`EncodedValues` (what `DynamicActivity` receives); using it here was + considered and not taken. +- `retry_state` on `ActivityExecutionOutcome` (api#843, server-populated since + temporal#11321) is reachable via `getRawResponse()` but has no typed accessor + yet. + +## Upstream dependencies + +Requires a server with the standalone-activity operator-command APIs enabled +(`frontend.activityAPIsEnabled`). Relevant API changes already merged and +reflected here: api#792 (describe opt-ins), api#834 (`PAUSED` status), api#844 +(request IDs), api#846 (removed `reset_attempts`/`reset_heartbeat` from +Unpause), api#848 (`reset_heartbeat` back on Reset, paired with +temporal#11417), api#807 (`execution_time`), api#804 / temporal#10745 +(`start_delay` on update-options). diff --git a/run-all-tests.sh b/run-all-tests.sh new file mode 100755 index 0000000000..ad1003beb9 --- /dev/null +++ b/run-all-tests.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Runs the full Java build (compile, spotless, tests) the way CI does, against the CLI release +# pinned in SdkJavaTestServerProfile.TEST_CLI_VERSION. No local server involved. +# +# NOTE: not added to git (per working conventions). +set -euo pipefail + +WT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$WT" + +./gradlew prepareDevServerTests +./gradlew --offline build -PtestServer=dev-server diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000000..ca9bfb11ba --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Runs the SAA operator-command tests on gmt/operator-commands. +# +# No server setup: `-PtestServer=dev-server` makes gradle download the CLI release pinned in +# SdkJavaTestServerProfile.TEST_CLI_VERSION, start it, and set USE_EXTERNAL_SERVICE=true — the +# same path CI's "Unit test with CLI" job takes. Do not point this at a hand-built server; the +# whole point is that local runs and CI exercise the identical binary. +# +# NOTE: not added to git (per working conventions). +set -euo pipefail + +WT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$WT" + +./gradlew prepareDevServerTests +./gradlew --offline :temporal-sdk:test -PtestServer=dev-server \ + --tests '*StandaloneActivityOperatorCommandsTest' \ + --tests '*StandaloneActivityTest' \ + --tests '*ActivityExecutionDescriptionTest' \ + --tests '*ActivityHandleOperatorCommandsTest' \ + --tests '*ActivityClientCallsInterceptorBaseTest' diff --git a/squash-message.txt b/squash-message.txt new file mode 100644 index 0000000000..907c4d24bb --- /dev/null +++ b/squash-message.txt @@ -0,0 +1,11 @@ +- ActivityExecutionDescription: drop the redundant `info` field and read `response.getInfo()` throughout. +- ActivityExecutionDescription: attach ActivitySerializationContext to the data converter once in the constructor, instead of rebuilding it on every user-metadata read. +- ActivityExecutionDescription: drop parent-presence guards that protobuf's null-coalescing getters make redundant. +- ActivityExecutionDescription: getResult(Class) now passes a null generic type, matching ActivityClient.startActivity; the two-arg overload accepts null and normalizes it (previously it threw). +- ActivityExecutionDescription: rename getFailure to getOutcomeFailure to distinguish the terminal outcome from getLastFailure; both now return RuntimeException. +- ActivityExecutionDescription: getInput() and getHeartbeatDetails() return EncodedValues; getInputCount() and the typed overloads are gone. BREAKING: getHeartbeatDetails shipped in v1.35.0-v1.38.0. +- ActivityClientCallsInterceptor: UnpauseActivityInput and ResetActivityInput carry the options object rather than exploded fields. +- RootActivityClientInvoker: clear payload fields the caller did not request, so an older or buggy server cannot make the description's has* accessors disagree with what was asked for. +- Delete ActivityExecutionOptions and return UpdateActivityOptions from updateOptions; the update request and response share one proto options type, so the field sets cannot diverge. +- UpdateActivityOptionsOutput holds the final options object; the proto-to-options conversion moved into the root interceptor, so interceptors see the public type rather than the wire type. +- Move restoreOriginal off UpdateActivityOptions into ActivityHandle.restoreOriginalOptions(), removing a builder state the server rejects outright. This also drops the "at least one option must be set" guard, which no longer holds now that the type serves as both request and response. 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 c76694ca3c..935905f021 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 @@ -59,6 +59,8 @@ public void unobservableRequestFields() { ResetActivityOptions.newBuilder() .setJitter(Duration.ofSeconds(2)) .setResetHeartbeat(true) + .setKeepPaused(true) + .setRestoreOriginalOptions(true) .build()); handle.updateOptions( UpdateActivityOptions.newBuilder().setStartDelay(Duration.ofSeconds(7)).build()); @@ -83,6 +85,9 @@ public void unobservableRequestFields() { assertEquals(0, resetReq.getJitter().getNanos()); assertTrue("reset request_id should be set", !resetReq.getRequestId().isEmpty()); assertTrue("reset should carry reset_heartbeat=true", resetReq.getResetHeartbeat()); + assertTrue("reset should carry keep_paused=true", resetReq.getKeepPaused()); + assertTrue( + "reset should carry restore_original_options=true", resetReq.getRestoreOriginalOptions()); // updateOptions carries start_delay in activity_options with a matching update_mask path, plus // an auto-generated dedup request_id (api#844). start_delay is applied server-side but not From 3a20112c3abe903ebba0f18630dbb1cccafd1942 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 16:22:40 -0400 Subject: [PATCH 32/53] Replace three describe tests with one describePayloads Ported from sdk-python#1782, written against our current API shape. Replaces describePayloadFieldsAreOptIn, describeReadsInputAndOutcome and describeReadsFailureOutcome with one test covering all three, and more: - a new HeartbeatFailIncrementActivity heartbeats, fails once, then succeeds, so a single describe carries input, result, heartbeat details and a last failure at the same time. The three tests it replaces each used a different activity, so no describe ever held them together. - pins hasLastFailure true while getOutcomeFailure is null on a succeeded activity that failed once, the terminal-versus-attempt distinction that was untested. - asserts the accessors are absent, not merely that has* is false. The activity takes and returns Integer rather than int: MethodExtractor cannot probe a method reference with primitive types. --- ...tandaloneActivityOperatorCommandsTest.java | 138 ++++++++++-------- 1 file changed, 76 insertions(+), 62 deletions(-) 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 index dd27740f66..9a828c13e4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -123,6 +123,28 @@ public String run() { } } + /** + * Heartbeats, fails the first attempt, then succeeds. One execution of this carries input, a + * result, heartbeat details and a last failure all at once, which is what lets a single describe + * exercise every payload field. + */ + @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 @@ -166,7 +188,8 @@ public void run() { new QuickActivityImpl(), new FailThenSucceedActivityImpl(), new TwoArgActivityImpl(), - new HeartbeatOnceActivityImpl()) + new HeartbeatOnceActivityImpl(), + new HeartbeatFailIncrementActivityImpl()) .build(); /** @@ -570,105 +593,96 @@ public void describeReportsPausedStatus() { handle.terminate("cleanup"); } - /** - * The payload-bearing describe fields are opt-in (api#792). Assert the default really is "off" - * rather than the SDK quietly requesting everything: same activity, same moment, two describes. - */ + /** The count tracks heartbeats the server recorded. */ @Test(timeout = 60_000) - public void describePayloadFieldsAreOptIn() { + public void describeReportsTotalHeartbeatCount() { assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = startHeartbeatReadyActivity(); + ActivityHandle handle = + startRunningSlowActivity(slowOpts().setHeartbeatTimeout(Duration.ofSeconds(3))); - assertFalse(handle.describe().hasHeartbeatDetails()); - assertEquals(0, handle.describe().getHeartbeatDetails().getSize()); - assertTrue(handle.describe(WITH_HEARTBEAT_DETAILS).hasHeartbeatDetails()); - assertEquals( - "hb-details", - handle.describe(WITH_HEARTBEAT_DETAILS).getHeartbeatDetails().get(0, String.class)); + assertEventually( + Duration.ofSeconds(20), + () -> + assertTrue( + "total heartbeat count should reach 2", + handle.describe().getTotalHeartbeatCount() >= 2)); handle.terminate("cleanup"); } /** - * Input and outcome are opt-in like the other payload fields. Uses a two-argument activity so - * {@link ActivityExecutionDescription#getInput(int, Class)} has more than one argument to read. + * Every payload field on one description. The activity heartbeats, fails once, then succeeds, so + * a single execution carries input, a result, heartbeat details and a last failure at the same + * time. */ @Test(timeout = 60_000) - public void describeReadsInputAndOutcome() { + 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(TwoArgActivity.class, TwoArgActivity::run, opts, "ping", 7); - assertEquals("ping-7", handle.getResult(String.class)); + ActivityHandle handle = + newActivityClient() + .start( + HeartbeatFailIncrementActivity.class, HeartbeatFailIncrementActivity::run, opts, 1); + assertEquals(Integer.valueOf(2), handle.getResult(Integer.class)); - // Default describe omits both. + // Nothing requested: every payload field is absent. ActivityExecutionDescription bare = handle.describe(); assertFalse(bare.hasInput()); - assertEquals(0, bare.getInput().getSize()); assertFalse(bare.hasResult()); + assertFalse(bare.hasHeartbeatDetails()); + assertFalse(bare.hasLastFailure()); + assertFalse(bare.getResult(Integer.class).isPresent()); assertNull(bare.getOutcomeFailure()); + assertNull(bare.getLastFailure()); - ActivityExecutionDescription desc = + // 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(desc.hasInput()); - assertEquals(2, desc.getInput().getSize()); - assertEquals("ping", desc.getInput().get(0, String.class)); - assertEquals(Integer.valueOf(7), desc.getInput().get(1, Integer.class)); - assertTrue(desc.hasResult()); - assertEquals("ping-7", desc.getResult(String.class).orElse(null)); - // A successful outcome has no failure arm. - assertNull(desc.getOutcomeFailure()); - } - - /** 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"); - } - - /** The other arm of the outcome oneof: a terminally failed activity has a failure, no result. */ - @Test(timeout = 60_000) - public void describeReadsFailureOutcome() { - assumeTrue(SDKTestWorkflowRule.useExternalService); - StartActivityOptions opts = + 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()); + + // The other arm of the oneof, on an activity that never succeeds. + StartActivityOptions failOpts = StartActivityOptions.newBuilder() .setId(uniqueId()) .setTaskQueue(testWorkflowRule.getTaskQueue()) .setStartToCloseTimeout(Duration.ofSeconds(60)) .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(1).build()) .build(); - ActivityHandle handle = + ActivityHandle failed = newActivityClient() - .start(FailThenSucceedActivity.class, FailThenSucceedActivity::run, opts); - assertThrows(Exception.class, () -> handle.getResult(String.class)); + .start(FailThenSucceedActivity.class, FailThenSucceedActivity::run, failOpts); + assertThrows(Exception.class, () -> failed.getResult(String.class)); ActivityExecutionDescription desc = - handle.describe(DescribeActivityOptions.newBuilder().setIncludeOutcome(true).build()); + failed.describe( + DescribeActivityOptions.newBuilder() + .setIncludeOutcome(true) + .setIncludeLastFailure(true) + .build()); assertFalse(desc.hasResult()); assertFalse(desc.getResult(String.class).isPresent()); - - RuntimeException failure = desc.getOutcomeFailure(); - assertNotNull(failure); - assertTrue(failure instanceof ApplicationFailure); - assertEquals("retryable failure", ((ApplicationFailure) failure).getOriginalMessage()); + assertTrue(desc.getOutcomeFailure() instanceof ApplicationFailure); } @Test(timeout = 60_000) From baef99ad187175fb3279d083b5b7db7d0dd6c7e6 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 16:30:42 -0400 Subject: [PATCH 33/53] Unit-test that the describe opt-ins reach the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four api#792 flags were covered only functionally, so nothing proved the SDK actually sets them on DescribeActivityExecutionRequest — a default-on bug would have looked identical from observable state. Three cases against a stubbed client: defaults ask for nothing, all four are forwarded, and asking for one does not set the others. Brings Java level with Python and Go. --- .../ActivityHandleOperatorCommandsTest.java | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) 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 935905f021..f0f7d62ac0 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 @@ -1,6 +1,7 @@ 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; @@ -9,12 +10,16 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import io.temporal.api.activity.v1.ActivityExecutionInfo; +import io.temporal.api.workflowservice.v1.DescribeActivityExecutionRequest; +import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; import io.temporal.api.workflowservice.v1.ResetActivityExecutionRequest; 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.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; @@ -149,6 +154,72 @@ public void updateOptionsAcceptsASingleOption() { captureUpdate().getUpdateMask().getPathsList()); } + /** + * The four api#792 opt-ins are invisible in any observable server state, so only the outgoing + * request shows whether the SDK asked for them. + */ + @Test + public void describeOptInsReachTheRequest() { + when(genericClient.describeActivity(any())) + .thenReturn( + DescribeActivityExecutionResponse.newBuilder() + .setInfo(ActivityExecutionInfo.newBuilder().setActivityId("act-1")) + .build()); + + newHandle().describe(); + DescribeActivityExecutionRequest bare = captureDescribe(); + assertFalse("default should not request input", bare.getIncludeInput()); + assertFalse("default should not request outcome", bare.getIncludeOutcome()); + assertFalse("default should not request heartbeat details", bare.getIncludeHeartbeatDetails()); + assertFalse("default should not request last failure", bare.getIncludeLastFailure()); + } + + @Test + public void describeOptInsAreForwardedAndIndependent() { + when(genericClient.describeActivity(any())) + .thenReturn( + DescribeActivityExecutionResponse.newBuilder() + .setInfo(ActivityExecutionInfo.newBuilder().setActivityId("act-1")) + .build()); + + newHandle() + .describe( + DescribeActivityOptions.newBuilder() + .setIncludeInput(true) + .setIncludeOutcome(true) + .setIncludeHeartbeatDetails(true) + .setIncludeLastFailure(true) + .build()); + DescribeActivityExecutionRequest all = captureDescribe(); + assertTrue(all.getIncludeInput()); + assertTrue(all.getIncludeOutcome()); + assertTrue(all.getIncludeHeartbeatDetails()); + assertTrue(all.getIncludeLastFailure()); + } + + @Test + public void describeOptInSetsOnlyTheRequestedFlag() { + when(genericClient.describeActivity(any())) + .thenReturn( + DescribeActivityExecutionResponse.newBuilder() + .setInfo(ActivityExecutionInfo.newBuilder().setActivityId("act-1")) + .build()); + + newHandle().describe(DescribeActivityOptions.newBuilder().setIncludeInput(true).build()); + DescribeActivityExecutionRequest one = captureDescribe(); + assertTrue(one.getIncludeInput()); + assertFalse(one.getIncludeOutcome()); + assertFalse(one.getIncludeHeartbeatDetails()); + assertFalse(one.getIncludeLastFailure()); + } + + private DescribeActivityExecutionRequest captureDescribe() { + ArgumentCaptor captor = + ArgumentCaptor.forClass(DescribeActivityExecutionRequest.class); + verify(genericClient).describeActivity(captor.capture()); + return captor.getValue(); + } + private ResetActivityExecutionRequest captureReset() { ArgumentCaptor captor = ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); From 743ef905dd2503621f2e8c09e7d7ceb881c938c1 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 16:32:39 -0400 Subject: [PATCH 34/53] Unit-test that unrequested payloads are stripped client-side The stripping in RootActivityClientInvoker existed with no coverage. It only matters against a server that ignores the opt-ins, which no functional test can produce, so it needs a stub that returns every payload field regardless of what was asked for. Three cases: nothing requested strips all four, everything requested keeps all four, and stripping is per field. --- .../ActivityHandleOperatorCommandsTest.java | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) 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 f0f7d62ac0..99735f63c2 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 @@ -11,6 +11,10 @@ import static org.mockito.Mockito.when; import io.temporal.api.activity.v1.ActivityExecutionInfo; +import io.temporal.api.activity.v1.ActivityExecutionOutcome; +import io.temporal.api.common.v1.Payload; +import io.temporal.api.common.v1.Payloads; +import io.temporal.api.failure.v1.Failure; import io.temporal.api.workflowservice.v1.DescribeActivityExecutionRequest; import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; @@ -19,6 +23,7 @@ import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsRequest; import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsResponse; import io.temporal.client.ActivityClientOptions; +import io.temporal.client.ActivityExecutionDescription; import io.temporal.client.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; @@ -213,6 +218,70 @@ public void describeOptInSetsOnlyTheRequestedFlag() { assertFalse(one.getIncludeLastFailure()); } + /** + * A server that ignores the opt-ins must not be able to make the description's has* accessors + * disagree with what the caller asked for. Only a stub can produce that response. + */ + @Test + public void unrequestedPayloadsAreStripped() { + when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); + + ActivityExecutionDescription bare = newHandle().describe(); + assertFalse("input should be stripped", bare.hasInput()); + assertFalse("outcome should be stripped", bare.hasResult()); + assertFalse("heartbeat details should be stripped", bare.hasHeartbeatDetails()); + assertFalse("last failure should be stripped", bare.hasLastFailure()); + } + + @Test + public void requestedPayloadsAreKept() { + when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); + + ActivityExecutionDescription full = + newHandle() + .describe( + DescribeActivityOptions.newBuilder() + .setIncludeInput(true) + .setIncludeOutcome(true) + .setIncludeHeartbeatDetails(true) + .setIncludeLastFailure(true) + .build()); + assertTrue(full.hasInput()); + assertTrue(full.hasResult()); + assertTrue(full.hasHeartbeatDetails()); + assertTrue(full.hasLastFailure()); + } + + @Test + public void strippingIsPerField() { + when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); + + ActivityExecutionDescription desc = + newHandle().describe(DescribeActivityOptions.newBuilder().setIncludeInput(true).build()); + assertTrue("input was requested", desc.hasInput()); + assertFalse("outcome was not requested", desc.hasResult()); + assertFalse("heartbeat details were not requested", desc.hasHeartbeatDetails()); + assertFalse("last failure was not requested", desc.hasLastFailure()); + } + + /** A response carrying every payload field, as an older or buggy server might send. */ + private static DescribeActivityExecutionResponse overSharingResponse() { + Payloads payloads = + Payloads.newBuilder() + .addPayloads( + Payload.newBuilder().setData(com.google.protobuf.ByteString.copyFromUtf8("x"))) + .build(); + return DescribeActivityExecutionResponse.newBuilder() + .setInfo( + ActivityExecutionInfo.newBuilder() + .setActivityId("act-1") + .setHeartbeatDetails(payloads) + .setLastFailure(Failure.newBuilder().setMessage("boom"))) + .setInput(payloads) + .setOutcome(ActivityExecutionOutcome.newBuilder().setResult(payloads)) + .build(); + } + private DescribeActivityExecutionRequest captureDescribe() { ArgumentCaptor captor = ArgumentCaptor.forClass(DescribeActivityExecutionRequest.class); From c69c9a44b96eb086ff8b6bd3c09f085b37f40f27 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 16:38:51 -0400 Subject: [PATCH 35/53] Remove updateOptionsAcceptsASingleOption as redundant It asserted that a single set option produces a mask naming exactly that option. The mask tests in every SDK already assert the mask names exactly what changed, and a one-option case catches nothing the multi-option case misses. Java was the only SDK with it, so removing it is also parity rather than porting three near-duplicates. --- .../ActivityHandleOperatorCommandsTest.java | 15 --------------- 1 file changed, 15 deletions(-) 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 99735f63c2..2e96fedd09 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 @@ -144,21 +144,6 @@ public void updateOptionsRequiresAtLeastOneOption() { verifyNoInteractions(genericClient); } - /** A single set option is enough; the mask names exactly it. */ - @Test - public void updateOptionsAcceptsASingleOption() { - when(genericClient.updateActivityOptions(any())) - .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); - - newHandle() - .updateOptions( - UpdateActivityOptions.newBuilder().setHeartbeatTimeout(Duration.ofSeconds(25)).build()); - - assertEquals( - java.util.Collections.singletonList("heartbeat_timeout"), - captureUpdate().getUpdateMask().getPathsList()); - } - /** * The four api#792 opt-ins are invisible in any observable server state, so only the outgoing * request shows whether the SDK asked for them. From f24d4e62925d11045d874e2ed19218c8211cd08e Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 26 Aug 2026 16:41:13 -0400 Subject: [PATCH 36/53] Test that restore-original routes through the update interceptor RestoreOriginalOptions reuses the update-options interceptor rather than having one of its own, distinguished purely by the restore flag with an empty mask. An interceptor watching option updates would otherwise silently miss restores, and nothing pinned that. Ported from the Python interceptor suite, which was the only one asserting it. --- .../ActivityHandleOperatorCommandsTest.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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 2e96fedd09..caf13bb42e 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 @@ -267,6 +267,24 @@ private static DescribeActivityExecutionResponse overSharingResponse() { .build(); } + /** + * restoreOriginalOptions reuses the updateActivityOptions call rather than having one of its own, + * distinguished purely by restore_original with an empty mask. An interceptor watching option + * updates would otherwise silently miss restores. + */ + @Test + public void restoreOriginalOptionsRoutesThroughUpdate() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle().restoreOriginalOptions(); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + assertTrue("restore should set restore_original", req.getRestoreOriginal()); + assertTrue( + "restore should name no paths in the mask", req.getUpdateMask().getPathsList().isEmpty()); + } + private DescribeActivityExecutionRequest captureDescribe() { ArgumentCaptor captor = ArgumentCaptor.forClass(DescribeActivityExecutionRequest.class); From c3831d067709cebf11418ed5f9ecac68f87782ad Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 27 Aug 2026 12:13:53 -0400 Subject: [PATCH 37/53] remove non-pr files --- md-review-all-at-once.md | 93 ------------------------ oprd | 63 ---------------- pr-desc.md | 150 --------------------------------------- run-all-tests.sh | 12 ---- run-tests.sh | 21 ------ squash-message.txt | 11 --- 6 files changed, 350 deletions(-) delete mode 100644 md-review-all-at-once.md delete mode 100644 oprd delete mode 100644 pr-desc.md delete mode 100755 run-all-tests.sh delete mode 100755 run-tests.sh delete mode 100644 squash-message.txt diff --git a/md-review-all-at-once.md b/md-review-all-at-once.md deleted file mode 100644 index 61d8be1e61..0000000000 --- a/md-review-all-at-once.md +++ /dev/null @@ -1,93 +0,0 @@ -# Maciej's review of sdk-java#3013 — all fixes, applied at once - -Record of the 14 inline review comments on -https://github.com/temporalio/sdk-java/pull/3013 and the change each one drove. -13 were actionable; one ("lol") needed nothing. - -The Java fixes were applied together on branch `oc-md-review-all`, and are being -re-applied one commit per comment on `oc-md-review-pieces`. - ---- - -## Java - -**Comment A** — *`ActivityExecutionDescription.java:32`:* "`info` field is now redundant, remove it and change methods to use `response.getInfo()`" - -Dropped `private final ActivityExecutionInfo info;`; all ~20 accessors now read `response.getInfo()`. - -**Comment B** — *`ActivityExecutionDescription.java:57`:* "We should add `ActivitySerializationContext` to `dataConverter` here." - -Constructor now stores `dataConverter.withContext(new ActivitySerializationContext(namespace, null, null, getActivityType(), getTaskQueue(), false))`. This also let the `namespace` field go, and removed the ad-hoc `withContext(...)` that `getStaticSummary`/`getStaticDetails` were each building inline. - -**Comment C** — *`ActivityExecutionDescription.java:289`:* "`getInput` should have only one 0-arg overload that returns `EncodedValues`. After that change, we should also remove `getInputCount`. While we're at it, I think we should change `getHeartbeatDetails` to return `EncodedValues` too." - -Five `getInput` overloads plus `getInputCount` collapse to `EncodedValues getInput()`. Both `getHeartbeatDetails` overloads collapse to `EncodedValues getHeartbeatDetails()`. - -**Comment D** — *`ActivityExecutionDescription.java:350`:* "We should match what we do in `ActivityClient.startActivity`." (suggested `return getResult(valueType, null);`) - -Applied; the two-arg form takes `@Nullable Type` and normalizes null to `valueType`, the same way `decodeOutcome` does. - -**Comment E** — *`ActivityExecutionDescription.java:340`:* "Protobuf getters are null-coalescing. Fix other similar lines too." (suggested `return response.getOutcome().hasResult();`) - -Applied to `hasResult()`; the "other similar lines" were the `!response.hasOutcome() ||` guard in the outcome-failure getter and the ternary in the deleted `getInputCount`. - -**Comment F** — *`ActivityExecutionDescription.java:376`:* "I'd rename this method to `getOutcomeFailure` or something to better differentiate it from `getLastFailure`. We should also change return type of both this and `getLastFailure` to `RuntimeException`." - -`getFailure` → `getOutcomeFailure`; both it and `getLastFailure` now return `RuntimeException` (free — `DataConverter.failureToException` already returns that). - -**Comment G** — *`ActivityExecutionOptions.java:17`:* "`UpdateActivityExecutionOptionsRequest` and `UpdateActivityOptionsResponse` use the same type to store options, which means the available fields will always match. Instead of adding yet another class, we can return `UpdateActivityOptions`." - -Deleted `ActivityExecutionOptions.java`; `updateOptions` returns `UpdateActivityOptions`. - -**Comment H** — *`UpdateActivityOptions.java:19`:* "Consider alternative design: `UpdateActivityOptions` does not have `restoreOriginal` field. Instead, `ActivityHandle` has an additional method `restoreOriginalOptions`." - -Removed the field, setter, getter, and both `Preconditions` checks; added `restoreOriginalOptions()` to `UntypedActivityHandle` and both impls. Dropped the two tests that asserted the now-gone validation. - -**Comment I** — *`ActivityClientCallsInterceptor.java:424` and `:464`* (two suggestions): `private final UnpauseActivityOptions options;` / `private final ResetActivityOptions options;` - -Both inputs now carry the options object instead of exploded fields; the invoker reads `input.getOptions()`. - -**Comment J** — *`ActivityClientCallsInterceptor.java:556`:* "`UpdateActivityOptionsOutput` should have the final options object, not Proto object. The conversion should happen inside the root interceptor." - -Output holds `UpdateActivityOptions`; conversion moved from `ActivityHandleImpl.fromProto` into `RootActivityClientInvoker.toUpdateActivityOptions`. - -**Comment K** — *`RootActivityClientInvoker.java:349`:* "We should remove payload fields that were not requested (to support older/buggy servers)." - -New `stripUnrequestedPayloads` clears `input`/`outcome` on the response and `heartbeat_details`/`last_failure` on `info` when the corresponding flag was false. - -**Comment L** — *`UntypedActivityHandle.java:135`:* "I think `DescribeActivityOptions.java` file is missing." - -No code change — the file was committed locally but the branch had unpushed commits. Resolved by pushing. - -**Comment M** — *`ActivityHandleOperatorCommandsTest.java:49`:* "lol" — no action. - ---- - -## Ruby counterparts - -| Ruby change | Inspired by | -|---|---| -| `update_options` loses the `restore_original:` kwarg and both `ArgumentError` guards; new `ActivityHandle#restore_original_options(rpc_options:)` | **Comment H** (restoreOriginal → own method) | -| `implementation.rb#update_activity_options` returns `ActivityExecutionOptions._from_proto(resp.activity_options)` instead of the raw proto; `activity_handle.rb` no longer converts | **Comment J** (output holds final options, convert in root interceptor) | -| `describe_activity` clears `resp.input`, `resp.outcome`, `resp.info.heartbeat_details`, `resp.info.last_failure` when not requested | **Comment K** (strip un-requested payloads) | -| `Description#failure` → `#outcome_failure` | **Comment F** (rename to differentiate from `last_failure`) | -| RBS/RBI updated for both renames and the new method; tests updated, two obsolete validation tests deleted | consequences of **F** and **H** | - -Four Java comments have no Ruby counterpart, deliberately: - -- **Comment C (EncodedValues)** — Ruby has no `Values` type, and `input(hints:)` / `heartbeat_details(hints:)` already return the whole array, which is the shape the Java change moves toward. -- **Comment E (null-coalescing)** — Ruby's protobuf returns `nil` for unset message fields rather than a default instance, so `@raw_description.outcome&.value` is required, not redundant. -- **Comment B (serialization context)** — Ruby's `DataConverter` has no context mechanism; nothing to attach. -- **Comments A, D, G, I** — no Ruby analogue: Ruby's `Description` stores only `@raw_description`, hints are a single value, `ActivityExecutionOptions` isn't redundant there (Ruby's update input is kwargs, not a class), and Ruby's interceptor inputs have no options objects to hold. - ---- - -## Verification - -Java: 40 tests (17 operator-commands, 14 description unit, 8 interceptor, 1 handle-build), 0 failures, spotless clean. -Ruby: 17 + 1 + 1 runs across the three operator-command files, 411 assertions, 0 failures; steep clean, RuboCop clean across 191 files. - -## Notes - -- Removing `restoreOriginal` also removed the "at least one option must be set" guard, since `UpdateActivityOptions` is now both an input and a return type and that check would break the return direction. An empty update now reaches the server rather than failing locally. -- `UpdateActivityOptions` as a return type carries builder-validation semantics it does not need; it works, but a reviewer may notice the dual role. diff --git a/oprd b/oprd deleted file mode 100644 index 5e33196500..0000000000 --- a/oprd +++ /dev/null @@ -1,63 +0,0 @@ -# Standalone Activity operator commands: Pause, Unpause, Reset, UpdateOptions - -## Summary - -Adds client-side operator commands for standalone activities to the activity handle: -**Pause**, **Unpause**, **Reset**, and **UpdateOptions**. Each is a thin client call that -builds the corresponding `WorkflowService` request and dispatches it through the activity -client interceptor chain, mirroring the existing `cancel`/`terminate` operations on the -handle. All new surface is annotated `@Experimental`. - -## What's added - -### Handle methods (`ActivityHandle` / `UntypedActivityHandle`) -- `pause(String reason)` (and a no-arg `pause()`). -- `unpause(UnpauseActivityOptions)` (and a no-arg `unpause()`). -- `reset(ResetActivityOptions)` (and a no-arg `reset()`). -- `updateOptions(UpdateActivityOptions)` returning the updated `ActivityExecutionOptions`. - -### Option classes -- **`UnpauseActivityOptions`** — `reason`, `resetAttempts`, `resetHeartbeat`, `jitter`. -- **`ResetActivityOptions`** — `resetHeartbeat`, `keepPaused`, `jitter`, - `restoreOriginalOptions`. -- **`UpdateActivityOptions`** — `taskQueue`, `scheduleToCloseTimeout`, - `scheduleToStartTimeout`, `startToCloseTimeout`, `heartbeatTimeout`, `retryOptions`, - `priority`, and `restoreOriginal`. Only the fields explicitly set are sent (a derived - field mask drives the partial update). `build()` validates that `restoreOriginal` is not - combined with any other field. -- **`ActivityExecutionOptions`** — the value object returned by `updateOptions`, reflecting - the activity's options after the update. - -### Interceptor surface (`ActivityClientCallsInterceptor`) -- New methods `pauseActivity`, `unpauseActivity`, `resetActivity`, `updateActivityOptions`, - each with its own `Input` and `Output` class (the `Output` classes follow the existing - empty-placeholder convention). `ActivityClientCallsInterceptorBase` provides pass-through - defaults so existing interceptors keep compiling. - -### Plumbing -- `RootActivityClientInvoker` builds the four requests (setting the dedup `request_id` only - on `pause`, matching `cancel`/`terminate` and the proto), and the internal - `ActivityHandleImpl` wires the handle through the interceptor chain. -- `GenericWorkflowClient` / `GenericWorkflowClientImpl` gain the four service calls. - -## Testing - -- **Functional suite** (`StandaloneActivityOperatorCommandsTest`) — gated on - `SDKTestWorkflowRule.useExternalService` and run against a real dev server, since the - embedded test server does not implement the standalone-activity operator APIs. Covers each - command's observable server-state change: pause→paused, unpause→resumes, reset→attempt - reset to 1, updateOptions partial-mask + all-fields (including `task_queue`), the secondary - flags (`resetAttempts`, `resetHeartbeat`, `keepPaused`, `restoreOriginal`/ - `restoreOriginalOptions`), the `restoreOriginal` exclusivity validation, and an interceptor - flow-through test exercising all four commands. -- **No-server unit test** (`ActivityHandleOperatorCommandsTest`) — asserts the request fields - the server does not surface back (`reason`, `jitter`, the pause `request_id`) are built - correctly, via a mocked `GenericWorkflowClient` with `ArgumentCaptor`. - -## Dependency / merge ordering - -This depends on **server-side standalone-activity operator command support** -(server PR temporalio/temporal#10106), which is **not yet merged**. The functional tests -require a dev server that implements these RPCs. **This SDK PR should not merge until that -server work lands and ships in a released dev server**; until then the functional suite only -passes against a locally built PR server. diff --git a/pr-desc.md b/pr-desc.md deleted file mode 100644 index bf1a283cea..0000000000 --- a/pr-desc.md +++ /dev/null @@ -1,150 +0,0 @@ -# Add operator commands for standalone activities - -Adds pause, unpause, reset, and update-options to standalone activities, plus -the describe surface needed to observe their effects. - -Standalone activities already supported start, result, describe, cancel, and -terminate. This adds the four operator commands the server exposes for them, so -an operator can hold, resume, restart, and retune a running activity without -going through a workflow. - -## API - -On `ActivityHandle` / `UntypedActivityHandle`: - -```java -void pause(); -void pause(@Nullable String reason); -void unpause(); -void unpause(UnpauseActivityOptions options); -void reset(); -void reset(ResetActivityOptions options); -ActivityExecutionOptions updateOptions(UpdateActivityOptions options); -ActivityExecutionDescription describe(DescribeActivityOptions options); -``` - -New option types, all following the SDK's builder convention with -`newBuilder()`, `getDefaultInstance()`, `toBuilder()`, and value equality: - -- `UnpauseActivityOptions` — reason, jitter. -- `ResetActivityOptions` — keep-paused, jitter, restore-original-options, - reset-heartbeat. -- `UpdateActivityOptions` — task queue, the four timeouts, retry policy, - priority, start delay, restore-original. -- `ActivityExecutionOptions` — the server's post-update view, returned by - `updateOptions`. -- `DescribeActivityOptions` — the four payload opt-ins described below. - -`updateOptions` derives a field mask from exactly the options set on the -builder, so unset fields are left untouched server-side. - -## Describe: payload fields are opt-in - -`DescribeActivityExecutionRequest` gates four payload-bearing fields behind -per-call flags (api#792). All four are now plumbed through -`DescribeActivityOptions` and **default to false**, matching Rust's -`ActivityDescribeOptions`: - -```java -DescribeActivityOptions.newBuilder() - .setIncludeInput(true) - .setIncludeOutcome(true) - .setIncludeHeartbeatDetails(true) - .setIncludeLastFailure(true) - .build(); -``` - -This is a behavior change: `describe()` previously hard-coded -`includeHeartbeatDetails` and `includeLastFailure` to true. Callers that read -heartbeat details or the last failure must now ask for them. The rationale is -the one the proto gives — these fields carry arbitrarily large payloads and -shouldn't be fetched unless needed. - -`ActivityExecutionDescription` gained accessors for the newly reachable data: - -- `hasInput()`, `getInput(int, Class)`, `getInput(int, Class, Type)`, - `getInput(Class)`, `getInput(Class, Type)`, `getInputCount()` -- `hasResult()`, `getResult(Class)`, `getResult(Class, Type)`, `getFailure()` -- `hasLastFailure()`, `getStartDelay()`, `getExecutionTime()`, `getRawResponse()` - -The outcome is a result-or-failure oneof; it's flattened into `hasResult` / -`getResult` / `getFailure` rather than exposed as an outcome object, and none of -them throw — this follows `NexusOperationExecutionDescription`, which solves the -same problem for the same reason. `getFailure()` is the terminal outcome; -`getLastFailure()` remains the most recent attempt's failure and may be set -while the activity is still retrying. - -Activity input is a payload *list*, unlike a Nexus operation's single payload, -which is why the input accessors are indexed while the Nexus ones aren't. - -## Compatibility - -The one behavior change is `describe()` no longer returning heartbeat details -and the last failure unless asked, described above. - -`ActivityExecutionDescription`'s constructor signature changes from -`ActivityExecutionInfo` to `DescribeActivityExecutionResponse`. The `input` and -`outcome` fields live on the response, not on `info`, so the info alone can't -back the new accessors. This also brings the class in line with its siblings — -`WorkflowExecutionDescription` and `NexusOperationExecutionDescription` both -already take their full response. - -In practice this constructor is internal: the only callers in the tree are -`RootActivityClientInvoker` and a unit test. It is public only because the -invoker lives in `io.temporal.internal.client` and Java has no internal -visibility. The one way a user could be affected is an interceptor that -synthesizes a `DescribeActivityOutput` rather than delegating to `next`. - -Note that `ActivityExecutionMetadata.getScheduledTime()` is deliberately left -alone. It is the odd spelling out among the SDKs — the proto field is -`schedule_time`, and Ruby and Rust both expose `schedule_time` — but it has -shipped in every release since v1.35.0, and renaming it would be a compile break -bought for nothing this PR needs. Worth doing separately, most likely as a -deprecate-and-delegate rather than a rename. - -## Interceptors - -`ActivityClientCallsInterceptor` gains `pauseActivity`, `unpauseActivity`, -`resetActivity`, and `updateActivityOptions`, each with an `*Input`/`*Output` -pair, and `DescribeActivityInput` now carries the describe options. -`ActivityClientCallsInterceptorBase` picks up matching pass-through overrides -and a class-level `@Experimental` it was previously missing. - -## Tests - -- `StandaloneActivityOperatorCommandsTest` — functional coverage of each - command against a real server, each asserting an observable server-side state - change rather than just a successful RPC. Includes update-options on a paused - activity, describe reporting `PAUSED` for an activity paused while scheduled, - the heartbeat-preservation behavior of each command, and a test that describe's - payload fields really are off by default. -- `ActivityExecutionDescriptionTest` — unit coverage of the new accessors, - including both arms of the outcome oneof and multi-argument input. -- `ActivityHandleOperatorCommandsTest` — unit coverage that each command builds - the right request. -- `ActivityClientCallsInterceptorBaseTest` — pass-through coverage. - -Functional tests are gated on `SDKTestWorkflowRule.useExternalService`; the -embedded test server doesn't implement the standalone-activity APIs. - -## Notes for reviewers - -- `getInputs(Class[], Type[])` returning `Object[]` is deliberate, and worth - a look. It's the only user-facing `Object[]`-returning decode accessor in the - SDK, and it coexists with the indexed `getInput(int, ...)`, so it is partly - redundant. The SDK's existing abstraction for a typed argument list is - `Values`/`EncodedValues` (what `DynamicActivity` receives); using it here was - considered and not taken. -- `retry_state` on `ActivityExecutionOutcome` (api#843, server-populated since - temporal#11321) is reachable via `getRawResponse()` but has no typed accessor - yet. - -## Upstream dependencies - -Requires a server with the standalone-activity operator-command APIs enabled -(`frontend.activityAPIsEnabled`). Relevant API changes already merged and -reflected here: api#792 (describe opt-ins), api#834 (`PAUSED` status), api#844 -(request IDs), api#846 (removed `reset_attempts`/`reset_heartbeat` from -Unpause), api#848 (`reset_heartbeat` back on Reset, paired with -temporal#11417), api#807 (`execution_time`), api#804 / temporal#10745 -(`start_delay` on update-options). diff --git a/run-all-tests.sh b/run-all-tests.sh deleted file mode 100755 index ad1003beb9..0000000000 --- a/run-all-tests.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -# Runs the full Java build (compile, spotless, tests) the way CI does, against the CLI release -# pinned in SdkJavaTestServerProfile.TEST_CLI_VERSION. No local server involved. -# -# NOTE: not added to git (per working conventions). -set -euo pipefail - -WT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$WT" - -./gradlew prepareDevServerTests -./gradlew --offline build -PtestServer=dev-server diff --git a/run-tests.sh b/run-tests.sh deleted file mode 100755 index ca9bfb11ba..0000000000 --- a/run-tests.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash -# Runs the SAA operator-command tests on gmt/operator-commands. -# -# No server setup: `-PtestServer=dev-server` makes gradle download the CLI release pinned in -# SdkJavaTestServerProfile.TEST_CLI_VERSION, start it, and set USE_EXTERNAL_SERVICE=true — the -# same path CI's "Unit test with CLI" job takes. Do not point this at a hand-built server; the -# whole point is that local runs and CI exercise the identical binary. -# -# NOTE: not added to git (per working conventions). -set -euo pipefail - -WT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$WT" - -./gradlew prepareDevServerTests -./gradlew --offline :temporal-sdk:test -PtestServer=dev-server \ - --tests '*StandaloneActivityOperatorCommandsTest' \ - --tests '*StandaloneActivityTest' \ - --tests '*ActivityExecutionDescriptionTest' \ - --tests '*ActivityHandleOperatorCommandsTest' \ - --tests '*ActivityClientCallsInterceptorBaseTest' diff --git a/squash-message.txt b/squash-message.txt deleted file mode 100644 index 907c4d24bb..0000000000 --- a/squash-message.txt +++ /dev/null @@ -1,11 +0,0 @@ -- ActivityExecutionDescription: drop the redundant `info` field and read `response.getInfo()` throughout. -- ActivityExecutionDescription: attach ActivitySerializationContext to the data converter once in the constructor, instead of rebuilding it on every user-metadata read. -- ActivityExecutionDescription: drop parent-presence guards that protobuf's null-coalescing getters make redundant. -- ActivityExecutionDescription: getResult(Class) now passes a null generic type, matching ActivityClient.startActivity; the two-arg overload accepts null and normalizes it (previously it threw). -- ActivityExecutionDescription: rename getFailure to getOutcomeFailure to distinguish the terminal outcome from getLastFailure; both now return RuntimeException. -- ActivityExecutionDescription: getInput() and getHeartbeatDetails() return EncodedValues; getInputCount() and the typed overloads are gone. BREAKING: getHeartbeatDetails shipped in v1.35.0-v1.38.0. -- ActivityClientCallsInterceptor: UnpauseActivityInput and ResetActivityInput carry the options object rather than exploded fields. -- RootActivityClientInvoker: clear payload fields the caller did not request, so an older or buggy server cannot make the description's has* accessors disagree with what was asked for. -- Delete ActivityExecutionOptions and return UpdateActivityOptions from updateOptions; the update request and response share one proto options type, so the field sets cannot diverge. -- UpdateActivityOptionsOutput holds the final options object; the proto-to-options conversion moved into the root interceptor, so interceptors see the public type rather than the wire type. -- Move restoreOriginal off UpdateActivityOptions into ActivityHandle.restoreOriginalOptions(), removing a builder state the server rejects outright. This also drops the "at least one option must be set" guard, which no longer holds now that the type serves as both request and response. From b4999344894ef0ad0c57f72fe3aa55d5f60fe9c6 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 27 Aug 2026 14:17:32 -0400 Subject: [PATCH 38/53] cleanup --- .../StandaloneActivityOperatorCommandsTest.java | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) 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 index 9a828c13e4..ed5452ab64 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -124,9 +124,7 @@ public String run() { } /** - * Heartbeats, fails the first attempt, then succeeds. One execution of this carries input, a - * result, heartbeat details and a last failure all at once, which is what lets a single describe - * exercise every payload field. + * Heartbeats, fails the first attempt, then succeeds. */ @ActivityInterface public interface HeartbeatFailIncrementActivity { @@ -609,11 +607,6 @@ public void describeReportsTotalHeartbeatCount() { handle.terminate("cleanup"); } - /** - * Every payload field on one description. The activity heartbeats, fails once, then succeeds, so - * a single execution carries input, a result, heartbeat details and a last failure at the same - * time. - */ @Test(timeout = 60_000) public void describePayloads() { assumeTrue(SDKTestWorkflowRule.useExternalService); @@ -661,7 +654,6 @@ public void describePayloads() { assertTrue(full.hasLastFailure()); assertNotNull(full.getLastFailure()); - // The other arm of the oneof, on an activity that never succeeds. StartActivityOptions failOpts = StartActivityOptions.newBuilder() .setId(uniqueId()) From e3404b3790a4844a0c8c32680a63d3bd612b12b6 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 27 Aug 2026 14:28:53 -0400 Subject: [PATCH 39/53] remove describeOptInsReachTheRequest tests --- ...tandaloneActivityOperatorCommandsTest.java | 4 +- .../ActivityHandleOperatorCommandsTest.java | 67 ------------------- 2 files changed, 1 insertion(+), 70 deletions(-) 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 index ed5452ab64..e186290094 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -123,9 +123,7 @@ public String run() { } } - /** - * Heartbeats, fails the first attempt, then succeeds. - */ + /** Heartbeats, fails the first attempt, then succeeds. */ @ActivityInterface public interface HeartbeatFailIncrementActivity { @ActivityMethod(name = "HeartbeatFailIncrement") 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 caf13bb42e..0fc2013a37 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 @@ -15,7 +15,6 @@ import io.temporal.api.common.v1.Payload; import io.temporal.api.common.v1.Payloads; import io.temporal.api.failure.v1.Failure; -import io.temporal.api.workflowservice.v1.DescribeActivityExecutionRequest; import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; import io.temporal.api.workflowservice.v1.ResetActivityExecutionRequest; @@ -144,65 +143,6 @@ public void updateOptionsRequiresAtLeastOneOption() { verifyNoInteractions(genericClient); } - /** - * The four api#792 opt-ins are invisible in any observable server state, so only the outgoing - * request shows whether the SDK asked for them. - */ - @Test - public void describeOptInsReachTheRequest() { - when(genericClient.describeActivity(any())) - .thenReturn( - DescribeActivityExecutionResponse.newBuilder() - .setInfo(ActivityExecutionInfo.newBuilder().setActivityId("act-1")) - .build()); - - newHandle().describe(); - DescribeActivityExecutionRequest bare = captureDescribe(); - assertFalse("default should not request input", bare.getIncludeInput()); - assertFalse("default should not request outcome", bare.getIncludeOutcome()); - assertFalse("default should not request heartbeat details", bare.getIncludeHeartbeatDetails()); - assertFalse("default should not request last failure", bare.getIncludeLastFailure()); - } - - @Test - public void describeOptInsAreForwardedAndIndependent() { - when(genericClient.describeActivity(any())) - .thenReturn( - DescribeActivityExecutionResponse.newBuilder() - .setInfo(ActivityExecutionInfo.newBuilder().setActivityId("act-1")) - .build()); - - newHandle() - .describe( - DescribeActivityOptions.newBuilder() - .setIncludeInput(true) - .setIncludeOutcome(true) - .setIncludeHeartbeatDetails(true) - .setIncludeLastFailure(true) - .build()); - DescribeActivityExecutionRequest all = captureDescribe(); - assertTrue(all.getIncludeInput()); - assertTrue(all.getIncludeOutcome()); - assertTrue(all.getIncludeHeartbeatDetails()); - assertTrue(all.getIncludeLastFailure()); - } - - @Test - public void describeOptInSetsOnlyTheRequestedFlag() { - when(genericClient.describeActivity(any())) - .thenReturn( - DescribeActivityExecutionResponse.newBuilder() - .setInfo(ActivityExecutionInfo.newBuilder().setActivityId("act-1")) - .build()); - - newHandle().describe(DescribeActivityOptions.newBuilder().setIncludeInput(true).build()); - DescribeActivityExecutionRequest one = captureDescribe(); - assertTrue(one.getIncludeInput()); - assertFalse(one.getIncludeOutcome()); - assertFalse(one.getIncludeHeartbeatDetails()); - assertFalse(one.getIncludeLastFailure()); - } - /** * A server that ignores the opt-ins must not be able to make the description's has* accessors * disagree with what the caller asked for. Only a stub can produce that response. @@ -285,13 +225,6 @@ public void restoreOriginalOptionsRoutesThroughUpdate() { "restore should name no paths in the mask", req.getUpdateMask().getPathsList().isEmpty()); } - private DescribeActivityExecutionRequest captureDescribe() { - ArgumentCaptor captor = - ArgumentCaptor.forClass(DescribeActivityExecutionRequest.class); - verify(genericClient).describeActivity(captor.capture()); - return captor.getValue(); - } - private ResetActivityExecutionRequest captureReset() { ArgumentCaptor captor = ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); From 2f293744ea4a40edc2e0ddb3211d843c9dbd2885 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 27 Aug 2026 14:34:09 -0400 Subject: [PATCH 40/53] cleanup --- .../internal/client/ActivityHandleOperatorCommandsTest.java | 4 ---- 1 file changed, 4 deletions(-) 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 0fc2013a37..f6a1c81424 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 @@ -143,10 +143,6 @@ public void updateOptionsRequiresAtLeastOneOption() { verifyNoInteractions(genericClient); } - /** - * A server that ignores the opt-ins must not be able to make the description's has* accessors - * disagree with what the caller asked for. Only a stub can produce that response. - */ @Test public void unrequestedPayloadsAreStripped() { when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); From 6de983c3e6ea5b394be11a4439413333058f3eb5 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 27 Aug 2026 15:04:29 -0400 Subject: [PATCH 41/53] remove test_restore_original_options_routes_through_update --- .../ActivityHandleOperatorCommandsTest.java | 18 ------------------ 1 file changed, 18 deletions(-) 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 f6a1c81424..c20ea05172 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 @@ -203,24 +203,6 @@ private static DescribeActivityExecutionResponse overSharingResponse() { .build(); } - /** - * restoreOriginalOptions reuses the updateActivityOptions call rather than having one of its own, - * distinguished purely by restore_original with an empty mask. An interceptor watching option - * updates would otherwise silently miss restores. - */ - @Test - public void restoreOriginalOptionsRoutesThroughUpdate() { - when(genericClient.updateActivityOptions(any())) - .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); - - newHandle().restoreOriginalOptions(); - - UpdateActivityExecutionOptionsRequest req = captureUpdate(); - assertTrue("restore should set restore_original", req.getRestoreOriginal()); - assertTrue( - "restore should name no paths in the mask", req.getUpdateMask().getPathsList().isEmpty()); - } - private ResetActivityExecutionRequest captureReset() { ArgumentCaptor captor = ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); From 9f0b15361d2c60c082dc5680993649d20883ad8a Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 27 Aug 2026 16:54:44 -0400 Subject: [PATCH 42/53] Test clearing an option with a zero duration Java clears the same way Go does, by passing a zero value rather than a dedicated sentinel: Duration.ZERO is non-null so the path reaches the mask, and the server normalizes a zero timeout back to unset. Null keeps the path out of the mask entirely. Neither behaviour was covered. Brings Java level with Go and Python. --- .../ActivityHandleOperatorCommandsTest.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) 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 c20ea05172..af8ea5f346 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 @@ -203,6 +203,47 @@ private static DescribeActivityExecutionResponse overSharingResponse() { .build(); } + /** + * A zero duration clears the option: the path is named in the mask so the server acts on it, and + * the server normalizes a zero timeout back to unset. Null means "do not touch" and keeps the + * path out of the mask entirely. + */ + @Test + public void zeroDurationClearsTheOption() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle() + .updateOptions( + UpdateActivityOptions.newBuilder().setHeartbeatTimeout(Duration.ZERO).build()); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + assertEquals( + java.util.Collections.singletonList("heartbeat_timeout"), + req.getUpdateMask().getPathsList()); + assertEquals(0, req.getActivityOptions().getHeartbeatTimeout().getSeconds()); + assertEquals(0, req.getActivityOptions().getHeartbeatTimeout().getNanos()); + } + + /** A null option is left alone: it never reaches the mask. */ + @Test + public void nullOptionIsNotTouched() { + when(genericClient.updateActivityOptions(any())) + .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); + + newHandle() + .updateOptions( + UpdateActivityOptions.newBuilder() + .setHeartbeatTimeout(null) + .setStartToCloseTimeout(Duration.ofSeconds(90)) + .build()); + + UpdateActivityExecutionOptionsRequest req = captureUpdate(); + assertEquals( + java.util.Collections.singletonList("start_to_close_timeout"), + req.getUpdateMask().getPathsList()); + } + private ResetActivityExecutionRequest captureReset() { ArgumentCaptor captor = ArgumentCaptor.forClass(ResetActivityExecutionRequest.class); From f2088044fae39b6f64b78fcd31ec5fe187417ded Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 28 Aug 2026 15:57:18 -0400 Subject: [PATCH 43/53] Model update activity options on sdk precedent --- ...ons.java => ActivityExecutionOptions.java} | 18 +-- .../temporal/client/ActivityHandleImpl.java | 6 +- .../client/UntypedActivityHandle.java | 16 ++- .../ActivityClientCallsInterceptor.java | 8 +- .../internal/client/ActivityHandleImpl.java | 110 ++++++++++-------- .../client/RootActivityClientInvoker.java | 4 +- ...tandaloneActivityOperatorCommandsTest.java | 67 +++++------ .../ActivityHandleOperatorCommandsTest.java | 83 +++++++++---- 8 files changed, 181 insertions(+), 131 deletions(-) rename temporal-sdk/src/main/java/io/temporal/client/{UpdateActivityOptions.java => ActivityExecutionOptions.java} (92%) diff --git a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java similarity index 92% rename from temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java rename to temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java index ba51df1fe5..4f8c214ae1 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UpdateActivityOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java @@ -8,19 +8,19 @@ import javax.annotation.Nullable; /** - * Options for {@link UntypedActivityHandle#updateOptions(UpdateActivityOptions)}. + * Options for {@link UntypedActivityHandle#updateOptions(ActivityExecutionOptions)}. * *

Only the fields that are explicitly set are sent to the server; a derived field mask ensures * that unset fields are left unchanged (a partial update). */ @Experimental -public final class UpdateActivityOptions { +public final class ActivityExecutionOptions { public static Builder newBuilder() { return new Builder(); } - public static Builder newBuilder(UpdateActivityOptions options) { + public static Builder newBuilder(ActivityExecutionOptions options) { return new Builder(options); } @@ -36,7 +36,7 @@ public static final class Builder { private Builder() {} - private Builder(UpdateActivityOptions options) { + private Builder(ActivityExecutionOptions options) { if (options == null) { return; } @@ -98,8 +98,8 @@ public Builder setStartDelay(@Nullable Duration startDelay) { return this; } - public UpdateActivityOptions build() { - return new UpdateActivityOptions(this); + public ActivityExecutionOptions build() { + return new ActivityExecutionOptions(this); } } @@ -112,7 +112,7 @@ public UpdateActivityOptions build() { private final @Nullable Priority priority; private final @Nullable Duration startDelay; - private UpdateActivityOptions(Builder builder) { + private ActivityExecutionOptions(Builder builder) { this.taskQueue = builder.taskQueue; this.scheduleToCloseTimeout = builder.scheduleToCloseTimeout; this.scheduleToStartTimeout = builder.scheduleToStartTimeout; @@ -171,7 +171,7 @@ public Duration getStartDelay() { public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - UpdateActivityOptions that = (UpdateActivityOptions) o; + ActivityExecutionOptions that = (ActivityExecutionOptions) o; return Objects.equals(taskQueue, that.taskQueue) && Objects.equals(scheduleToCloseTimeout, that.scheduleToCloseTimeout) && Objects.equals(scheduleToStartTimeout, that.scheduleToStartTimeout) @@ -197,7 +197,7 @@ public int hashCode() { @Override public String toString() { - return "UpdateActivityOptions{" + return "ActivityExecutionOptions{" + "taskQueue='" + taskQueue + "', scheduleToCloseTimeout=" 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 dd8864b60d..f73c344f6e 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityHandleImpl.java @@ -158,12 +158,12 @@ public void reset(ResetActivityOptions options) { } @Override - public UpdateActivityOptions updateOptions(UpdateActivityOptions options) { - return delegate.updateOptions(options); + public ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates) { + return delegate.updateOptions(updates); } @Override - public UpdateActivityOptions restoreOriginalOptions() { + public ActivityExecutionOptions restoreOriginalOptions() { return delegate.restoreOriginalOptions(); } } 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 6f07628e96..07e9a04af3 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java +++ b/temporal-sdk/src/main/java/io/temporal/client/UntypedActivityHandle.java @@ -190,19 +190,23 @@ CompletableFuture getResultAsync( void reset(ResetActivityOptions options); /** - * Updates the activity's options. Only the fields explicitly set in {@code options} are changed; - * a derived field mask leaves the rest untouched. To revert to the options the activity was - * created with, use {@link #restoreOriginalOptions()}. + * 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()}. * - * @param options the options to apply + *

Updates are created from the keys on {@link ActivityOptionsKeys}, via {@link + * ActivityOptionsKey#valueSet} to set an option or {@link ActivityOptionsKey#valueUnset} 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 */ - UpdateActivityOptions updateOptions(UpdateActivityOptions options); + 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 */ - UpdateActivityOptions restoreOriginalOptions(); + 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 9b167be3bf..a68b468bbf 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 @@ -6,13 +6,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.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; import io.temporal.client.UnpauseActivityOptions; -import io.temporal.client.UpdateActivityOptions; import io.temporal.common.Experimental; import java.lang.reflect.Type; import java.util.List; @@ -521,14 +521,14 @@ public boolean isRestoreOriginal() { @Experimental final class UpdateActivityOptionsOutput { - private final UpdateActivityOptions options; + private final ActivityExecutionOptions options; - public UpdateActivityOptionsOutput(UpdateActivityOptions options) { + public UpdateActivityOptionsOutput(ActivityExecutionOptions options) { this.options = options; } /** The activity options as resolved by the server after the update. */ - public UpdateActivityOptions getOptions() { + public ActivityExecutionOptions getOptions() { return options; } } 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 4557056d47..dea524d190 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 @@ -6,18 +6,24 @@ import io.temporal.api.activity.v1.ActivityOptions; import io.temporal.api.taskqueue.v1.TaskQueue; import io.temporal.client.ActivityExecutionDescription; +import io.temporal.client.ActivityExecutionOptions; +import io.temporal.client.ActivityOptionsKey; +import io.temporal.client.ActivityOptionsKeys; +import io.temporal.client.ActivityOptionsUpdate; import io.temporal.client.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; -import io.temporal.client.UpdateActivityOptions; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; import io.temporal.common.interceptors.ActivityClientCallsInterceptor; import io.temporal.internal.common.ProtoConverters; import io.temporal.internal.common.ProtobufTimeUtils; import java.lang.reflect.Type; -import java.util.ArrayList; -import java.util.List; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; @@ -186,56 +192,32 @@ public void reset(ResetActivityOptions options) { } @Override - public UpdateActivityOptions updateOptions(UpdateActivityOptions options) { + public ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates) { ActivityOptions.Builder activityOptions = ActivityOptions.newBuilder(); - List maskPaths = new ArrayList<>(); - - if (options.getTaskQueue() != null) { - activityOptions.setTaskQueue(TaskQueue.newBuilder().setName(options.getTaskQueue()).build()); - maskPaths.add("task_queue.name"); - } - if (options.getScheduleToCloseTimeout() != null) { - activityOptions.setScheduleToCloseTimeout( - ProtobufTimeUtils.toProtoDuration(options.getScheduleToCloseTimeout())); - maskPaths.add("schedule_to_close_timeout"); - } - if (options.getScheduleToStartTimeout() != null) { - activityOptions.setScheduleToStartTimeout( - ProtobufTimeUtils.toProtoDuration(options.getScheduleToStartTimeout())); - maskPaths.add("schedule_to_start_timeout"); - } - if (options.getStartToCloseTimeout() != null) { - activityOptions.setStartToCloseTimeout( - ProtobufTimeUtils.toProtoDuration(options.getStartToCloseTimeout())); - maskPaths.add("start_to_close_timeout"); - } - if (options.getHeartbeatTimeout() != null) { - activityOptions.setHeartbeatTimeout( - ProtobufTimeUtils.toProtoDuration(options.getHeartbeatTimeout())); - maskPaths.add("heartbeat_timeout"); - } - if (options.getRetryOptions() != null) { - activityOptions.setRetryPolicy(toRetryPolicy(options.getRetryOptions())); - maskPaths.add("retry_policy"); - } - if (options.getPriority() != null) { - activityOptions.setPriority(ProtoConverters.toProto(options.getPriority())); - maskPaths.add("priority"); - } - if (options.getStartDelay() != null) { - activityOptions.setStartDelay(ProtobufTimeUtils.toProtoDuration(options.getStartDelay())); - maskPaths.add("start_delay"); + // A repeated key resolves to its last update, so a later valueUnset overrides an earlier + // valueSet. Nothing else about ordering matters: the server reads the mask as a set, and each + // path writes a different field of ActivityOptions. + Map> byPath = new HashMap<>(); + for (ActivityOptionsUpdate update : updates) { + if (update != null) { + byPath.put(update.getKey().getName(), update); + } } // 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 (maskPaths.isEmpty()) { - throw new IllegalArgumentException( - "UpdateActivityOptions must set at least one option to update"); + if (byPath.isEmpty()) { + throw new IllegalArgumentException("updateOptions requires at least one option update"); + } + + for (ActivityOptionsUpdate update : byPath.values()) { + // An unset update names its path but leaves the field absent, which is how the server is + // told to clear the option. + update.getValue().ifPresent(value -> applyUpdate(activityOptions, update.getKey(), value)); } - FieldMask updateMask = FieldMask.newBuilder().addAllPaths(maskPaths).build(); + FieldMask updateMask = FieldMask.newBuilder().addAllPaths(byPath.keySet()).build(); ActivityClientCallsInterceptor.UpdateActivityOptionsOutput output = clientCallsInterceptor.updateActivityOptions( @@ -245,8 +227,44 @@ public UpdateActivityOptions updateOptions(UpdateActivityOptions options) { return output.getOptions(); } + /** + * Writes one option's value onto the request. The cast is safe because every key is created by + * {@link ActivityOptionsKeys} with the value type its path expects. + */ + private static void applyUpdate( + ActivityOptions.Builder options, ActivityOptionsKey key, Object value) { + switch (key.getName()) { + case "task_queue.name": + options.setTaskQueue(TaskQueue.newBuilder().setName((String) value).build()); + break; + case "schedule_to_close_timeout": + options.setScheduleToCloseTimeout(ProtobufTimeUtils.toProtoDuration((Duration) value)); + break; + case "schedule_to_start_timeout": + options.setScheduleToStartTimeout(ProtobufTimeUtils.toProtoDuration((Duration) value)); + break; + case "start_to_close_timeout": + options.setStartToCloseTimeout(ProtobufTimeUtils.toProtoDuration((Duration) value)); + break; + case "heartbeat_timeout": + options.setHeartbeatTimeout(ProtobufTimeUtils.toProtoDuration((Duration) value)); + break; + case "start_delay": + options.setStartDelay(ProtobufTimeUtils.toProtoDuration((Duration) value)); + break; + case "retry_policy": + options.setRetryPolicy(toRetryPolicy((RetryOptions) value)); + break; + case "priority": + options.setPriority(ProtoConverters.toProto((Priority) value)); + break; + default: + throw new IllegalArgumentException("Unknown activity option: " + key.getName()); + } + } + @Override - public UpdateActivityOptions restoreOriginalOptions() { + public ActivityExecutionOptions restoreOriginalOptions() { 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 cb5f3209c4..f672a68c1d 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 @@ -528,8 +528,8 @@ private static DescribeActivityExecutionResponse stripUnrequestedPayloads( } /** Converts the server's resolved activity options into the public options type. */ - private static UpdateActivityOptions toUpdateActivityOptions(ActivityOptions proto) { - UpdateActivityOptions.Builder builder = UpdateActivityOptions.newBuilder(); + private static ActivityExecutionOptions toUpdateActivityOptions(ActivityOptions proto) { + ActivityExecutionOptions.Builder builder = ActivityExecutionOptions.newBuilder(); if (proto.hasTaskQueue()) { builder.setTaskQueue(proto.getTaskQueue().getName()); } 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 index e186290094..0837570514 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -14,12 +14,13 @@ 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.ActivityOptionsKeys; import io.temporal.client.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.StartActivityOptions; -import io.temporal.client.UpdateActivityOptions; import io.temporal.common.CancellationToken; import io.temporal.common.Priority; import io.temporal.common.RetryOptions; @@ -341,11 +342,9 @@ public void updateOptionsRespectsMask() { .setStartToCloseTimeout(Duration.ofSeconds(45)) .setScheduleToCloseTimeout(Duration.ofSeconds(120))); - UpdateActivityOptions updated = + ActivityExecutionOptions updated = handle.updateOptions( - UpdateActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(90)) - .build()); + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); // Returned options: only start_to_close changed; schedule_to_close kept its original value. assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); @@ -379,23 +378,21 @@ public void updateOptionsAllFields() { ActivityHandle handle = newActivityClient().start(QuickActivity.class, QuickActivity::run, opts); - UpdateActivityOptions updated = + ActivityExecutionOptions updated = handle.updateOptions( - UpdateActivityOptions.newBuilder() - .setTaskQueue("updated-tq") - .setScheduleToCloseTimeout(Duration.ofSeconds(200)) - .setScheduleToStartTimeout(Duration.ofSeconds(15)) - .setStartToCloseTimeout(Duration.ofSeconds(90)) - .setHeartbeatTimeout(Duration.ofSeconds(25)) - .setRetryOptions( - RetryOptions.newBuilder() - .setInitialInterval(Duration.ofSeconds(1)) - .setBackoffCoefficient(2.0) - .setMaximumAttempts(7) - .build()) - .setPriority(Priority.newBuilder().setPriorityKey(3).build()) - .setStartDelay(Duration.ofSeconds(500)) - .build()); + ActivityOptionsKeys.TASK_QUEUE.valueSet("updated-tq"), + ActivityOptionsKeys.SCHEDULE_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(200)), + ActivityOptionsKeys.SCHEDULE_TO_START_TIMEOUT.valueSet(Duration.ofSeconds(15)), + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90)), + ActivityOptionsKeys.HEARTBEAT_TIMEOUT.valueSet(Duration.ofSeconds(25)), + ActivityOptionsKeys.RETRY_OPTIONS.valueSet( + RetryOptions.newBuilder() + .setInitialInterval(Duration.ofSeconds(1)) + .setBackoffCoefficient(2.0) + .setMaximumAttempts(7) + .build()), + ActivityOptionsKeys.PRIORITY.valueSet(Priority.newBuilder().setPriorityKey(3).build()), + ActivityOptionsKeys.START_DELAY.valueSet(Duration.ofSeconds(500))); // Every field is settable and lands: the returned options reflect each new value. assertEquals("updated-tq", updated.getTaskQueue()); @@ -418,8 +415,8 @@ public void updateOptionsAllFields() { assertEquals(3, desc.getPriority().getPriorityKey()); assertEquals(Duration.ofSeconds(500), desc.getStartDelay()); // execution_time (api#807 + temporal#11017): reflects the updated start_delay. Server - // recomputes it on UpdateActivityOptions, so it lands at schedule_time + 500s (the new value), - // not schedule_time + 300s (the value at start). + // recomputes it on updateOptions, so it lands at schedule_time + 500s (the new value), not + // schedule_time + 300s (the value at start). assertEquals( desc.getScheduledTime().plus(Duration.ofSeconds(500)).getEpochSecond(), desc.getExecutionTime().getEpochSecond()); @@ -434,15 +431,13 @@ public void updateOptionsRestoreOriginal() { startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); // Change an option away from the original. - UpdateActivityOptions changed = + ActivityExecutionOptions changed = handle.updateOptions( - UpdateActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(90)) - .build()); + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); assertEquals(Duration.ofSeconds(90), changed.getStartToCloseTimeout()); // restore_original alone reverts to the value the activity was created with. - UpdateActivityOptions restored = handle.restoreOriginalOptions(); + ActivityExecutionOptions restored = handle.restoreOriginalOptions(); assertEquals(Duration.ofSeconds(45), restored.getStartToCloseTimeout()); handle.terminate("cleanup"); } @@ -473,11 +468,9 @@ public void updateOptionsOnPausedActivity() { handle.describe().getRunState())); // Updating options is legal while paused, and the new value lands. - UpdateActivityOptions updated = + ActivityExecutionOptions updated = handle.updateOptions( - UpdateActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(90)) - .build()); + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); ActivityExecutionDescription desc = handle.describe(); @@ -533,11 +526,9 @@ public void resetRestoresOriginalOptions() { ActivityHandle handle = startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); - UpdateActivityOptions updated = + ActivityExecutionOptions updated = handle.updateOptions( - UpdateActivityOptions.newBuilder() - .setStartToCloseTimeout(Duration.ofSeconds(90)) - .build()); + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); assertEquals(Duration.ofSeconds(90), updated.getStartToCloseTimeout()); handle.reset(ResetActivityOptions.newBuilder().setRestoreOriginalOptions(true).build()); @@ -721,7 +712,7 @@ public void updateOptionsPreservesHeartbeat() { // UpdateOptions changes activity options only; it never touches heartbeat details. handle.updateOptions( - UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(90)).build()); + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); assertTrue( "heartbeat details should be preserved after updateOptions", @@ -755,7 +746,7 @@ public void interceptorInvokesEachOperatorCommand() { assertEventuallyPaused(handle); handle.unpause(); handle.updateOptions( - UpdateActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(90)).build()); + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); handle.reset(); handle.terminate("cleanup"); 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 af8ea5f346..216a622a93 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 @@ -23,12 +23,12 @@ import io.temporal.api.workflowservice.v1.UpdateActivityExecutionOptionsResponse; import io.temporal.client.ActivityClientOptions; import io.temporal.client.ActivityExecutionDescription; +import io.temporal.client.ActivityOptionsKeys; import io.temporal.client.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; import io.temporal.client.UntypedActivityHandle; -import io.temporal.client.UpdateActivityOptions; import io.temporal.internal.client.external.GenericWorkflowClient; import java.time.Duration; import org.junit.Test; @@ -71,8 +71,7 @@ public void unobservableRequestFields() { .setKeepPaused(true) .setRestoreOriginalOptions(true) .build()); - handle.updateOptions( - UpdateActivityOptions.newBuilder().setStartDelay(Duration.ofSeconds(7)).build()); + handle.updateOptions(ActivityOptionsKeys.START_DELAY.valueSet(Duration.ofSeconds(7))); // pause carries the reason and an auto-generated dedup request_id; neither is returned by // describe. @@ -135,9 +134,7 @@ public void updateOptionsRequiresAtLeastOneOption() { UntypedActivityHandle handle = newHandle(); IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> handle.updateOptions(UpdateActivityOptions.newBuilder().build())); + assertThrows(IllegalArgumentException.class, () -> handle.updateOptions()); assertTrue(e.getMessage().contains("at least one option")); verifyNoInteractions(genericClient); @@ -204,44 +201,84 @@ private static DescribeActivityExecutionResponse overSharingResponse() { } /** - * A zero duration clears the option: the path is named in the mask so the server acts on it, and - * the server normalizes a zero timeout back to unset. Null means "do not touch" and keeps the - * path out of the mask entirely. + * 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 zeroDurationClearsTheOption() { + public void valueSetOfZeroSendsAnExplicitZero() { when(genericClient.updateActivityOptions(any())) .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); - newHandle() - .updateOptions( - UpdateActivityOptions.newBuilder().setHeartbeatTimeout(Duration.ZERO).build()); + newHandle().updateOptions(ActivityOptionsKeys.HEARTBEAT_TIMEOUT.valueSet(Duration.ZERO)); UpdateActivityExecutionOptionsRequest req = captureUpdate(); assertEquals( - java.util.Collections.singletonList("heartbeat_timeout"), - req.getUpdateMask().getPathsList()); + 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()); } - /** A null option is left alone: it never reaches the mask. */ + /** + * 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(ActivityOptionsKeys.HEARTBEAT_TIMEOUT.valueUnset()); + + 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()); + } + + /** 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( + ActivityOptionsKeys.HEARTBEAT_TIMEOUT.valueSet(Duration.ofSeconds(5)), + ActivityOptionsKeys.HEARTBEAT_TIMEOUT.valueUnset()); + + 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 nullOptionIsNotTouched() { + public void maskNamesOnlyTheChangedOptions() { when(genericClient.updateActivityOptions(any())) .thenReturn(UpdateActivityExecutionOptionsResponse.getDefaultInstance()); newHandle() .updateOptions( - UpdateActivityOptions.newBuilder() - .setHeartbeatTimeout(null) - .setStartToCloseTimeout(Duration.ofSeconds(90)) - .build()); + ActivityOptionsKeys.TASK_QUEUE.valueSet("new-tq"), + ActivityOptionsKeys.START_TO_CLOSE_TIMEOUT.valueSet(Duration.ofSeconds(90))); UpdateActivityExecutionOptionsRequest req = captureUpdate(); assertEquals( - java.util.Collections.singletonList("start_to_close_timeout"), - req.getUpdateMask().getPathsList()); + 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 ResetActivityExecutionRequest captureReset() { From 5a476ea4fa3f09792278a3f65d1f06a94e34bd29 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 28 Aug 2026 15:58:27 -0400 Subject: [PATCH 44/53] cleanup --- .../java/io/temporal/internal/client/ActivityHandleImpl.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 dea524d190..2a2b3be224 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 @@ -194,9 +194,7 @@ public void reset(ResetActivityOptions options) { @Override public ActivityExecutionOptions updateOptions(ActivityOptionsUpdate... updates) { ActivityOptions.Builder activityOptions = ActivityOptions.newBuilder(); - // A repeated key resolves to its last update, so a later valueUnset overrides an earlier - // valueSet. Nothing else about ordering matters: the server reads the mask as a set, and each - // path writes a different field of ActivityOptions. + // For repeated keys, later values override previous ones. Map> byPath = new HashMap<>(); for (ActivityOptionsUpdate update : updates) { if (update != null) { From d429cefad9f20f11e606d49370425752c6d3bbef Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 1 Sep 2026 12:11:12 -0400 Subject: [PATCH 45/53] Remove mocked id checks --- .../ActivityHandleOperatorCommandsTest.java | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) 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 216a622a93..7b0d3db76e 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 @@ -73,40 +73,32 @@ public void unobservableRequestFields() { .build()); handle.updateOptions(ActivityOptionsKeys.START_DELAY.valueSet(Duration.ofSeconds(7))); - // pause carries the reason and an auto-generated dedup request_id; neither is returned by - // describe. + // pause carries the reason, which is not returned by describe. PauseActivityExecutionRequest pauseReq = capturePause(); assertEquals("because", pauseReq.getReason()); - assertTrue("pause request_id should be set", !pauseReq.getRequestId().isEmpty()); - // unpause carries the reason, jitter, and an auto-generated dedup request_id (api#844). + // unpause carries the reason and jitter. UnpauseActivityExecutionRequest unpauseReq = captureUnpause(); assertEquals("go", unpauseReq.getReason()); assertEquals(5, unpauseReq.getJitter().getSeconds()); assertEquals(0, unpauseReq.getJitter().getNanos()); - assertTrue("unpause request_id should be set", !unpauseReq.getRequestId().isEmpty()); - // reset carries jitter, an auto-generated dedup request_id (api#844), and reset_heartbeat - // (api#848). + // reset carries jitter and reset_heartbeat (api#848). ResetActivityExecutionRequest resetReq = captureReset(); assertEquals(2, resetReq.getJitter().getSeconds()); assertEquals(0, resetReq.getJitter().getNanos()); - assertTrue("reset request_id should be set", !resetReq.getRequestId().isEmpty()); assertTrue("reset should carry reset_heartbeat=true", resetReq.getResetHeartbeat()); assertTrue("reset should carry keep_paused=true", resetReq.getKeepPaused()); assertTrue( "reset should carry restore_original_options=true", resetReq.getRestoreOriginalOptions()); - // updateOptions carries start_delay in activity_options with a matching update_mask path, plus - // an auto-generated dedup request_id (api#844). start_delay is applied server-side but not - // otherwise observable from the request. + // 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")); - assertTrue("updateOptions request_id should be set", !updateReq.getRequestId().isEmpty()); } private PauseActivityExecutionRequest capturePause() { @@ -241,6 +233,17 @@ public void valueUnsetNamesThePathButLeavesTheFieldAbsent() { "an unset value is absent, not zero", req.getActivityOptions().hasHeartbeatTimeout()); } + @Test + public void omittedJitterIsLeftOffTheWire() { + UntypedActivityHandle handle = newHandle(); + + handle.unpause(UnpauseActivityOptions.newBuilder().build()); + handle.reset(ResetActivityOptions.newBuilder().build()); + + assertFalse("unpause should not send jitter", captureUnpause().hasJitter()); + assertFalse("reset should not send jitter", captureReset().hasJitter()); + } + /** A repeated key resolves to its last update: a later valueUnset overrides an earlier set. */ @Test public void aRepeatedKeyResolvesToItsLastUpdate() { From 1a2ba38d344f3227acfbd0ee70759866f0c15545 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 1 Sep 2026 14:24:41 -0400 Subject: [PATCH 46/53] Remove long start delay wait --- .../functional/StandaloneActivityOperatorCommandsTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 index 0837570514..54c9e255a4 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -294,8 +294,10 @@ public void unpauseResumes() { handle.describe().getRunState())); handle.unpause(); - // After unpause the activity proceeds and completes successfully (proving it resumed). - assertEquals("resumed", handle.getResult()); + assertEventually( + Duration.ofSeconds(30), + () -> assertFalse(PAUSED_STATES.contains(handle.describe().getRunState()))); + handle.terminate("cleanup"); } // Overrides the rule's default 10s global timeout: driving retries + reset takes longer. From 40072777559accf0930b9a9a67c318920c415d87 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 1 Sep 2026 14:55:05 -0400 Subject: [PATCH 47/53] start delayed in restore test --- .../StandaloneActivityOperatorCommandsTest.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 index 54c9e255a4..caa3736e10 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -525,8 +525,18 @@ public void resetKeepsPaused() { @Test(timeout = 60_000) public void resetRestoresOriginalOptions() { assumeTrue(SDKTestWorkflowRule.useExternalService); - ActivityHandle handle = - startRunningSlowActivity(slowOpts().setStartToCloseTimeout(Duration.ofSeconds(45))); + // Start delayed so the restore is applied immediately. + ActivityHandle handle = + newActivityClient() + .start( + QuickActivity.class, + QuickActivity::run, + StartActivityOptions.newBuilder() + .setId(uniqueId()) + .setTaskQueue(testWorkflowRule.getTaskQueue()) + .setStartToCloseTimeout(Duration.ofSeconds(45)) + .setStartDelay(Duration.ofSeconds(300)) + .build()); ActivityExecutionOptions updated = handle.updateOptions( From 98f668f8649248cb0c55fd0929e3f9708277b4bd Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 1 Sep 2026 15:27:27 -0400 Subject: [PATCH 48/53] missing files --- .../temporal/client/ActivityOptionsKey.java | 48 +++++++++++++++ .../temporal/client/ActivityOptionsKeys.java | 37 ++++++++++++ .../client/ActivityOptionsUpdate.java | 60 +++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKey.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKeys.java create mode 100644 temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsUpdate.java diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKey.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKey.java new file mode 100644 index 0000000000..ebd734c5ea --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKey.java @@ -0,0 +1,48 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import javax.annotation.Nonnull; + +/** + * Typed key for one updatable activity option. + * + *

Use the keys on {@link ActivityOptionsKeys} rather than constructing these directly. + * + * @param type of the option's value + */ +@Experimental +public final class ActivityOptionsKey { + + private final String name; + private final Class valueType; + + ActivityOptionsKey(String name, Class valueType) { + this.name = name; + this.valueType = valueType; + } + + /** Field-mask path this key updates. */ + public String getName() { + return name; + } + + /** Type of this key's value. */ + public Class getValueType() { + return valueType; + } + + /** Create an update that sets this option to the given value. */ + public ActivityOptionsUpdate valueSet(@Nonnull T value) { + return ActivityOptionsUpdate.valueSet(this, value); + } + + /** Create an update that clears this option server-side. */ + public ActivityOptionsUpdate valueUnset() { + return ActivityOptionsUpdate.valueUnset(this); + } + + @Override + public String toString() { + return "ActivityOptionsKey{name='" + name + "', valueType=" + valueType.getSimpleName() + '}'; + } +} diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKeys.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKeys.java new file mode 100644 index 0000000000..7651f3aff2 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsKeys.java @@ -0,0 +1,37 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import io.temporal.common.Priority; +import io.temporal.common.RetryOptions; +import java.time.Duration; + +/** The activity options that {@link UntypedActivityHandle#updateOptions} can change. */ +@Experimental +public final class ActivityOptionsKeys { + + public static final ActivityOptionsKey TASK_QUEUE = + new ActivityOptionsKey<>("task_queue.name", String.class); + + public static final ActivityOptionsKey SCHEDULE_TO_CLOSE_TIMEOUT = + new ActivityOptionsKey<>("schedule_to_close_timeout", Duration.class); + + public static final ActivityOptionsKey SCHEDULE_TO_START_TIMEOUT = + new ActivityOptionsKey<>("schedule_to_start_timeout", Duration.class); + + public static final ActivityOptionsKey START_TO_CLOSE_TIMEOUT = + new ActivityOptionsKey<>("start_to_close_timeout", Duration.class); + + public static final ActivityOptionsKey HEARTBEAT_TIMEOUT = + new ActivityOptionsKey<>("heartbeat_timeout", Duration.class); + + public static final ActivityOptionsKey START_DELAY = + new ActivityOptionsKey<>("start_delay", Duration.class); + + public static final ActivityOptionsKey RETRY_OPTIONS = + new ActivityOptionsKey<>("retry_policy", RetryOptions.class); + + public static final ActivityOptionsKey PRIORITY = + new ActivityOptionsKey<>("priority", Priority.class); + + private ActivityOptionsKeys() {} +} 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..d3c7ad39ab --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityOptionsUpdate.java @@ -0,0 +1,60 @@ +package io.temporal.client; + +import io.temporal.common.Experimental; +import java.util.Optional; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +/** + * A single change to an activity's options. Updates are usually created via {@link + * ActivityOptionsKey#valueSet} or {@link ActivityOptionsKey#valueUnset}. + * + *

An option with no update in the call is left untouched. + * + * @param type of the option's value + */ +@Experimental +public final class ActivityOptionsUpdate { + + /** + * Create an update setting an option to a value. Most users will prefer {@link + * ActivityOptionsKey#valueSet}. + */ + public static ActivityOptionsUpdate valueSet(ActivityOptionsKey key, @Nonnull T value) { + if (value == null) { + throw new IllegalArgumentException("Value cannot be null, use valueUnset"); + } + return new ActivityOptionsUpdate<>(key, value); + } + + /** + * Create an update clearing an option. Most users will prefer {@link + * ActivityOptionsKey#valueUnset}. + */ + public static ActivityOptionsUpdate valueUnset(ActivityOptionsKey key) { + return new ActivityOptionsUpdate<>(key, null); + } + + 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); + } + + @Override + public String toString() { + return "ActivityOptionsUpdate{key=" + key.getName() + ", value=" + value + '}'; + } +} From d864113316da1cd9300292de789095229bfeb87c Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Tue, 1 Sep 2026 15:48:14 -0400 Subject: [PATCH 49/53] cleanup --- .../StandaloneActivityOperatorCommandsTest.java | 8 -------- .../client/ActivityHandleOperatorCommandsTest.java | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) 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 index caa3736e10..ae9e5e9365 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -416,9 +416,6 @@ public void updateOptionsAllFields() { assertEquals(7, desc.getRetryOptions().getMaximumAttempts()); assertEquals(3, desc.getPriority().getPriorityKey()); assertEquals(Duration.ofSeconds(500), desc.getStartDelay()); - // execution_time (api#807 + temporal#11017): reflects the updated start_delay. Server - // recomputes it on updateOptions, so it lands at schedule_time + 500s (the new value), not - // schedule_time + 300s (the value at start). assertEquals( desc.getScheduledTime().plus(Duration.ofSeconds(500)).getEpochSecond(), desc.getExecutionTime().getEpochSecond()); @@ -556,11 +553,6 @@ public void resetRestoresOriginalOptions() { handle.terminate("cleanup"); } - /** - * Describe reports a paused activity as PAUSED (api#834), on both the execution status and the - * run state. Asserts the transition, not just the end state: the same handle reports RUNNING - * before the pause. - */ @Test(timeout = 60_000) public void describeReportsPausedStatus() { assumeTrue(SDKTestWorkflowRule.useExternalService); 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 7b0d3db76e..f393fef42e 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 @@ -83,7 +83,7 @@ public void unobservableRequestFields() { assertEquals(5, unpauseReq.getJitter().getSeconds()); assertEquals(0, unpauseReq.getJitter().getNanos()); - // reset carries jitter and reset_heartbeat (api#848). + // reset carries jitter and reset_heartbeat. ResetActivityExecutionRequest resetReq = captureReset(); assertEquals(2, resetReq.getJitter().getSeconds()); assertEquals(0, resetReq.getJitter().getNanos()); From dfaf027cfc5c3c1406b931ed48c6123c911dba74 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 2 Sep 2026 12:17:31 -0400 Subject: [PATCH 50/53] Add CHANGELOG.md --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..c514182c83 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ + + +# Changelog + +## [Unreleased] + +### Added + +#### Standalone Activity operator commands + +- `UntypedActivityHandle` and `ActivityHandle` now support operator commands for standalone + activities: `pause()`, `unpause()`, `reset()`, `updateOptions()` and `restoreOriginalOptions()`. + `updateOptions()` takes `ActivityOptionsUpdate` values built from the keys on + `ActivityOptionsKeys`, via `ActivityOptionsKey.valueSet()` to set an option or + `ActivityOptionsKey.valueUnset()` to clear it, and returns the server's resolved + `ActivityExecutionOptions`. +- Added opt-in payload flags to `DescribeActivityOptions`: `setIncludeInput()`, + `setIncludeOutcome()`, `setIncludeHeartbeatDetails()` and `setIncludeLastFailure()`, all + defaulting to `false`. +- Added missing `ActivityExecutionDescription` fields: `getExecutionTime()`, `getStartDelay()` + and `getTotalHeartbeatCount()`. + +### :boom: Breaking Changes + +- `ActivityExecutionDescription` payload fields are now opt-in and must be requested via + `DescribeActivityOptions`: `getInput()`, `getResult()`, `getHeartbeatDetails()` and + `getLastFailure()`. Each has a matching `hasInput()` / `hasResult()` / + `hasHeartbeatDetails()` / `hasLastFailure()` predicate. + +### Changed + +### Deprecated + +### Fixed + +### Security From 451153e101a118f2a53dec40636f20a538144d9e Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 2 Sep 2026 13:05:21 -0400 Subject: [PATCH 51/53] Fix javadoc --- .../main/java/io/temporal/client/ActivityExecutionOptions.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java index 4f8c214ae1..f134f46cc3 100644 --- a/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java +++ b/temporal-sdk/src/main/java/io/temporal/client/ActivityExecutionOptions.java @@ -8,7 +8,7 @@ import javax.annotation.Nullable; /** - * Options for {@link UntypedActivityHandle#updateOptions(ActivityExecutionOptions)}. + * Options for {@link UntypedActivityHandle#updateOptions(ActivityOptionsUpdate...)}. * *

Only the fields that are explicitly set are sent to the server; a derived field mask ensures * that unset fields are left unchanged (a partial update). From e13ba47e4a1d54acce3b951c47d361e3ae5c3cc2 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Wed, 2 Sep 2026 15:58:03 -0400 Subject: [PATCH 52/53] Do not remove unrequested optional describe payloads --- .../client/RootActivityClientInvoker.java | 31 +-------- .../ActivityHandleOperatorCommandsTest.java | 68 ------------------- 2 files changed, 1 insertion(+), 98 deletions(-) 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 2fb6c29f04..3e12ecf956 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 @@ -352,8 +352,7 @@ public DescribeActivityOutput describeActivity(DescribeActivityInput input) { if (input.getRunId() != null) { req.setRunId(input.getRunId()); } - DescribeActivityExecutionResponse response = - stripUnrequestedPayloads(genericClient.describeActivity(req.build()), input.getOptions()); + DescribeActivityExecutionResponse response = genericClient.describeActivity(req.build()); return new DescribeActivityOutput( new ActivityExecutionDescription( response, clientOptions.getDataConverter(), clientOptions.getNamespace())); @@ -502,34 +501,6 @@ public CountActivitiesOutput countActivities(CountActivitiesInput input) { return new CountActivitiesOutput(new ActivityExecutionCount(resp)); } - /** - * Clears payload-bearing fields the caller did not ask for, in case an older or buggy server sent - * them anyway. - */ - private static DescribeActivityExecutionResponse stripUnrequestedPayloads( - DescribeActivityExecutionResponse response, DescribeActivityOptions options) { - if (options.isIncludeInput() - && options.isIncludeOutcome() - && options.isIncludeHeartbeatDetails() - && options.isIncludeLastFailure()) { - return response; - } - DescribeActivityExecutionResponse.Builder builder = response.toBuilder(); - if (!options.isIncludeInput()) { - builder.clearInput(); - } - if (!options.isIncludeOutcome()) { - builder.clearOutcome(); - } - if (!options.isIncludeHeartbeatDetails()) { - builder.getInfoBuilder().clearHeartbeatDetails(); - } - if (!options.isIncludeLastFailure()) { - builder.getInfoBuilder().clearLastFailure(); - } - return builder.build(); - } - /** Converts the server's resolved activity options into the public options type. */ private static ActivityExecutionOptions toUpdateActivityOptions(ActivityOptions proto) { ActivityExecutionOptions.Builder builder = ActivityExecutionOptions.newBuilder(); 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 f393fef42e..0c32a3545e 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 @@ -10,21 +10,13 @@ import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import io.temporal.api.activity.v1.ActivityExecutionInfo; -import io.temporal.api.activity.v1.ActivityExecutionOutcome; -import io.temporal.api.common.v1.Payload; -import io.temporal.api.common.v1.Payloads; -import io.temporal.api.failure.v1.Failure; -import io.temporal.api.workflowservice.v1.DescribeActivityExecutionResponse; import io.temporal.api.workflowservice.v1.PauseActivityExecutionRequest; import io.temporal.api.workflowservice.v1.ResetActivityExecutionRequest; 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.ActivityExecutionDescription; import io.temporal.client.ActivityOptionsKeys; -import io.temporal.client.DescribeActivityOptions; import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; import io.temporal.client.UnpauseActivityOptions; @@ -132,66 +124,6 @@ public void updateOptionsRequiresAtLeastOneOption() { verifyNoInteractions(genericClient); } - @Test - public void unrequestedPayloadsAreStripped() { - when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); - - ActivityExecutionDescription bare = newHandle().describe(); - assertFalse("input should be stripped", bare.hasInput()); - assertFalse("outcome should be stripped", bare.hasResult()); - assertFalse("heartbeat details should be stripped", bare.hasHeartbeatDetails()); - assertFalse("last failure should be stripped", bare.hasLastFailure()); - } - - @Test - public void requestedPayloadsAreKept() { - when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); - - ActivityExecutionDescription full = - newHandle() - .describe( - DescribeActivityOptions.newBuilder() - .setIncludeInput(true) - .setIncludeOutcome(true) - .setIncludeHeartbeatDetails(true) - .setIncludeLastFailure(true) - .build()); - assertTrue(full.hasInput()); - assertTrue(full.hasResult()); - assertTrue(full.hasHeartbeatDetails()); - assertTrue(full.hasLastFailure()); - } - - @Test - public void strippingIsPerField() { - when(genericClient.describeActivity(any())).thenReturn(overSharingResponse()); - - ActivityExecutionDescription desc = - newHandle().describe(DescribeActivityOptions.newBuilder().setIncludeInput(true).build()); - assertTrue("input was requested", desc.hasInput()); - assertFalse("outcome was not requested", desc.hasResult()); - assertFalse("heartbeat details were not requested", desc.hasHeartbeatDetails()); - assertFalse("last failure was not requested", desc.hasLastFailure()); - } - - /** A response carrying every payload field, as an older or buggy server might send. */ - private static DescribeActivityExecutionResponse overSharingResponse() { - Payloads payloads = - Payloads.newBuilder() - .addPayloads( - Payload.newBuilder().setData(com.google.protobuf.ByteString.copyFromUtf8("x"))) - .build(); - return DescribeActivityExecutionResponse.newBuilder() - .setInfo( - ActivityExecutionInfo.newBuilder() - .setActivityId("act-1") - .setHeartbeatDetails(payloads) - .setLastFailure(Failure.newBuilder().setMessage("boom"))) - .setInput(payloads) - .setOutcome(ActivityExecutionOutcome.newBuilder().setResult(payloads)) - .build(); - } - /** * 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 From 00222f7d4a8285b3facf90683d82257005739b13 Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Fri, 4 Sep 2026 15:24:40 -0400 Subject: [PATCH 53/53] Add test_interceptor_receives_command_arguments, include inputs in RecordingInterceptor --- ...tandaloneActivityOperatorCommandsTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) 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 index ae9e5e9365..96f98ce20c 100644 --- a/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java +++ b/temporal-sdk/src/test/java/io/temporal/client/functional/StandaloneActivityOperatorCommandsTest.java @@ -21,6 +21,7 @@ import io.temporal.client.PauseActivityOptions; import io.temporal.client.ResetActivityOptions; 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; @@ -762,8 +763,57 @@ public void interceptorInvokesEachOperatorCommand() { } /** 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.reset( + ResetActivityOptions.newBuilder().setKeepPaused(true).setResetHeartbeat(true).build()); + 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()); + assertTrue(recorder.resetInput.getOptions().isKeepPaused()); + assertTrue(recorder.resetInput.getOptions().isResetHeartbeat()); + assertFalse(recorder.resetInput.getOptions().isRestoreOriginalOptions()); + } + private static class RecordingInterceptor extends ActivityClientInterceptorBase { private final List events; + PauseActivityInput pauseInput; + UnpauseActivityInput unpauseInput; + ResetActivityInput resetInput; RecordingInterceptor(List events) { this.events = events; @@ -776,18 +826,21 @@ public ActivityClientCallsInterceptor activityClientCallsInterceptor( @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 ResetActivityOutput resetActivity(ResetActivityInput input) { events.add("reset"); + resetInput = input; return super.resetActivity(input); }