From 6782c5449ef337983d6624725ff51f61f93c0e06 Mon Sep 17 00:00:00 2001 From: Maxim Fateev <1463622+mfateev@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:03:56 -0800 Subject: [PATCH 1/3] Cancel timer in Workflow.await(duration, condition) when condition satisfied This change addresses GitHub issue #2312 by ensuring that Workflow.await(duration, condition) cancels the timer when the condition is satisfied before the timeout expires. Changes: - Add CANCEL_AWAIT_TIMER_ON_CONDITION SDK flag for backward compatibility - Modify SyncWorkflowContext.await() to use a CancellationScope to cancel the timer when condition is satisfied before timeout - Skip timer creation entirely if condition is already satisfied - Add comprehensive tests including replay compatibility test The new behavior is enabled by default for new workflows via the SDK flag mechanism. Old workflows replay correctly with the original behavior. --- .../io/temporal/internal/common/SdkFlag.java | 5 + .../statemachines/WorkflowStateMachines.java | 4 +- .../internal/sync/SyncWorkflowContext.java | 32 ++- ...rkflowAwaitCancelTimerOnConditionTest.java | 202 ++++++++++++++++++ .../awaitTimerConditionOldBehavior.json | 138 ++++++++++++ 5 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java create mode 100644 temporal-sdk/src/test/resources/awaitTimerConditionOldBehavior.json diff --git a/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java b/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java index bb8ad8912c..cbfce7e434 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/common/SdkFlag.java @@ -18,6 +18,11 @@ public enum SdkFlag { * Changes behavior of CancellationScope to cancel children in a deterministic order. */ DETERMINISTIC_CANCELLATION_SCOPE_ORDER(3), + /* + * Changes behavior of Workflow.await(duration, condition) to cancel the timer if the + * condition is resolved before the timeout. + */ + CANCEL_AWAIT_TIMER_ON_CONDITION(4), UNKNOWN(Integer.MAX_VALUE); private final int value; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java index 2f2c716f24..6223053e4b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java @@ -52,7 +52,9 @@ enum HandleEventStatus { /** Initial set of SDK flags that will be set on all new workflow executions. */ @VisibleForTesting public static List initialFlags = - Collections.unmodifiableList(Arrays.asList(SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION)); + Collections.unmodifiableList( + Arrays.asList( + SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION, SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION)); /** * Keep track of the change versions that have been seen by the SDK. This is used to generate the diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index 977d9754e6..f9cf78536c 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -1317,9 +1317,35 @@ public void sleep(Duration duration) { @Override public boolean await(Duration timeout, String reason, Supplier unblockCondition) { - Promise timer = newTimer(timeout); - WorkflowThread.await(reason, () -> (timer.isCompleted() || unblockCondition.get())); - return !timer.isCompleted(); + boolean cancelTimerOnCondition = + replayContext.tryUseSdkFlag(SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION); + + // If new behavior is enabled and condition is already satisfied, skip creating timer + if (cancelTimerOnCondition && unblockCondition.get()) { + return true; + } + + if (cancelTimerOnCondition) { + // New behavior: create timer in a cancellation scope so we can cancel it when condition is + // satisfied + CompletablePromise timer = Workflow.newPromise(); + CancellationScope timerScope = + Workflow.newCancellationScope(() -> timer.completeFrom(newTimer(timeout))); + timerScope.run(); + + WorkflowThread.await(reason, () -> (timer.isCompleted() || unblockCondition.get())); + + boolean conditionSatisfied = !timer.isCompleted(); + if (conditionSatisfied) { + timerScope.cancel("await condition resolved"); + } + return conditionSatisfied; + } else { + // Old behavior: timer is not cancelled when condition is satisfied + Promise timer = newTimer(timeout); + WorkflowThread.await(reason, () -> (timer.isCompleted() || unblockCondition.get())); + return !timer.isCompleted(); + } } @Override diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java new file mode 100644 index 0000000000..56689f2f74 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java @@ -0,0 +1,202 @@ +package io.temporal.workflow.cancellationTests; + +import static org.junit.Assert.*; + +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.enums.v1.EventType; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.testing.WorkflowReplayer; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.workflow.SignalMethod; +import io.temporal.workflow.Workflow; +import io.temporal.workflow.WorkflowInterface; +import io.temporal.workflow.WorkflowMethod; +import java.time.Duration; +import org.junit.Rule; +import org.junit.Test; + +/** + * Tests for the CANCEL_AWAIT_TIMER_ON_CONDITION SDK flag behavior. This flag ensures that + * Workflow.await(duration, condition) cancels the timer when the condition is satisfied before the + * timeout. + * + *

Note: These tests verify the NEW behavior (timer cancellation) for new workflows. Replay + * compatibility for old workflows (without the flag) is preserved by the SDK flag mechanism - the + * old behavior is only used during replay of histories that were recorded without the flag. + */ +public class WorkflowAwaitCancelTimerOnConditionTest { + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + TestAwaitCancelTimerWorkflowImpl.class, + TestImmediateConditionWorkflowImpl.class, + TestReturnValueWorkflowImpl.class) + .build(); + + /** + * Tests that the timer is cancelled when the await condition is satisfied before the timeout. + * With the CANCEL_AWAIT_TIMER_ON_CONDITION flag enabled, we expect to see a TIMER_CANCELED event + * in the history when a signal satisfies the condition. + */ + @Test + public void testTimerCancelledWhenConditionSatisfied() { + TestAwaitWorkflow workflow = testWorkflowRule.newWorkflowStub(TestAwaitWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute); + + // Wait a bit for workflow to start and begin awaiting + testWorkflowRule.sleep(Duration.ofMillis(500)); + + // Signal to satisfy the condition + workflow.unblock(); + + // Get the result + WorkflowStub untyped = WorkflowStub.fromTyped(workflow); + String result = untyped.getResult(String.class); + assertEquals("condition satisfied", result); + + // Verify timer was started and then cancelled + testWorkflowRule.assertHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_STARTED); + testWorkflowRule.assertHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_CANCELED); + } + + /** + * Tests that no timer is created when the condition is already satisfied at the time of the await + * call. With the CANCEL_AWAIT_TIMER_ON_CONDITION flag enabled, if the condition is immediately + * true, we skip creating a timer entirely. + */ + @Test + public void testNoTimerWhenConditionImmediatelySatisfied() { + TestImmediateConditionWorkflow workflow = + testWorkflowRule.newWorkflowStub(TestImmediateConditionWorkflow.class); + + WorkflowExecution execution = WorkflowClient.start(workflow::execute); + + WorkflowStub untyped = WorkflowStub.fromTyped(workflow); + String result = untyped.getResult(String.class); + assertEquals("immediate condition", result); + + // Verify no timer was created + testWorkflowRule.assertNoHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_STARTED); + } + + /** + * Tests that the await returns true when condition is satisfied and false when it times out. This + * verifies the return value semantics are preserved with the new flag, and that only the first + * timer (condition satisfied) is canceled while the second timer (timeout) fires normally. + */ + @Test + public void testAwaitReturnValue() { + TestReturnValueWorkflow workflow = + testWorkflowRule.newWorkflowStub(TestReturnValueWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute); + + // Signal to satisfy the first condition + testWorkflowRule.sleep(Duration.ofMillis(500)); + workflow.unblock(); + + WorkflowStub untyped = WorkflowStub.fromTyped(workflow); + String result = untyped.getResult(String.class); + + // First await should return true (condition satisfied), second should return false (timeout) + // timedOut = !await(...) = !false = true when it times out + assertEquals("conditionSatisfied=true,timedOut=true", result); + + // Verify: first timer was canceled (condition satisfied), second timer fired (timeout) + testWorkflowRule.assertHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_CANCELED); + testWorkflowRule.assertHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_FIRED); + } + + /** + * Tests replay compatibility with old workflow histories that were recorded WITHOUT the + * CANCEL_AWAIT_TIMER_ON_CONDITION flag. This ensures the SDK can correctly replay workflows that + * used the old behavior (timer not cancelled when condition satisfied). + * + *

The history file (awaitTimerConditionOldBehavior.json) contains a workflow execution that: + * 1. Started with Workflow.await(1 hour, condition) 2. Received a signal that satisfied the + * condition 3. Completed without canceling the timer (old behavior) + * + *

The replay should succeed without throwing a non-determinism error, proving that the new SDK + * code correctly falls back to old behavior when replaying histories without the flag. + */ + @Test + public void testReplayOldHistoryWithoutFlag() throws Exception { + WorkflowReplayer.replayWorkflowExecutionFromResource( + "awaitTimerConditionOldBehavior.json", TestAwaitCancelTimerWorkflowImpl.class); + } + + @WorkflowInterface + public interface TestAwaitWorkflow { + @WorkflowMethod + String execute(); + + @SignalMethod + void unblock(); + } + + @WorkflowInterface + public interface TestImmediateConditionWorkflow { + @WorkflowMethod + String execute(); + } + + @WorkflowInterface + public interface TestReturnValueWorkflow { + @WorkflowMethod + String execute(); + + @SignalMethod + void unblock(); + } + + public static class TestAwaitCancelTimerWorkflowImpl implements TestAwaitWorkflow { + private boolean unblocked = false; + + @Override + public String execute() { + boolean result = Workflow.await(Duration.ofHours(1), () -> unblocked); + return result ? "condition satisfied" : "timed out"; + } + + @Override + public void unblock() { + unblocked = true; + } + } + + public static class TestImmediateConditionWorkflowImpl implements TestImmediateConditionWorkflow { + @Override + public String execute() { + // Condition is immediately true + boolean result = Workflow.await(Duration.ofHours(1), () -> true); + return result ? "immediate condition" : "unexpected timeout"; + } + } + + public static class TestReturnValueWorkflowImpl implements TestReturnValueWorkflow { + private boolean unblocked = false; + + @Override + public String execute() { + // First await: condition will be satisfied by signal + boolean conditionSatisfied = Workflow.await(Duration.ofHours(1), () -> unblocked); + + // Second await: condition will never be satisfied, should timeout + boolean timedOut = !Workflow.await(Duration.ofMillis(100), () -> false); + + return "conditionSatisfied=" + conditionSatisfied + ",timedOut=" + timedOut; + } + + @Override + public void unblock() { + unblocked = true; + } + } +} diff --git a/temporal-sdk/src/test/resources/awaitTimerConditionOldBehavior.json b/temporal-sdk/src/test/resources/awaitTimerConditionOldBehavior.json new file mode 100644 index 0000000000..f827281925 --- /dev/null +++ b/temporal-sdk/src/test/resources/awaitTimerConditionOldBehavior.json @@ -0,0 +1,138 @@ +{ + "events": [ + { + "eventId": "1", + "eventTime": "2026-01-07T01:45:58.381Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED", + "workflowExecutionStartedEventAttributes": { + "workflowType": { + "name": "TestAwaitWorkflow" + }, + "taskQueue": { + "name": "WorkflowTest-replay-await-timer" + }, + "input": {}, + "workflowExecutionTimeout": "315360000s", + "workflowRunTimeout": "315360000s", + "workflowTaskTimeout": "10s", + "originalExecutionRunId": "08ddbb4c-d763-4a85-af00-e841226b7f73", + "identity": "test-worker", + "firstExecutionRunId": "08ddbb4c-d763-4a85-af00-e841226b7f73", + "attempt": 1, + "firstWorkflowTaskBackoff": "0s", + "header": {} + } + }, + { + "eventId": "2", + "eventTime": "2026-01-07T01:45:58.381Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "WorkflowTest-replay-await-timer" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "3", + "eventTime": "2026-01-07T01:45:58.391Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "2", + "identity": "test-worker" + } + }, + { + "eventId": "4", + "eventTime": "2026-01-07T01:45:58.495Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "2", + "identity": "test-worker", + "sdkMetadata": { + "langUsedFlags": [ + 1 + ], + "sdkName": "temporal-java", + "sdkVersion": "1.20.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "5", + "eventTime": "2026-01-07T01:45:58.495Z", + "eventType": "EVENT_TYPE_TIMER_STARTED", + "timerStartedEventAttributes": { + "timerId": "f5baace7-0ef1-310f-93eb-30caf39e6f4a", + "startToFireTimeout": "3600s", + "workflowTaskCompletedEventId": "4" + } + }, + { + "eventId": "6", + "eventTime": "2026-01-07T01:45:58.896Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED", + "workflowExecutionSignaledEventAttributes": { + "signalName": "unblock", + "input": {}, + "identity": "test-client" + } + }, + { + "eventId": "7", + "eventTime": "2026-01-07T01:45:58.896Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_SCHEDULED", + "workflowTaskScheduledEventAttributes": { + "taskQueue": { + "name": "WorkflowTest-replay-await-timer" + }, + "startToCloseTimeout": "10s", + "attempt": 1 + } + }, + { + "eventId": "8", + "eventTime": "2026-01-07T01:45:58.896Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_STARTED", + "workflowTaskStartedEventAttributes": { + "scheduledEventId": "7", + "identity": "test-worker" + } + }, + { + "eventId": "9", + "eventTime": "2026-01-07T01:45:58.912Z", + "eventType": "EVENT_TYPE_WORKFLOW_TASK_COMPLETED", + "workflowTaskCompletedEventAttributes": { + "scheduledEventId": "7", + "identity": "test-worker", + "sdkMetadata": { + "sdkName": "temporal-java", + "sdkVersion": "1.20.0" + }, + "meteringMetadata": {} + } + }, + { + "eventId": "10", + "eventTime": "2026-01-07T01:45:58.912Z", + "eventType": "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED", + "workflowExecutionCompletedEventAttributes": { + "result": { + "payloads": [ + { + "metadata": { + "encoding": "anNvbi9wbGFpbg==" + }, + "data": "ImNvbmRpdGlvbiBzYXRpc2ZpZWQi" + } + ] + }, + "workflowTaskCompletedEventId": "9" + } + } + ] +} From 986e9542591b3c63a91f4c22d079674efecdaf42 Mon Sep 17 00:00:00 2001 From: Maxim Fateev <1463622+mfateev@users.noreply.github.com> Date: Mon, 23 Feb 2026 17:59:14 -0800 Subject: [PATCH 2/3] Disable CANCEL_AWAIT_TIMER_ON_CONDITION by default for safe rollout Follow the Go SDK pattern (PR #2153) per reviewer feedback: 1. Use checkSdkFlag instead of tryUseSdkFlag so the flag is NOT auto-enabled for new workflows. Add TODO to switch to tryUseSdkFlag in the next release. 2. Remove CANCEL_AWAIT_TIMER_ON_CONDITION from initialFlags. 3. Tests explicitly toggle the flag to verify both old behavior (timer NOT cancelled) and new behavior (timer cancelled). --- .../statemachines/WorkflowStateMachines.java | 4 +- .../internal/sync/SyncWorkflowContext.java | 4 +- ...rkflowAwaitCancelTimerOnConditionTest.java | 110 +++++++++++------- 3 files changed, 72 insertions(+), 46 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java index 6223053e4b..2f2c716f24 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java @@ -52,9 +52,7 @@ enum HandleEventStatus { /** Initial set of SDK flags that will be set on all new workflow executions. */ @VisibleForTesting public static List initialFlags = - Collections.unmodifiableList( - Arrays.asList( - SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION, SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION)); + Collections.unmodifiableList(Arrays.asList(SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION)); /** * Keep track of the change versions that have been seen by the SDK. This is used to generate the diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index f9cf78536c..ede3f0847f 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -1317,8 +1317,10 @@ public void sleep(Duration duration) { @Override public boolean await(Duration timeout, String reason, Supplier unblockCondition) { + // TODO: Change checkSdkFlag to tryUseSdkFlag in the next release to enable this flag by + // default. boolean cancelTimerOnCondition = - replayContext.tryUseSdkFlag(SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION); + replayContext.checkSdkFlag(SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION); // If new behavior is enabled and condition is already satisfied, skip creating timer if (cancelTimerOnCondition && unblockCondition.get()) { diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java index 56689f2f74..2f5f2caab5 100644 --- a/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java +++ b/temporal-sdk/src/test/java/io/temporal/workflow/cancellationTests/WorkflowAwaitCancelTimerOnConditionTest.java @@ -6,6 +6,8 @@ import io.temporal.api.enums.v1.EventType; import io.temporal.client.WorkflowClient; import io.temporal.client.WorkflowStub; +import io.temporal.internal.common.SdkFlag; +import io.temporal.internal.statemachines.WorkflowStateMachines; import io.temporal.testing.WorkflowReplayer; import io.temporal.testing.internal.SDKTestWorkflowRule; import io.temporal.workflow.SignalMethod; @@ -13,20 +15,25 @@ import io.temporal.workflow.WorkflowInterface; import io.temporal.workflow.WorkflowMethod; import java.time.Duration; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import org.junit.After; +import org.junit.Before; import org.junit.Rule; import org.junit.Test; /** - * Tests for the CANCEL_AWAIT_TIMER_ON_CONDITION SDK flag behavior. This flag ensures that - * Workflow.await(duration, condition) cancels the timer when the condition is satisfied before the - * timeout. + * Tests for the CANCEL_AWAIT_TIMER_ON_CONDITION SDK flag behavior. Tests verify both old and new + * behavior by explicitly switching the SDK flag, following the Go SDK pattern. * - *

Note: These tests verify the NEW behavior (timer cancellation) for new workflows. Replay - * compatibility for old workflows (without the flag) is preserved by the SDK flag mechanism - the - * old behavior is only used during replay of histories that were recorded without the flag. + *

Since the flag is NOT auto-enabled (uses checkSdkFlag, not tryUseSdkFlag), tests must + * explicitly add it to initialFlags to enable the new behavior. */ public class WorkflowAwaitCancelTimerOnConditionTest { + private List savedInitialFlags; + @Rule public SDKTestWorkflowRule testWorkflowRule = SDKTestWorkflowRule.newBuilder() @@ -36,28 +43,37 @@ public class WorkflowAwaitCancelTimerOnConditionTest { TestReturnValueWorkflowImpl.class) .build(); + @Before + public void setUp() { + savedInitialFlags = WorkflowStateMachines.initialFlags; + } + + @After + public void tearDown() { + WorkflowStateMachines.initialFlags = savedInitialFlags; + } + /** - * Tests that the timer is cancelled when the await condition is satisfied before the timeout. - * With the CANCEL_AWAIT_TIMER_ON_CONDITION flag enabled, we expect to see a TIMER_CANCELED event - * in the history when a signal satisfies the condition. + * Tests that the timer IS cancelled when the flag is explicitly enabled. With + * CANCEL_AWAIT_TIMER_ON_CONDITION in initialFlags, we expect TIMER_CANCELED in history. */ @Test - public void testTimerCancelledWhenConditionSatisfied() { + public void testTimerCancelledWhenFlagEnabled() { + WorkflowStateMachines.initialFlags = + Collections.unmodifiableList( + Arrays.asList( + SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION, SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION)); + TestAwaitWorkflow workflow = testWorkflowRule.newWorkflowStub(TestAwaitWorkflow.class); WorkflowExecution execution = WorkflowClient.start(workflow::execute); - // Wait a bit for workflow to start and begin awaiting testWorkflowRule.sleep(Duration.ofMillis(500)); - - // Signal to satisfy the condition workflow.unblock(); - // Get the result WorkflowStub untyped = WorkflowStub.fromTyped(workflow); String result = untyped.getResult(String.class); assertEquals("condition satisfied", result); - // Verify timer was started and then cancelled testWorkflowRule.assertHistoryEvent( execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_STARTED); testWorkflowRule.assertHistoryEvent( @@ -65,49 +81,72 @@ public void testTimerCancelledWhenConditionSatisfied() { } /** - * Tests that no timer is created when the condition is already satisfied at the time of the await - * call. With the CANCEL_AWAIT_TIMER_ON_CONDITION flag enabled, if the condition is immediately - * true, we skip creating a timer entirely. + * Tests that the timer is NOT cancelled when the flag is disabled (default). Without the flag in + * initialFlags, the old behavior is used: timer runs even after condition is satisfied. + */ + @Test + public void testTimerNotCancelledWhenFlagDisabled() { + // Default initialFlags do NOT include CANCEL_AWAIT_TIMER_ON_CONDITION + TestAwaitWorkflow workflow = testWorkflowRule.newWorkflowStub(TestAwaitWorkflow.class); + WorkflowExecution execution = WorkflowClient.start(workflow::execute); + + testWorkflowRule.sleep(Duration.ofMillis(500)); + workflow.unblock(); + + WorkflowStub untyped = WorkflowStub.fromTyped(workflow); + String result = untyped.getResult(String.class); + assertEquals("condition satisfied", result); + + testWorkflowRule.assertHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_STARTED); + testWorkflowRule.assertNoHistoryEvent( + execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_CANCELED); + } + + /** + * Tests that no timer is created when the condition is immediately true and the flag is enabled. */ @Test - public void testNoTimerWhenConditionImmediatelySatisfied() { + public void testNoTimerWhenConditionImmediatelySatisfiedWithFlag() { + WorkflowStateMachines.initialFlags = + Collections.unmodifiableList( + Arrays.asList( + SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION, SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION)); + TestImmediateConditionWorkflow workflow = testWorkflowRule.newWorkflowStub(TestImmediateConditionWorkflow.class); - WorkflowExecution execution = WorkflowClient.start(workflow::execute); WorkflowStub untyped = WorkflowStub.fromTyped(workflow); String result = untyped.getResult(String.class); assertEquals("immediate condition", result); - // Verify no timer was created testWorkflowRule.assertNoHistoryEvent( execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_STARTED); } /** * Tests that the await returns true when condition is satisfied and false when it times out. This - * verifies the return value semantics are preserved with the new flag, and that only the first - * timer (condition satisfied) is canceled while the second timer (timeout) fires normally. + * verifies the return value semantics are preserved with the new flag. */ @Test public void testAwaitReturnValue() { + WorkflowStateMachines.initialFlags = + Collections.unmodifiableList( + Arrays.asList( + SdkFlag.SKIP_YIELD_ON_DEFAULT_VERSION, SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION)); + TestReturnValueWorkflow workflow = testWorkflowRule.newWorkflowStub(TestReturnValueWorkflow.class); WorkflowExecution execution = WorkflowClient.start(workflow::execute); - // Signal to satisfy the first condition testWorkflowRule.sleep(Duration.ofMillis(500)); workflow.unblock(); WorkflowStub untyped = WorkflowStub.fromTyped(workflow); String result = untyped.getResult(String.class); - - // First await should return true (condition satisfied), second should return false (timeout) - // timedOut = !await(...) = !false = true when it times out assertEquals("conditionSatisfied=true,timedOut=true", result); - // Verify: first timer was canceled (condition satisfied), second timer fired (timeout) testWorkflowRule.assertHistoryEvent( execution.getWorkflowId(), EventType.EVENT_TYPE_TIMER_CANCELED); testWorkflowRule.assertHistoryEvent( @@ -116,15 +155,7 @@ public void testAwaitReturnValue() { /** * Tests replay compatibility with old workflow histories that were recorded WITHOUT the - * CANCEL_AWAIT_TIMER_ON_CONDITION flag. This ensures the SDK can correctly replay workflows that - * used the old behavior (timer not cancelled when condition satisfied). - * - *

The history file (awaitTimerConditionOldBehavior.json) contains a workflow execution that: - * 1. Started with Workflow.await(1 hour, condition) 2. Received a signal that satisfied the - * condition 3. Completed without canceling the timer (old behavior) - * - *

The replay should succeed without throwing a non-determinism error, proving that the new SDK - * code correctly falls back to old behavior when replaying histories without the flag. + * CANCEL_AWAIT_TIMER_ON_CONDITION flag. */ @Test public void testReplayOldHistoryWithoutFlag() throws Exception { @@ -174,7 +205,6 @@ public void unblock() { public static class TestImmediateConditionWorkflowImpl implements TestImmediateConditionWorkflow { @Override public String execute() { - // Condition is immediately true boolean result = Workflow.await(Duration.ofHours(1), () -> true); return result ? "immediate condition" : "unexpected timeout"; } @@ -185,12 +215,8 @@ public static class TestReturnValueWorkflowImpl implements TestReturnValueWorkfl @Override public String execute() { - // First await: condition will be satisfied by signal boolean conditionSatisfied = Workflow.await(Duration.ofHours(1), () -> unblocked); - - // Second await: condition will never be satisfied, should timeout boolean timedOut = !Workflow.await(Duration.ofMillis(100), () -> false); - return "conditionSatisfied=" + conditionSatisfied + ",timedOut=" + timedOut; } From 4e91e0054bce026d0ea4d4187fdf43f99585cd5b Mon Sep 17 00:00:00 2001 From: Maxim Fateev <1463622+mfateev@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:05:01 -0800 Subject: [PATCH 3/3] Move early condition check inside cancelTimerOnCondition branch --- .../temporal/internal/sync/SyncWorkflowContext.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java index ede3f0847f..010458b64b 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java @@ -1322,14 +1322,12 @@ public boolean await(Duration timeout, String reason, Supplier unblockC boolean cancelTimerOnCondition = replayContext.checkSdkFlag(SdkFlag.CANCEL_AWAIT_TIMER_ON_CONDITION); - // If new behavior is enabled and condition is already satisfied, skip creating timer - if (cancelTimerOnCondition && unblockCondition.get()) { - return true; - } - if (cancelTimerOnCondition) { - // New behavior: create timer in a cancellation scope so we can cancel it when condition is - // satisfied + // If condition is already satisfied, skip creating timer + if (unblockCondition.get()) { + return true; + } + // Create timer in a cancellation scope so we can cancel it when condition is satisfied CompletablePromise timer = Workflow.newPromise(); CancellationScope timerScope = Workflow.newCancellationScope(() -> timer.completeFrom(newTimer(timeout)));