diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContext.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContext.java index 982737dbee..b0f44bd6a9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContext.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContext.java @@ -14,6 +14,7 @@ import io.temporal.workflow.Functions; import io.temporal.workflow.Functions.Func; import io.temporal.workflow.Functions.Func1; +import io.temporal.workflow.WorkflowRandomStream; import java.time.Duration; import java.util.List; import java.util.Map; @@ -292,6 +293,9 @@ Integer getVersion( /** Replay safe random. */ Random newRandom(); + /** Replay safe named random stream. */ + WorkflowRandomStream getRandomStream(String name); + /** * @return scope to be used for metrics reporting. */ diff --git a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java index 2f600b20aa..0f31c6b4f9 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/replay/ReplayWorkflowContextImpl.java @@ -22,6 +22,7 @@ import io.temporal.workflow.Functions; import io.temporal.workflow.Functions.Func; import io.temporal.workflow.Functions.Func1; +import io.temporal.workflow.WorkflowRandomStream; import java.time.Duration; import java.util.*; import javax.annotation.Nonnull; @@ -81,6 +82,11 @@ public Random newRandom() { return workflowStateMachines.newRandom(); } + @Override + public WorkflowRandomStream getRandomStream(String name) { + return workflowStateMachines.getRandomStream(name); + } + @Override public Scope getMetricsScope() { return replayAwareWorkflowMetricsScope; diff --git a/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowRandomStreams.java b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowRandomStreams.java new file mode 100644 index 0000000000..870a2935d5 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowRandomStreams.java @@ -0,0 +1,105 @@ +package io.temporal.internal.statemachines; + +import io.temporal.workflow.WorkflowRandomStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +final class WorkflowRandomStreams { + private static final byte[] SEED_VERSION = + "temporal.sdk.random.v1".getBytes(StandardCharsets.UTF_8); + + private final Map streams = new HashMap<>(); + private String runId; + + WorkflowRandomStream get(String name) { + Objects.requireNonNull(name, "name"); + if (runId == null) { + throw new IllegalStateException("Workflow Run ID is not initialized"); + } + return streams.computeIfAbsent(name, key -> new Stream(deriveSeed(runId, key))); + } + + void updateRunId(String runId) { + this.runId = Objects.requireNonNull(runId, "runId"); + streams.forEach((name, stream) -> stream.reseed(deriveSeed(runId, name))); + } + + static byte[] deriveSeed(String runId, String name) { + MessageDigest digest = newSha256(); + digest.update(SEED_VERSION); + digest.update((byte) 0); + digest.update(runId.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(name.getBytes(StandardCharsets.UTF_8)); + return digest.digest(); + } + + private static MessageDigest newSha256() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is unavailable", e); + } + } + + private static final class Stream implements WorkflowRandomStream { + private final MessageDigest digest = newSha256(); + private byte[] seed; + private byte[] block = new byte[0]; + private int blockOffset; + private long counter; + + private Stream(byte[] seed) { + reseed(seed); + } + + @Override + public void nextBytes(byte[] bytes) { + Objects.requireNonNull(bytes, "bytes"); + int outputOffset = 0; + while (outputOffset < bytes.length) { + if (blockOffset == block.length) { + refill(); + } + int length = Math.min(bytes.length - outputOffset, block.length - blockOffset); + System.arraycopy(block, blockOffset, bytes, outputOffset, length); + blockOffset += length; + outputOffset += length; + } + } + + @Override + public long nextLong() { + byte[] bytes = new byte[Long.BYTES]; + nextBytes(bytes); + long value = 0; + for (byte current : bytes) { + value = (value << Byte.SIZE) | (current & 0xffL); + } + return value; + } + + private void refill() { + digest.reset(); + digest.update(seed); + for (int shift = Long.SIZE - Byte.SIZE; shift >= 0; shift -= Byte.SIZE) { + digest.update((byte) (counter >>> shift)); + } + counter++; + block = digest.digest(); + blockOffset = 0; + } + + private void reseed(byte[] seed) { + this.seed = Arrays.copyOf(seed, seed.length); + block = new byte[0]; + blockOffset = 0; + counter = 0; + } + } +} 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 2de6b6ea15..ff1145e41c 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 @@ -35,6 +35,7 @@ import io.temporal.workflow.ChildWorkflowCancellationType; import io.temporal.workflow.Functions; import io.temporal.workflow.NexusOperationCancellationType; +import io.temporal.workflow.WorkflowRandomStream; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.function.BiFunction; @@ -115,6 +116,8 @@ enum HandleEventStatus { /** Used Workflow.newRandom and randomUUID together with currentRunId. */ private long idCounter; + private final WorkflowRandomStreams randomStreams = new WorkflowRandomStreams(); + /** Current workflow time. */ private long currentTimeMillis = -1; @@ -845,6 +848,7 @@ private void handleNonStatefulEvent(HistoryEvent event, boolean hasNextEvent) { case EVENT_TYPE_WORKFLOW_EXECUTION_STARTED: this.currentRunId = event.getWorkflowExecutionStartedEventAttributes().getOriginalExecutionRunId(); + randomStreams.updateRunId(currentRunId); callbacks.start(event); break; case EVENT_TYPE_WORKFLOW_TASK_SCHEDULED: @@ -1195,6 +1199,11 @@ public Random newRandom() { return new Random(randomUUID().getLeastSignificantBits()); } + public WorkflowRandomStream getRandomStream(String name) { + checkEventLoopExecuting(); + return randomStreams.get(name); + } + public void sideEffect( Functions.Func> func, UserMetadata userMetadata, @@ -1548,6 +1557,7 @@ public void workflowTaskStarted( @Override public void updateRunId(String currentRunId) { WorkflowStateMachines.this.currentRunId = currentRunId; + randomStreams.updateRunId(currentRunId); } } diff --git a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java index 84b1e91fd3..4207cf75fd 100644 --- a/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java +++ b/temporal-sdk/src/main/java/io/temporal/internal/sync/WorkflowInternal.java @@ -744,6 +744,11 @@ public static Random newRandom() { return getRootWorkflowContext().newRandom(); } + public static WorkflowRandomStream getRandomStream(String name) { + assertNotReadOnly("random"); + return getRootWorkflowContext().getReplayContext().getRandomStream(name); + } + public static Logger getLogger(Class clazz) { Logger logger = LoggerFactory.getLogger(clazz); return new ReplayAwareLogger( @@ -919,8 +924,12 @@ static SyncWorkflowContext getRootWorkflowContext() { return DeterministicRunnerImpl.currentThreadInternal().getWorkflowContext(); } - static boolean isReadOnly() { - return getRootWorkflowContext().isReadOnly(); + public static boolean isReadOnly() { + if (QueryDispatcher.isQueryHandler()) { + return getRootWorkflowContext().isReadOnly(); + } + Optional thread = DeterministicRunnerImpl.currentThreadInternalIfPresent(); + return thread.isPresent() && getRootWorkflowContext().isReadOnly(); } static void assertNotReadOnly(String action) { diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java index d04a617d5d..b6d8a22189 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/Workflow.java @@ -711,6 +711,22 @@ public static Random newRandom() { return WorkflowInternal.newRandom(); } + /** + * Returns a deterministic pseudorandom stream private to {@code name}. + * + *

Calling this method again with the same name returns the same logical stream where earlier + * draws left it. A Workflow Reset replays the same values up to the reset point, then reseeds the + * stream for the new Run. Each Continue-As-New Run gets a new sequence. + * + *

Each draw advances Workflow state without recording an Event in Workflow History. Replay + * must make the same draws in the same order. Calling this method in read-only code fails; shared + * code can check {@link WorkflowUnsafe#isReadOnly()} first. + */ + @Experimental + public static WorkflowRandomStream getRandomStream(String name) { + return WorkflowInternal.getRandomStream(name); + } + /** * True if workflow code is being replayed. * diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowRandomStream.java b/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowRandomStream.java new file mode 100644 index 0000000000..369ccaabf8 --- /dev/null +++ b/temporal-sdk/src/main/java/io/temporal/workflow/WorkflowRandomStream.java @@ -0,0 +1,26 @@ +package io.temporal.workflow; + +import io.temporal.common.Experimental; +import io.temporal.workflow.unsafe.WorkflowUnsafe; + +/** + * A named deterministic pseudorandom stream for Workflow code. + * + *

Repeated calls to {@link Workflow#getRandomStream(String)} with the same name return the same + * logical stream at its current position. Different names do not affect each other's sequences. + * + *

Each draw advances Workflow state without recording an Event in Workflow History. Replay must + * make the same draws in the same order. Do not draw in read-only code; shared code can check + * {@link WorkflowUnsafe#isReadOnly()}. + * + *

Use a stable package-style name. Stream names are retained for the life of the Workflow Run. + */ +@Experimental +public interface WorkflowRandomStream { + + /** Fills {@code bytes} with the next bytes from this stream. */ + void nextBytes(byte[] bytes); + + /** Returns the next signed 64-bit value from this stream in big-endian byte order. */ + long nextLong(); +} diff --git a/temporal-sdk/src/main/java/io/temporal/workflow/unsafe/WorkflowUnsafe.java b/temporal-sdk/src/main/java/io/temporal/workflow/unsafe/WorkflowUnsafe.java index 1a67b4e8a6..69cb30e28c 100644 --- a/temporal-sdk/src/main/java/io/temporal/workflow/unsafe/WorkflowUnsafe.java +++ b/temporal-sdk/src/main/java/io/temporal/workflow/unsafe/WorkflowUnsafe.java @@ -1,5 +1,6 @@ package io.temporal.workflow.unsafe; +import io.temporal.common.Experimental; import io.temporal.internal.sync.WorkflowInternal; import io.temporal.workflow.Functions; @@ -46,6 +47,19 @@ public static boolean isReplaying() { return WorkflowInternal.isReplaying(); } + /** + * Reports whether the current code is running where Workflow state cannot be mutated. + * + *

Read-only code includes Query handlers, Update validators, Side Effect functions, Await + * conditions, and other SDK callbacks that must not mutate Workflow state. + * + * @return true in a read-only Workflow context, or false outside Workflow execution + */ + @Experimental + public static boolean isReadOnly() { + return WorkflowInternal.isReadOnly(); + } + /** * Runs the supplied procedure in the calling thread with disabled deadlock detection if called * from the workflow thread. Does nothing except the procedure execution if called from a diff --git a/temporal-sdk/src/test/java/io/temporal/internal/statemachines/WorkflowRandomStreamsTest.java b/temporal-sdk/src/test/java/io/temporal/internal/statemachines/WorkflowRandomStreamsTest.java new file mode 100644 index 0000000000..7977a4ca45 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/internal/statemachines/WorkflowRandomStreamsTest.java @@ -0,0 +1,89 @@ +package io.temporal.internal.statemachines; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertSame; + +import io.temporal.workflow.WorkflowRandomStream; +import java.util.Base64; +import org.junit.Test; + +public class WorkflowRandomStreamsTest { + private static final String RUN_ID = "runID"; + private static final String NAME = "io.temporal.test"; + + @Test + public void goldenSeedAndBytes() { + assertEquals( + "cYwA+k67hflWG1GVOzY897A19H2s16mOOzlic16UFtI=", + Base64.getEncoder().encodeToString(WorkflowRandomStreams.deriveSeed(RUN_ID, NAME))); + + WorkflowRandomStreams randoms = new WorkflowRandomStreams(); + randoms.updateRunId(RUN_ID); + byte[] bytes = new byte[32]; + randoms.get(NAME).nextBytes(bytes); + + assertEquals( + "wY4Mb9uhREeU08hmLgZRqar87inHCMHTkHCss0U8Wi0=", Base64.getEncoder().encodeToString(bytes)); + assertEquals(-4499645303130864569L, randoms(RUN_ID).get(NAME).nextLong()); + } + + @Test + public void seedFramingSeparatesRunIdAndName() { + assertNotEquals( + Base64.getEncoder().encodeToString(WorkflowRandomStreams.deriveSeed("ab", "c")), + Base64.getEncoder().encodeToString(WorkflowRandomStreams.deriveSeed("a", "bc"))); + } + + @Test + public void sameNameContinuesAndNamesAreIndependent() { + WorkflowRandomStreams interleaved = randoms(RUN_ID); + WorkflowRandomStream first = interleaved.get(NAME); + long firstValue = first.nextLong(); + long otherValue = interleaved.get("other").nextLong(); + WorkflowRandomStream second = interleaved.get(NAME); + long secondValue = second.nextLong(); + + WorkflowRandomStreams isolated = randoms(RUN_ID); + assertSame(first, second); + assertEquals(firstValue, isolated.get(NAME).nextLong()); + assertEquals(secondValue, isolated.get(NAME).nextLong()); + assertEquals(otherValue, isolated.get("other").nextLong()); + assertNotEquals(firstValue, otherValue); + } + + @Test + public void runIdUpdateReseedsExistingStreamInPlace() { + WorkflowRandomStreams randoms = randoms(RUN_ID); + WorkflowRandomStream before = randoms.get(NAME); + before.nextLong(); + + randoms.updateRunId("new-run"); + WorkflowRandomStream after = randoms.get(NAME); + + WorkflowRandomStreams fresh = randoms("new-run"); + assertSame(before, after); + assertEquals(fresh.get(NAME).nextLong(), after.nextLong()); + } + + @Test + public void streamCreatedAfterRunIdUpdateUsesNewRun() { + WorkflowRandomStreams randoms = randoms(RUN_ID); + randoms.get("before-reset").nextLong(); + randoms.updateRunId("new-run"); + + byte[] actual = new byte[32]; + randoms.get(NAME).nextBytes(actual); + + byte[] expected = new byte[32]; + randoms("new-run").get(NAME).nextBytes(expected); + assertArrayEquals(expected, actual); + } + + private static WorkflowRandomStreams randoms(String runId) { + WorkflowRandomStreams randoms = new WorkflowRandomStreams(); + randoms.updateRunId(runId); + return randoms; + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowRandomStreamResetTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowRandomStreamResetTest.java new file mode 100644 index 0000000000..b5ca2b03e8 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowRandomStreamResetTest.java @@ -0,0 +1,151 @@ +package io.temporal.workflow; + +import static io.temporal.api.enums.v1.EventType.EVENT_TYPE_WORKFLOW_TASK_COMPLETED; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assume.assumeTrue; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.activity.ActivityOptions; +import io.temporal.api.common.v1.WorkflowExecution; +import io.temporal.api.workflowservice.v1.ResetWorkflowExecutionRequest; +import io.temporal.api.workflowservice.v1.ResetWorkflowExecutionResponse; +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowOptions; +import io.temporal.client.WorkflowStub; +import io.temporal.client.WorkflowTargetOptions; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.testing.internal.SDKTestOptions; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import java.time.Duration; +import java.util.UUID; +import org.junit.Rule; +import org.junit.Test; + +public class WorkflowRandomStreamResetTest { + private static final String STREAM_NAME = "io.temporal.test"; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(ResetWorkflowImpl.class, LateStreamResetWorkflowImpl.class) + .setActivityImplementations(new BoundaryActivityImpl()) + .build(); + + @Test + public void resetReseedsHeldStreamAfterResetPoint() { + assumeTrue( + "Test Server doesn't support reset workflow", SDKTestWorkflowRule.useExternalService); + assertResetValues(ResetWorkflow.class); + } + + @Test + public void streamCreatedAfterResetPointUsesNewRun() { + assumeTrue( + "Test Server doesn't support reset workflow", SDKTestWorkflowRule.useExternalService); + assertResetValues(LateStreamResetWorkflow.class); + } + + private void assertResetValues(Class workflowType) { + String workflowId = UUID.randomUUID().toString(); + WorkflowClient client = testWorkflowRule.getWorkflowClient(); + WorkflowOptions options = + SDKTestOptions.newWorkflowOptionsWithTimeouts(testWorkflowRule.getTaskQueue()).toBuilder() + .setWorkflowId(workflowId) + .build(); + T workflow = client.newWorkflowStub(workflowType, options); + WorkflowStub stub = WorkflowStub.fromTyped(workflow); + stub.start(); + long[] original = stub.getResult(long[].class); + + WorkflowExecution execution = stub.getExecution(); + WorkflowExecutionHistory history = client.fetchHistory(workflowId); + long resetEventId = + history.getEvents().stream() + .filter(event -> event.getEventType() == EVENT_TYPE_WORKFLOW_TASK_COMPLETED) + .mapToLong(event -> event.getEventId()) + .max() + .orElseThrow(IllegalStateException::new); + + @SuppressWarnings("deprecation") + ResetWorkflowExecutionResponse response = + client + .getWorkflowServiceStubs() + .blockingStub() + .resetWorkflowExecution( + ResetWorkflowExecutionRequest.newBuilder() + .setNamespace(SDKTestWorkflowRule.NAMESPACE) + .setWorkflowExecution(execution) + .setWorkflowTaskFinishEventId(resetEventId) + .setReason("Integration test") + .setRequestId(UUID.randomUUID().toString()) + .build()); + + T resetWorkflow = + client.newWorkflowStub( + workflowType, + WorkflowTargetOptions.newBuilder() + .setWorkflowId(workflowId) + .setRunId(response.getRunId()) + .build()); + long[] afterReset = WorkflowStub.fromTyped(resetWorkflow).getResult(long[].class); + + assertEquals(original[0], afterReset[0]); + assertNotEquals(original[1], afterReset[1]); + } + + @WorkflowInterface + public interface ResetWorkflow { + @WorkflowMethod + long[] run(); + } + + @WorkflowInterface + public interface LateStreamResetWorkflow { + @WorkflowMethod + long[] run(); + } + + @ActivityInterface + public interface BoundaryActivity { + @ActivityMethod + void run(); + } + + public static class ResetWorkflowImpl implements ResetWorkflow { + private final BoundaryActivity activity = + Workflow.newActivityStub( + BoundaryActivity.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + @Override + public long[] run() { + WorkflowRandomStream random = Workflow.getRandomStream(STREAM_NAME); + long first = random.nextLong(); + activity.run(); + long second = random.nextLong(); + return new long[] {first, second}; + } + } + + public static class LateStreamResetWorkflowImpl implements LateStreamResetWorkflow { + private final BoundaryActivity activity = + Workflow.newActivityStub( + BoundaryActivity.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + @Override + public long[] run() { + long first = Workflow.getRandomStream("before-reset").nextLong(); + activity.run(); + long second = Workflow.getRandomStream(STREAM_NAME).nextLong(); + return new long[] {first, second}; + } + } + + public static class BoundaryActivityImpl implements BoundaryActivity { + @Override + public void run() {} + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowRandomStreamTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowRandomStreamTest.java new file mode 100644 index 0000000000..571ae48285 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowRandomStreamTest.java @@ -0,0 +1,169 @@ +package io.temporal.workflow; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertTrue; + +import io.temporal.client.WorkflowStub; +import io.temporal.common.WorkflowExecutionHistory; +import io.temporal.testing.WorkflowReplayer; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.WorkerOptions; +import io.temporal.workflow.shared.TestWorkflows.TestWorkflowReturnString; +import io.temporal.workflow.unsafe.WorkflowUnsafe; +import java.time.Duration; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class WorkflowRandomStreamTest { + private static boolean replayed; + private static boolean useNamedStream; + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes( + RandomStreamWorkflow.class, + RandomLongWorkflowImpl.class, + ContinueAsNewWorkflowImpl.class, + ParentWorkflowImpl.class, + RandomIsolationWorkflow.class) + .setWorkerOptions( + WorkerOptions.newBuilder() + .setStickyQueueScheduleToStartTimeout(Duration.ZERO) + .build()) + .build(); + + @Before + public void setUp() { + replayed = false; + useNamedStream = false; + } + + @Test + public void namedStreamIsStableAcrossReplay() { + TestWorkflowReturnString workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(TestWorkflowReturnString.class); + + assertEquals("ok", workflow.execute()); + assertTrue(replayed); + } + + @Test + public void differentRunsUseDifferentStreams() { + RandomLongWorkflow first = + testWorkflowRule.newWorkflowStubTimeoutOptions(RandomLongWorkflow.class); + RandomLongWorkflow second = + testWorkflowRule.newWorkflowStubTimeoutOptions(RandomLongWorkflow.class); + + assertNotEquals(first.run(), second.run()); + } + + @Test + public void continueAsNewAndChildRunsUseDifferentStreams() { + ParentWorkflow workflow = testWorkflowRule.newWorkflowStubTimeoutOptions(ParentWorkflow.class); + + long[] values = workflow.run(); + + assertEquals(3, values.length); + assertNotEquals(values[0], values[1]); + assertNotEquals(values[0], values[2]); + assertNotEquals(values[1], values[2]); + } + + @Test + public void namedStreamDoesNotPerturbWorkflowRandom() throws Exception { + RandomIsolation workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(RandomIsolation.class); + assertEquals("ok", workflow.run()); + WorkflowExecutionHistory history = + testWorkflowRule + .getWorkflowClient() + .fetchHistory(WorkflowStub.fromTyped(workflow).getExecution().getWorkflowId()); + + useNamedStream = true; + WorkflowReplayer.replayWorkflowExecution(history, RandomIsolationWorkflow.class); + } + + @WorkflowInterface + public interface RandomLongWorkflow { + @WorkflowMethod + long run(); + } + + @WorkflowInterface + public interface ContinueAsNewWorkflow { + @WorkflowMethod + long[] run(Long previous); + } + + @WorkflowInterface + public interface ParentWorkflow { + @WorkflowMethod + long[] run(); + } + + @WorkflowInterface + public interface RandomIsolation { + @WorkflowMethod + String run(); + } + + public static class RandomStreamWorkflow implements TestWorkflowReturnString { + @Override + public String execute() { + WorkflowRandomStream random = Workflow.getRandomStream("io.temporal.test"); + long first = random.nextLong(); + long recorded = Workflow.sideEffect(long.class, () -> first); + if (WorkflowUnsafe.isReplaying()) { + assertEquals(recorded, first); + replayed = true; + } + + Workflow.sleep(Duration.ofMillis(1)); + long second = random.nextLong(); + assertNotEquals(first, second); + return "ok"; + } + } + + public static class RandomLongWorkflowImpl implements RandomLongWorkflow { + @Override + public long run() { + return Workflow.getRandomStream("io.temporal.test").nextLong(); + } + } + + public static class ContinueAsNewWorkflowImpl implements ContinueAsNewWorkflow { + @Override + public long[] run(Long previous) { + long current = Workflow.getRandomStream("io.temporal.test").nextLong(); + if (previous == null) { + Workflow.continueAsNew(current); + } + return new long[] {previous, current}; + } + } + + public static class ParentWorkflowImpl implements ParentWorkflow { + @Override + public long[] run() { + long parent = Workflow.getRandomStream("io.temporal.test").nextLong(); + long[] child = Workflow.newChildWorkflowStub(ContinueAsNewWorkflow.class).run(null); + return new long[] {parent, child[0], child[1]}; + } + } + + public static class RandomIsolationWorkflow implements RandomIsolation { + @Override + public String run() { + if (useNamedStream) { + Workflow.getRandomStream("io.temporal.test").nextLong(); + } + int delayMillis = Workflow.newRandom().nextInt(100) + 1; + Workflow.sleep(Duration.ofMillis(delayMillis)); + return "ok"; + } + } +} diff --git a/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowUnsafeReadOnlyTest.java b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowUnsafeReadOnlyTest.java new file mode 100644 index 0000000000..d50513c983 --- /dev/null +++ b/temporal-sdk/src/test/java/io/temporal/workflow/WorkflowUnsafeReadOnlyTest.java @@ -0,0 +1,208 @@ +package io.temporal.workflow; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import io.temporal.client.WorkflowClient; +import io.temporal.client.WorkflowStub; +import io.temporal.common.interceptors.WorkerInterceptorBase; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptor; +import io.temporal.common.interceptors.WorkflowInboundCallsInterceptorBase; +import io.temporal.internal.sync.ReadOnlyException; +import io.temporal.testing.internal.SDKTestWorkflowRule; +import io.temporal.worker.VersionPreference; +import io.temporal.worker.WorkerFactoryOptions; +import io.temporal.worker.WorkerOptions; +import io.temporal.workflow.unsafe.WorkflowUnsafe; +import java.time.Duration; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +public class WorkflowUnsafeReadOnlyTest { + private static final Map calls = new ConcurrentHashMap<>(); + + @Rule + public SDKTestWorkflowRule testWorkflowRule = + SDKTestWorkflowRule.newBuilder() + .setWorkflowTypes(ReadOnlyWorkflowImpl.class) + .setWorkerFactoryOptions( + WorkerFactoryOptions.newBuilder() + .setWorkerInterceptors(new ReadOnlyRecordingInterceptor()) + .build()) + .setWorkerOptions( + WorkerOptions.newBuilder() + .setPreferredVersionProvider( + input -> { + record("preferredVersionProvider"); + return VersionPreference.of(Workflow.DEFAULT_VERSION); + }) + .build()) + .build(); + + @Before + public void setUp() { + calls.clear(); + } + + @Test + public void falseOutsideWorkflow() { + assertFalse(WorkflowUnsafe.isReadOnly()); + } + + @Test + public void reportsEveryReadOnlyWorkflowContext() { + ReadOnlyWorkflow workflow = + testWorkflowRule.newWorkflowStubTimeoutOptions(ReadOnlyWorkflow.class); + WorkflowClient.start(workflow::run); + + assertTrue(workflow.query()); + assertEquals("updated", workflow.update()); + workflow.finish(); + assertEquals("done", WorkflowStub.fromTyped(workflow).getResult(String.class)); + + assertEquals(expectedCalls(), calls); + } + + private static Map expectedCalls() { + Map expected = new ConcurrentHashMap<>(); + expected.put("execute", false); + expected.put("workflow", false); + expected.put("sideEffect", true); + expected.put("mutableSideEffect", true); + expected.put("await", true); + expected.put("preferredVersionProvider", true); + expected.put("handleQuery", true); + expected.put("query", true); + expected.put("validateUpdate", true); + expected.put("validator", true); + expected.put("executeUpdate", false); + expected.put("update", false); + expected.put("handleSignal", false); + expected.put("signal", false); + return expected; + } + + private static void record(String name) { + calls.put(name, WorkflowUnsafe.isReadOnly()); + } + + private static void assertRandomStreamRejected() { + ReadOnlyException error = + assertThrows(ReadOnlyException.class, () -> Workflow.getRandomStream("io.temporal.test")); + assertEquals("While in read-only function, action attempted: random", error.getMessage()); + } + + @WorkflowInterface + public interface ReadOnlyWorkflow { + @WorkflowMethod + String run(); + + @QueryMethod + boolean query(); + + @UpdateMethod + String update(); + + @UpdateValidatorMethod(updateName = "update") + void validateUpdate(); + + @SignalMethod + void finish(); + } + + public static class ReadOnlyWorkflowImpl implements ReadOnlyWorkflow { + private boolean finished; + + @Override + public String run() { + record("workflow"); + Workflow.sideEffect(boolean.class, () -> recordAndReturnTrue("sideEffect")); + Workflow.mutableSideEffect( + "read-only", + boolean.class, + Boolean::equals, + () -> recordAndReturnTrue("mutableSideEffect")); + Workflow.await( + Duration.ofMillis(1), + () -> { + record("await"); + return true; + }); + Workflow.getVersion("read-only", Workflow.DEFAULT_VERSION, 1); + Workflow.await(() -> finished); + return "done"; + } + + @Override + public boolean query() { + record("query"); + assertRandomStreamRejected(); + return true; + } + + @Override + public String update() { + record("update"); + return "updated"; + } + + @Override + public void validateUpdate() { + record("validator"); + assertRandomStreamRejected(); + } + + @Override + public void finish() { + record("signal"); + finished = true; + } + + private static boolean recordAndReturnTrue(String name) { + record(name); + return true; + } + } + + private static class ReadOnlyRecordingInterceptor extends WorkerInterceptorBase { + @Override + public WorkflowInboundCallsInterceptor interceptWorkflow(WorkflowInboundCallsInterceptor next) { + return new WorkflowInboundCallsInterceptorBase(next) { + @Override + public WorkflowOutput execute(WorkflowInput input) { + record("execute"); + return super.execute(input); + } + + @Override + public void handleSignal(SignalInput input) { + record("handleSignal"); + super.handleSignal(input); + } + + @Override + public QueryOutput handleQuery(QueryInput input) { + record("handleQuery"); + return super.handleQuery(input); + } + + @Override + public void validateUpdate(UpdateInput input) { + record("validateUpdate"); + super.validateUpdate(input); + } + + @Override + public UpdateOutput executeUpdate(UpdateInput input) { + record("executeUpdate"); + return super.executeUpdate(input); + } + }; + } + } +} diff --git a/temporal-testing/src/main/java/io/temporal/internal/sync/DummySyncWorkflowContext.java b/temporal-testing/src/main/java/io/temporal/internal/sync/DummySyncWorkflowContext.java index f89e61c64b..8fb6d2dec9 100644 --- a/temporal-testing/src/main/java/io/temporal/internal/sync/DummySyncWorkflowContext.java +++ b/temporal-testing/src/main/java/io/temporal/internal/sync/DummySyncWorkflowContext.java @@ -15,6 +15,7 @@ import io.temporal.internal.replay.ReplayWorkflowContext; import io.temporal.internal.statemachines.*; import io.temporal.workflow.Functions; +import io.temporal.workflow.WorkflowRandomStream; import java.time.Duration; import java.util.*; import javax.annotation.Nonnull; @@ -288,6 +289,11 @@ public Random newRandom() { throw new UnsupportedOperationException("not implemented"); } + @Override + public WorkflowRandomStream getRandomStream(String name) { + throw new UnsupportedOperationException("not implemented"); + } + @Override public Scope getMetricsScope() { return new NoopScope();