Skip to content

Commit 055f283

Browse files
IWF-174: Incorrect overriding stateOptions when reusing a static valu… (#279)
1 parent fa37be6 commit 055f283

File tree

7 files changed

+176
-10
lines changed

7 files changed

+176
-10
lines changed

src/main/java/io/iworkflow/core/Client.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ public String startWorkflow(
172172
throw new WorkflowDefinitionException(String.format("input cannot be assigned to the starting state, input type: %s, starting state input type: %s", input.getClass(), registeredInputType));
173173
}
174174

175-
WorkflowStateOptions stateOptions = StateMovementMapper.validateAndGetStateOptions(stateDef);
175+
WorkflowStateOptions stateOptions = StateMovementMapper.validateAndGetStateOptionsCopy(stateDef);
176176
if (shouldSkipWaitUntil(stateDef.getWorkflowState())) {
177177
if (stateOptions == null) {
178178
stateOptions = new WorkflowStateOptions().skipWaitUntil(true);

src/main/java/io/iworkflow/core/WorkflowStateOptionsExtension.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import io.iworkflow.gen.models.WaitUntilApiFailurePolicy;
55
import io.iworkflow.gen.models.WorkflowStateOptions;
66

7+
import java.util.Objects;
8+
79
/**
810
* WorkflowStateOptionsExtension provides extension to WorkflowStateOptions
911
* to make it easier to build some fields of the stateOptions.
@@ -21,6 +23,7 @@
2123
* }
2224
*/
2325
public class WorkflowStateOptionsExtension extends WorkflowStateOptions {
26+
private static final JacksonJsonObjectEncoder JSON_ENCODER = new JacksonJsonObjectEncoder();
2427

2528
/**
2629
* By default, workflow would fail after execute API retry exhausted.
@@ -96,4 +99,23 @@ public WorkflowStateOptionsExtension setProceedOnExecuteFailure(final Class<? ex
9699
this.executeApiFailureProceedStateOptions(stateOptionsOverride);
97100
return this;
98101
}
102+
103+
// TODO: This is a workaround due to openapi-generator's "generateBuilders" config not working.
104+
// https://openapi-generator.tech/docs/generators/java/#config-options
105+
// I have opened a ticket with them (https://github.com/OpenAPITools/openapi-generator/issues/20320).
106+
/**
107+
* Uses JSON serialization to deep copy WorkflowStateOptions.
108+
* @param stateOptions the state options to deep copy.
109+
* @return the newly created copy.
110+
*/
111+
public static WorkflowStateOptions deepCopyStateOptions(WorkflowStateOptions stateOptions) {
112+
final WorkflowStateOptions deepCopy =
113+
stateOptions == null ? null : JSON_ENCODER.decode(JSON_ENCODER.encode(stateOptions), stateOptions.getClass());
114+
115+
if (!Objects.deepEquals(stateOptions, deepCopy)) {
116+
throw new ObjectEncoderException("Deep copy of WorkflowStateOptions did not match.");
117+
}
118+
119+
return deepCopy;
120+
}
99121
}

src/main/java/io/iworkflow/core/mapper/StateMovementMapper.java

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import static io.iworkflow.core.StateMovement.RESERVED_STATE_ID_PREFIX;
1515
import static io.iworkflow.core.WorkflowState.shouldSkipWaitUntil;
16+
import static io.iworkflow.core.WorkflowStateOptionsExtension.deepCopyStateOptions;
1617

1718
public class StateMovementMapper {
1819

@@ -28,9 +29,12 @@ public static StateMovement toGenerated(final io.iworkflow.core.StateMovement st
2829
}
2930

3031
// Try to get the overrode stateOptions, if it's null, get the stateOptions from stateDef
31-
WorkflowStateOptions stateOptions = stateMovement.getStateOptionsOverride().orElse(null);
32-
if (stateOptions == null) {
33-
stateOptions = StateMovementMapper.validateAndGetStateOptions(stateDef);
32+
WorkflowStateOptions stateOptions;
33+
if (stateMovement.getStateOptionsOverride().isPresent()) {
34+
// Always deep copy the state options so we don't modify the original
35+
stateOptions = deepCopyStateOptions(stateMovement.getStateOptionsOverride().get());
36+
} else {
37+
stateOptions = StateMovementMapper.validateAndGetStateOptionsCopy(stateDef);
3438
}
3539

3640
if (shouldSkipWaitUntil(stateDef.getWorkflowState())) {
@@ -62,7 +66,7 @@ public static void autoFillFailureProceedingStateOptions(WorkflowStateOptions st
6266
// fill the state options for the proceeding state
6367
String proceedStateId = stateOptions.getExecuteApiFailureProceedStateId();
6468
final StateDef proceedStatDef = registry.getWorkflowState(workflowType, proceedStateId);
65-
WorkflowStateOptions proceedStateOptions = StateMovementMapper.validateAndGetStateOptions(proceedStatDef);
69+
WorkflowStateOptions proceedStateOptions = StateMovementMapper.validateAndGetStateOptionsCopy(proceedStatDef);
6670
if (proceedStateOptions != null &&
6771
proceedStateOptions.getExecuteApiFailurePolicy() == ExecuteApiFailurePolicy.PROCEED_TO_CONFIGURED_STATE) {
6872
throw new WorkflowDefinitionException("nested failure handling is not supported. You cannot set a failure proceeding state on top of another failure proceeding state.");
@@ -80,9 +84,10 @@ public static void autoFillFailureProceedingStateOptions(WorkflowStateOptions st
8084
}
8185
}
8286

83-
public static WorkflowStateOptions validateAndGetStateOptions(final StateDef stateDef){
87+
public static WorkflowStateOptions validateAndGetStateOptionsCopy(final StateDef stateDef){
8488
final WorkflowState state = stateDef.getWorkflowState();
85-
WorkflowStateOptions stateOptions = state.getStateOptions();
89+
// Always deep copy the state options so we don't modify the original
90+
WorkflowStateOptions stateOptions = deepCopyStateOptions(state.getStateOptions());
8691
if (stateOptions == null){
8792
return null;
8893
}

src/test/java/io/iworkflow/integ/BasicTest.java

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,33 @@
1414
import io.iworkflow.gen.models.Context;
1515
import io.iworkflow.gen.models.ErrorSubStatus;
1616
import io.iworkflow.gen.models.IDReusePolicy;
17+
import io.iworkflow.gen.models.PersistenceLoadingPolicy;
18+
import io.iworkflow.gen.models.PersistenceLoadingType;
19+
import io.iworkflow.gen.models.RetryPolicy;
1720
import io.iworkflow.gen.models.WorkflowConfig;
21+
import io.iworkflow.gen.models.WorkflowStateOptions;
1822
import io.iworkflow.gen.models.WorkflowStatus;
1923
import io.iworkflow.integ.basic.AbnormalExitWorkflow;
2024
import io.iworkflow.integ.basic.BasicWorkflow;
21-
import io.iworkflow.integ.basic.BasicWorkflowState1;
2225
import io.iworkflow.integ.basic.BasicWorkflowState2;
2326
import io.iworkflow.integ.basic.EmptyInputWorkflow;
2427
import io.iworkflow.integ.basic.EmptyInputWorkflowState1;
2528
import io.iworkflow.integ.basic.FakContextImpl;
29+
import io.iworkflow.integ.basic.MixOfWithWaitUntilAndSkipWaitUntilWorkflow;
2630
import io.iworkflow.integ.basic.ModelInputWorkflow;
2731
import io.iworkflow.integ.basic.ProceedOnStateStartFailWorkflow;
28-
import io.iworkflow.integ.timer.BasicTimerWorkflow;
29-
import io.iworkflow.integ.timer.BasicTimerWorkflowState1;
32+
import io.iworkflow.integ.stateoptions.StateOptionsWorkflow;
3033
import io.iworkflow.spring.TestSingletonWorkerService;
3134
import io.iworkflow.spring.controller.WorkflowRegistry;
3235
import org.junit.jupiter.api.Assertions;
3336
import org.junit.jupiter.api.BeforeEach;
3437
import org.junit.jupiter.api.Test;
3538

39+
import java.util.Collections;
3640
import java.util.concurrent.ExecutionException;
3741

42+
import static io.iworkflow.core.WorkflowStateOptionsExtension.deepCopyStateOptions;
43+
3844
public class BasicTest {
3945

4046
@BeforeEach
@@ -226,4 +232,33 @@ public void testWorkflowWaitForStateCompletionWithWaitForKey() {
226232
final WorkflowInfo childWorkflowInfo = client.describeWorkflow(childWfId);
227233
Assertions.assertEquals(WorkflowStatus.COMPLETED, childWorkflowInfo.getWorkflowStatus());
228234
}
235+
236+
@Test
237+
public void testMixOfWithWaitUntilAndSkipWaitUntilWorkflow() {
238+
final Client client = new Client(WorkflowRegistry.registry, ClientOptions.localDefault);
239+
final long startTs = System.currentTimeMillis();
240+
final String wfId = "wf-mix-of-with-wait-until-and-skip-wait-until-workflow-test-id" + startTs / 1000;
241+
final Integer input = 5;
242+
243+
client.startWorkflow(MixOfWithWaitUntilAndSkipWaitUntilWorkflow.class, wfId, 10, input);
244+
client.waitForWorkflowCompletion(wfId);
245+
}
246+
247+
@Test
248+
public void deepCopyWorkflowStateOptionsTest() {
249+
final WorkflowStateOptions origOptions =
250+
new WorkflowStateOptions().executeApiRetryPolicy(new RetryPolicy().maximumAttempts(3));
251+
origOptions.setSkipWaitUntil(true);
252+
origOptions.setExecuteApiFailureProceedStateId("execute-api-failure-proceed-state-id");
253+
origOptions.setSearchAttributesLoadingPolicy(new PersistenceLoadingPolicy().persistenceLoadingType(
254+
PersistenceLoadingType.PARTIAL_WITH_EXCLUSIVE_LOCK)
255+
.partialLoadingKeys(Collections.singletonList(StateOptionsWorkflow.DA_WAIT_UNTIL)));
256+
257+
final WorkflowStateOptions deepCopyOptions = deepCopyStateOptions(origOptions);
258+
Assertions.assertEquals(origOptions, deepCopyOptions);
259+
260+
// Verify changing a value in one object doesn't update both by reference
261+
origOptions.setSkipWaitUntil(false);
262+
Assertions.assertNotEquals(origOptions, deepCopyOptions);
263+
}
229264
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package io.iworkflow.integ.basic;
2+
3+
import io.iworkflow.core.Context;
4+
import io.iworkflow.core.StateDecision;
5+
import io.iworkflow.core.WorkflowState;
6+
import io.iworkflow.core.command.CommandResults;
7+
import io.iworkflow.core.communication.Communication;
8+
import io.iworkflow.core.persistence.Persistence;
9+
import io.iworkflow.gen.models.WorkflowStateOptions;
10+
11+
import static io.iworkflow.integ.basic.MixOfWithWaitUntilAndSkipWaitUntilWorkflow.SHARED_STATE_OPTIONS;
12+
13+
public class MixOfWithWaitUntilAndSkipWaitUntilState1 implements WorkflowState<Integer> {
14+
15+
@Override
16+
public Class<Integer> getInputType() {
17+
return Integer.class;
18+
}
19+
20+
@Override
21+
public StateDecision execute(final Context context, final Integer input, final CommandResults commandResults, Persistence persistence, final Communication communication) {
22+
final int output = input + 1;
23+
return StateDecision.singleNextState(MixOfWithWaitUntilAndSkipWaitUntilState2.class, output);
24+
}
25+
26+
@Override
27+
public WorkflowStateOptions getStateOptions() {
28+
return SHARED_STATE_OPTIONS;
29+
}
30+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package io.iworkflow.integ.basic;
2+
3+
import io.iworkflow.core.Context;
4+
import io.iworkflow.core.StateDecision;
5+
import io.iworkflow.core.WorkflowState;
6+
import io.iworkflow.core.command.CommandRequest;
7+
import io.iworkflow.core.command.CommandResults;
8+
import io.iworkflow.core.command.TimerCommand;
9+
import io.iworkflow.core.communication.Communication;
10+
import io.iworkflow.core.persistence.Persistence;
11+
import io.iworkflow.gen.models.WorkflowStateOptions;
12+
13+
import java.time.Duration;
14+
15+
import static io.iworkflow.integ.basic.MixOfWithWaitUntilAndSkipWaitUntilWorkflow.SHARED_STATE_OPTIONS;
16+
17+
public class MixOfWithWaitUntilAndSkipWaitUntilState2 implements WorkflowState<Integer> {
18+
19+
@Override
20+
public Class<Integer> getInputType() {
21+
return Integer.class;
22+
}
23+
24+
@Override
25+
public CommandRequest waitUntil(
26+
final Context context,
27+
final Integer input,
28+
Persistence persistence,
29+
final Communication communication) {
30+
return CommandRequest.forAllCommandCompleted(TimerCommand.createByDuration(Duration.ofSeconds(1)));
31+
}
32+
33+
@Override
34+
public StateDecision execute(
35+
final Context context,
36+
final Integer input,
37+
final CommandResults commandResults,
38+
Persistence persistence,
39+
final Communication communication) {
40+
final int output = input + 1;
41+
commandResults.getAllTimerCommandResults().get(0).getTimerStatus();
42+
return StateDecision.gracefulCompleteWorkflow(output);
43+
}
44+
45+
@Override
46+
public WorkflowStateOptions getStateOptions() {
47+
return SHARED_STATE_OPTIONS;
48+
}
49+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.iworkflow.integ.basic;
2+
3+
import io.iworkflow.core.ObjectWorkflow;
4+
import io.iworkflow.core.StateDef;
5+
import io.iworkflow.gen.models.RetryPolicy;
6+
import io.iworkflow.gen.models.WorkflowStateOptions;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.Arrays;
10+
import java.util.List;
11+
12+
@Component
13+
public class MixOfWithWaitUntilAndSkipWaitUntilWorkflow implements ObjectWorkflow {
14+
15+
public static WorkflowStateOptions SHARED_STATE_OPTIONS =
16+
new WorkflowStateOptions().executeApiRetryPolicy(new RetryPolicy().maximumAttempts(3));
17+
18+
@Override
19+
public List<StateDef> getWorkflowStates() {
20+
return Arrays.asList(
21+
StateDef.startingState(new MixOfWithWaitUntilAndSkipWaitUntilState1()),
22+
StateDef.nonStartingState(new MixOfWithWaitUntilAndSkipWaitUntilState2())
23+
);
24+
}
25+
}

0 commit comments

Comments
 (0)