Skip to content

Commit dab1ab6

Browse files
authored
feat: isSubjectToReplay
1 parent 3ef4c6f commit dab1ab6

8 files changed

Lines changed: 98 additions & 33 deletions

File tree

‎temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,9 +355,11 @@ public Integer getVersion(
355355
: (min, max) ->
356356
WorkflowInternal.readOnly(
357357
() ->
358-
preferredVersionProvider.getPreferredVersion(
359-
new PreferredVersionProviderInput(
360-
WorkflowInternal.getWorkflowInfo(), changeId, min, max))),
358+
WorkflowInternal.notSubjectToReplay(
359+
() ->
360+
preferredVersionProvider.getPreferredVersion(
361+
new PreferredVersionProviderInput(
362+
WorkflowInternal.getWorkflowInfo(), changeId, min, max)))),
361363
callback);
362364
}
363365

‎temporal-sdk/src/main/java/io/temporal/internal/sync/QueryDispatcher.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ public Optional<Payloads> handleQuery(
9999
}
100100
try {
101101
replayContext.setReadOnly(true);
102+
replayContext.setSubjectToReplay(false);
102103
queryHandlerWorkflowContext.set(replayContext);
103104
Object result =
104105
inboundCallsInterceptor
@@ -107,6 +108,7 @@ public Optional<Payloads> handleQuery(
107108
return dataConverterWithWorkflowContext.toPayloads(result);
108109
} finally {
109110
replayContext.setReadOnly(false);
111+
replayContext.setSubjectToReplay(true);
110112
queryHandlerWorkflowContext.set(null);
111113
}
112114
}

‎temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflow.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ public void handleUpdate(
158158
if (!callbacks.isReplaying()) {
159159
try {
160160
workflowContext.setReadOnly(true);
161+
workflowContext.setSubjectToReplay(false);
161162
workflowProc.handleValidateUpdate(updateName, updateId, input, eventId, header);
162163
} catch (ReadOnlyException r) {
163164
// Rethrow instead on rejecting the update to fail the WFT
@@ -173,6 +174,7 @@ public void handleUpdate(
173174
return;
174175
} finally {
175176
workflowContext.setReadOnly(false);
177+
workflowContext.setSubjectToReplay(true);
176178
}
177179
}
178180
callbacks.accept();

‎temporal-sdk/src/main/java/io/temporal/internal/sync/SyncWorkflowContext.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ final class SyncWorkflowContext implements WorkflowContext, WorkflowOutboundCall
102102
private NexusServiceOptions defaultNexusServiceOptions = null;
103103
private Map<String, NexusServiceOptions> nexusServiceOptionsMap;
104104
private boolean readOnly = false;
105+
private boolean subjectToReplay = true;
105106
private final WorkflowThreadLocal<UpdateInfo> currentUpdateInfo = new WorkflowThreadLocal<>();
106107
@Nullable private String currentDetails;
107108

@@ -1091,10 +1092,12 @@ public <R> R sideEffect(
10911092
() -> {
10921093
try {
10931094
readOnly = true;
1095+
subjectToReplay = false;
10941096
R r = func.apply();
10951097
return dataConverterWithCurrentWorkflowContext.toPayloads(r);
10961098
} finally {
10971099
readOnly = false;
1100+
subjectToReplay = true;
10981101
}
10991102
},
11001103
userMetadata,
@@ -1157,6 +1160,7 @@ private <R> R mutableSideEffectImpl(
11571160
0, Optional.of(b), resultClass, resultType));
11581161
try {
11591162
readOnly = true;
1163+
subjectToReplay = false;
11601164
R funcResult =
11611165
Objects.requireNonNull(
11621166
func.apply(), "mutableSideEffect function " + "returned null");
@@ -1167,6 +1171,7 @@ private <R> R mutableSideEffectImpl(
11671171
return Optional.empty(); // returned only when value doesn't need to be updated
11681172
} finally {
11691173
readOnly = false;
1174+
subjectToReplay = true;
11701175
}
11711176
},
11721177
(p) ->
@@ -1309,6 +1314,14 @@ void setReadOnly(boolean readOnly) {
13091314
this.readOnly = readOnly;
13101315
}
13111316

1317+
boolean isSubjectToReplay() {
1318+
return subjectToReplay;
1319+
}
1320+
1321+
void setSubjectToReplay(boolean subjectToReplay) {
1322+
this.subjectToReplay = subjectToReplay;
1323+
}
1324+
13121325
@Override
13131326
public Map<Long, SignalHandlerInfo> getRunningSignalHandlers() {
13141327
return signalDispatcher.getRunningSignalHandlers();

‎temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -735,6 +735,17 @@ public static <T> T readOnly(Functions.Func<T> func) {
735735
}
736736
}
737737

738+
public static <T> T notSubjectToReplay(Functions.Func<T> func) {
739+
SyncWorkflowContext workflowContext = getRootWorkflowContext();
740+
boolean previousSubjectToReplay = workflowContext.isSubjectToReplay();
741+
workflowContext.setSubjectToReplay(false);
742+
try {
743+
return func.apply();
744+
} finally {
745+
workflowContext.setSubjectToReplay(previousSubjectToReplay);
746+
}
747+
}
748+
738749
public static WorkflowInfo getWorkflowInfo() {
739750
return new WorkflowInfoImpl(getRootWorkflowContext().getReplayContext());
740751
}
@@ -940,16 +951,20 @@ static SyncWorkflowContext getRootWorkflowContext() {
940951
return DeterministicRunnerImpl.currentThreadInternal().getWorkflowContext();
941952
}
942953

943-
public static boolean isReadOnly() {
954+
static boolean isReadOnly() {
944955
return getRootWorkflowContext().isReadOnly();
945956
}
946957

947-
public static void assertNotReadOnly(String action) {
958+
static void assertNotReadOnly(String action) {
948959
if (isReadOnly()) {
949960
throw new ReadOnlyException(action);
950961
}
951962
}
952963

964+
public static boolean isSubjectToReplay() {
965+
return getRootWorkflowContext().isSubjectToReplay();
966+
}
967+
953968
static void assertNotInUpdateHandler(String message) {
954969
if (getCurrentUpdateInfo().isPresent()) {
955970
throw new UnsupportedContinueAsNewRequest(message);

‎temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1010,8 +1010,8 @@ public static Random newRandom() {
10101010
* draws left it. A Workflow Reset replays the same values up to the reset point, then reseeds the
10111011
* stream for the new Run. Each Continue-As-New Run gets a new sequence.
10121012
*
1013-
* <p>Draws are not recorded in Workflow History, so do not draw in read-only code. Use {@link
1014-
* WorkflowUnsafe#isReadOnly()} to gate draws.
1013+
* <p>Draws are not recorded in Workflow History, so only draw where the code is re-executed on
1014+
* replay. Use {@link WorkflowUnsafe#isSubjectToReplay()} to gate draws.
10151015
*
10161016
* <p>Use a stable package-style name. Stream names are retained for the life of the Workflow Run.
10171017
* The stream is deterministic pseudorandomness and is not cryptographically secure.

‎temporal-sdk/src/main/java/io/temporal/workflow/unsafe/WorkflowUnsafe.java‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,18 +48,21 @@ public static boolean isReplaying() {
4848
}
4949

5050
/**
51-
* Reports whether the current code is running where Workflow state cannot be mutated.
51+
* Reports whether the currently executing code is re-executed when the Workflow replays.
5252
*
53-
* <p>Read-only code includes Query handlers, Update validators, Side Effect functions, Await
54-
* conditions, and other SDK callbacks that must not mutate Workflow state.
53+
* <p>Unlike {@link #isReplaying()}, this is a property of the calling context rather than of the
54+
* Workflow's current state. The Workflow method and its constructor, signal and update handlers,
55+
* and Await conditions are subject to replay. Query handlers, Update validators, and Side Effect
56+
* functions run once against the current state and are never re-executed, so they are not subject
57+
* to replay even while {@link #isReplaying()} reports true.
5558
*
5659
* <p>Must be called from Workflow code.
5760
*
58-
* @return true in a read-only Workflow context
61+
* @return true if the calling context is re-executed on replay
5962
*/
6063
@Experimental
61-
public static boolean isReadOnly() {
62-
return WorkflowInternal.isReadOnly();
64+
public static boolean isSubjectToReplay() {
65+
return WorkflowInternal.isSubjectToReplay();
6366
}
6467

6568
/**

temporal-sdk/src/test/java/io/temporal/workflow/WorkflowUnsafeReadOnlyTest.java renamed to temporal-sdk/src/test/java/io/temporal/workflow/WorkflowUnsafeSubjectToReplayTest.java

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,32 @@
99
import io.temporal.common.interceptors.WorkflowInboundCallsInterceptorBase;
1010
import io.temporal.testing.internal.SDKTestWorkflowRule;
1111
import io.temporal.worker.WorkerFactoryOptions;
12+
import io.temporal.worker.WorkerOptions;
1213
import io.temporal.workflow.unsafe.WorkflowUnsafe;
1314
import java.util.Map;
1415
import java.util.concurrent.ConcurrentHashMap;
1516
import org.junit.Before;
1617
import org.junit.Rule;
1718
import org.junit.Test;
1819

19-
public class WorkflowUnsafeReadOnlyTest {
20+
public class WorkflowUnsafeSubjectToReplayTest {
2021
private static final Map<String, Boolean> calls = new ConcurrentHashMap<>();
2122

2223
@Rule
2324
public SDKTestWorkflowRule testWorkflowRule =
2425
SDKTestWorkflowRule.newBuilder()
25-
.setWorkflowTypes(ReadOnlyWorkflowImpl.class)
26+
.setWorkflowTypes(SubjectToReplayWorkflowImpl.class)
2627
.setWorkerFactoryOptions(
2728
WorkerFactoryOptions.newBuilder()
28-
.setWorkerInterceptors(new ReadOnlyRecordingInterceptor())
29+
.setWorkerInterceptors(new SubjectToReplayRecordingInterceptor())
30+
.build())
31+
.setWorkerOptions(
32+
WorkerOptions.newBuilder()
33+
.setPreferredVersionProvider(
34+
input -> {
35+
record("versionProvider");
36+
return null;
37+
})
2938
.build())
3039
.build();
3140

@@ -34,10 +43,14 @@ public void setUp() {
3443
calls.clear();
3544
}
3645

46+
/**
47+
* Subjection to replay is a property of the calling context rather than of the Workflow's current
48+
* state, so running once live covers the whole contract.
49+
*/
3750
@Test
38-
public void isReadOnly() {
39-
ReadOnlyWorkflow workflow =
40-
testWorkflowRule.newWorkflowStubTimeoutOptions(ReadOnlyWorkflow.class);
51+
public void isSubjectToReplay() {
52+
SubjectToReplayWorkflow workflow =
53+
testWorkflowRule.newWorkflowStubTimeoutOptions(SubjectToReplayWorkflow.class);
4154
WorkflowClient.start(workflow::run);
4255

4356
workflow.query();
@@ -46,26 +59,32 @@ public void isReadOnly() {
4659
WorkflowStub.fromTyped(workflow).getResult(Void.class);
4760

4861
Map<String, Boolean> expected = new ConcurrentHashMap<>();
49-
expected.put("ExecuteWorkflow", false);
50-
expected.put("workflowTask", false);
51-
expected.put("ExecuteUpdate", false);
52-
expected.put("updateHandler", false);
53-
expected.put("HandleSignal", false);
54-
expected.put("sideEffect", true);
62+
// The durable Workflow path re-executes on every replay.
63+
expected.put("ExecuteWorkflow", true);
64+
expected.put("workflowTask", true);
65+
expected.put("ExecuteUpdate", true);
66+
expected.put("updateHandler", true);
67+
expected.put("HandleSignal", true);
68+
// An Await condition is read-only yet still re-evaluated on replay. This is the one context
69+
// where subjection to replay and read-only disagree, and the reason the two are separate.
5570
expected.put("await", true);
56-
expected.put("HandleQuery", true);
57-
expected.put("query", true);
58-
expected.put("ValidateUpdate", true);
59-
expected.put("validator", true);
71+
// Live callbacks run once against current state and are never re-executed from history.
72+
expected.put("sideEffect", false);
73+
expected.put("mutableSideEffect", false);
74+
expected.put("versionProvider", false);
75+
expected.put("HandleQuery", false);
76+
expected.put("query", false);
77+
expected.put("ValidateUpdate", false);
78+
expected.put("validator", false);
6079
assertEquals(expected, calls);
6180
}
6281

6382
private static void record(String name) {
64-
calls.put(name, WorkflowUnsafe.isReadOnly());
83+
calls.put(name, WorkflowUnsafe.isSubjectToReplay());
6584
}
6685

6786
@WorkflowInterface
68-
public interface ReadOnlyWorkflow {
87+
public interface SubjectToReplayWorkflow {
6988
@WorkflowMethod
7089
void run();
7190

@@ -82,18 +101,27 @@ public interface ReadOnlyWorkflow {
82101
void finish();
83102
}
84103

85-
public static class ReadOnlyWorkflowImpl implements ReadOnlyWorkflow {
104+
public static class SubjectToReplayWorkflowImpl implements SubjectToReplayWorkflow {
86105
private boolean finished;
87106

88107
@Override
89108
public void run() {
90109
record("workflowTask");
110+
Workflow.getVersion("change", Workflow.DEFAULT_VERSION, 1);
91111
Workflow.sideEffect(
92112
Void.class,
93113
() -> {
94114
record("sideEffect");
95115
return null;
96116
});
117+
Workflow.mutableSideEffect(
118+
"id",
119+
Integer.class,
120+
Integer::equals,
121+
() -> {
122+
record("mutableSideEffect");
123+
return 1;
124+
});
97125
Workflow.await(
98126
() -> {
99127
record("await");
@@ -123,7 +151,7 @@ public void finish() {
123151
}
124152
}
125153

126-
private static class ReadOnlyRecordingInterceptor extends WorkerInterceptorBase {
154+
private static class SubjectToReplayRecordingInterceptor extends WorkerInterceptorBase {
127155
@Override
128156
public WorkflowInboundCallsInterceptor interceptWorkflow(WorkflowInboundCallsInterceptor next) {
129157
return new WorkflowInboundCallsInterceptorBase(next) {

0 commit comments

Comments
 (0)