From 34a1350941d07a66ca80111b4d1513b16de3257f Mon Sep 17 00:00:00 2001 From: Greg Travis Date: Thu, 18 Jun 2026 15:00:29 -0400 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] - 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/24] 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(