Skip to content

Commit cd21e5b

Browse files
committed
Add named Workflow random streams and read-only detection
1 parent 6c60097 commit cd21e5b

11 files changed

Lines changed: 614 additions & 2 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,9 @@ Integer getVersion(
292292
/** Replay safe random. */
293293
Random newRandom();
294294

295+
/** Replay safe named random stream. */
296+
Random getRandomStream(String name);
297+
295298
/**
296299
* @return scope to be used for metrics reporting.
297300
*/

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ public Random newRandom() {
8181
return workflowStateMachines.newRandom();
8282
}
8383

84+
@Override
85+
public Random getRandomStream(String name) {
86+
return workflowStateMachines.getRandomStream(name);
87+
}
88+
8489
@Override
8590
public Scope getMetricsScope() {
8691
return replayAwareWorkflowMetricsScope;
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package io.temporal.internal.statemachines;
2+
3+
import com.google.common.hash.HashCode;
4+
import com.google.common.hash.Hashing;
5+
import java.nio.ByteBuffer;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.HashMap;
8+
import java.util.Map;
9+
import java.util.Random;
10+
import javax.annotation.Nonnull;
11+
12+
final class WorkflowRandomStreams {
13+
private static final String SEED_VERSION = "temporal.sdk.random.v1";
14+
15+
private final Map<String, Random> streams = new HashMap<>();
16+
17+
long deriveSeed(@Nonnull String runId, @Nonnull String name) {
18+
// The separators keep ("ab", "c") from colliding with ("a", "bc")
19+
String seed = String.join("\0", SEED_VERSION, runId, name);
20+
HashCode hash = Hashing.sha256().hashString(seed, StandardCharsets.UTF_8);
21+
return ByteBuffer.wrap(hash.asBytes()).getLong();
22+
}
23+
24+
Random get(@Nonnull String runId, @Nonnull String name) {
25+
return streams.computeIfAbsent(name, key -> new Random(deriveSeed(runId, key)));
26+
}
27+
28+
void reseed(@Nonnull String runId) {
29+
streams.forEach((name, stream) -> stream.setSeed(deriveSeed(runId, name)));
30+
}
31+
}

‎temporal-sdk/src/main/java/io/temporal/internal/statemachines/WorkflowStateMachines.java‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ enum HandleEventStatus {
115115
/** Used Workflow.newRandom and randomUUID together with currentRunId. */
116116
private long idCounter;
117117

118+
private final WorkflowRandomStreams randomStreams = new WorkflowRandomStreams();
119+
118120
/** Current workflow time. */
119121
private long currentTimeMillis = -1;
120122

@@ -1195,6 +1197,11 @@ public Random newRandom() {
11951197
return new Random(randomUUID().getLeastSignificantBits());
11961198
}
11971199

1200+
public Random getRandomStream(String name) {
1201+
checkEventLoopExecuting();
1202+
return randomStreams.get(currentRunId, name);
1203+
}
1204+
11981205
public void sideEffect(
11991206
Functions.Func<Optional<Payloads>> func,
12001207
UserMetadata userMetadata,
@@ -1548,6 +1555,7 @@ public void workflowTaskStarted(
15481555
@Override
15491556
public void updateRunId(String currentRunId) {
15501557
WorkflowStateMachines.this.currentRunId = currentRunId;
1558+
randomStreams.reseed(currentRunId);
15511559
}
15521560
}
15531561

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,10 @@ public static Random newRandom() {
744744
return getRootWorkflowContext().newRandom();
745745
}
746746

747+
public static Random getRandomStream(String name) {
748+
return getRootWorkflowContext().getReplayContext().getRandomStream(name);
749+
}
750+
747751
public static Logger getLogger(Class<?> clazz) {
748752
Logger logger = LoggerFactory.getLogger(clazz);
749753
return new ReplayAwareLogger(
@@ -919,11 +923,11 @@ static SyncWorkflowContext getRootWorkflowContext() {
919923
return DeterministicRunnerImpl.currentThreadInternal().getWorkflowContext();
920924
}
921925

922-
static boolean isReadOnly() {
926+
public static boolean isReadOnly() {
923927
return getRootWorkflowContext().isReadOnly();
924928
}
925929

926-
static void assertNotReadOnly(String action) {
930+
public static void assertNotReadOnly(String action) {
927931
if (isReadOnly()) {
928932
throw new ReadOnlyException(action);
929933
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,24 @@ public static Random newRandom() {
711711
return WorkflowInternal.newRandom();
712712
}
713713

714+
/**
715+
* Returns a deterministic pseudorandom stream private to {@code name}.
716+
*
717+
* <p>Calling this method again with the same name returns the same logical stream where earlier
718+
* draws left it. A Workflow Reset replays the same values up to the reset point, then reseeds the
719+
* stream for the new Run. Each Continue-As-New Run gets a new sequence.
720+
*
721+
* <p>Draws are not recorded in Workflow History, so do not draw in read-only code. Use {@link
722+
* WorkflowUnsafe#isReadOnly()} to gate draws.
723+
*
724+
* <p>Use a stable package-style name. Stream names are retained for the life of the Workflow Run.
725+
* The stream is deterministic pseudorandomness and is not cryptographically secure.
726+
*/
727+
@Experimental
728+
public static Random getRandomStream(String name) {
729+
return WorkflowInternal.getRandomStream(name);
730+
}
731+
714732
/**
715733
* True if workflow code is being replayed.
716734
*

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.temporal.workflow.unsafe;
22

3+
import io.temporal.common.Experimental;
34
import io.temporal.internal.sync.WorkflowInternal;
45
import io.temporal.workflow.Functions;
56

@@ -46,6 +47,21 @@ public static boolean isReplaying() {
4647
return WorkflowInternal.isReplaying();
4748
}
4849

50+
/**
51+
* Reports whether the current code is running where Workflow state cannot be mutated.
52+
*
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.
55+
*
56+
* <p>Must be called from Workflow code.
57+
*
58+
* @return true in a read-only Workflow context
59+
*/
60+
@Experimental
61+
public static boolean isReadOnly() {
62+
return WorkflowInternal.isReadOnly();
63+
}
64+
4965
/**
5066
* Runs the supplied procedure in the calling thread with disabled deadlock detection if called
5167
* from the workflow thread. Does nothing except the procedure execution if called from a
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package io.temporal.internal.statemachines;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertNotEquals;
5+
import static org.junit.Assert.assertSame;
6+
7+
import com.google.common.io.BaseEncoding;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.Random;
11+
import org.junit.Test;
12+
13+
public class WorkflowRandomStreamsTest {
14+
private static final String RUN_ID = "runID";
15+
private static final String NAME = "io.temporal.test";
16+
17+
@Test
18+
public void deriveSeed() {
19+
WorkflowRandomStreams randoms = new WorkflowRandomStreams();
20+
long seed = randoms.deriveSeed(RUN_ID, NAME);
21+
assertNotEquals(seed, randoms.deriveSeed("other", NAME));
22+
assertNotEquals(seed, randoms.deriveSeed(RUN_ID, "other"));
23+
assertNotEquals(seed, randoms.deriveSeed("other", "other"));
24+
}
25+
26+
/** Pins the seed derivation and resulting byte stream. Changing either breaks replay. */
27+
@Test
28+
public void getRandomStreamGolden() {
29+
WorkflowRandomStreams randoms = new WorkflowRandomStreams();
30+
assertEquals(8181915698088084985L, randoms.deriveSeed(RUN_ID, NAME));
31+
32+
byte[] bytes = new byte[32];
33+
randoms.get(RUN_ID, NAME).nextBytes(bytes);
34+
assertEquals(
35+
"1c1d4dd36999ff851d72aa41f660ecd3220d83499109ba3ba24e455a1b776c21",
36+
BaseEncoding.base16().lowerCase().encode(bytes));
37+
}
38+
39+
@Test
40+
public void deriveSeedSeparators() {
41+
WorkflowRandomStreams randoms = new WorkflowRandomStreams();
42+
assertNotEquals(randoms.deriveSeed("ab", "c"), randoms.deriveSeed("a", "bc"));
43+
}
44+
45+
/** A second lookup under the same name continues the sequence rather than restarting it. */
46+
@Test
47+
public void getRandomStreamMemoizes() {
48+
WorkflowRandomStreams randoms = new WorkflowRandomStreams();
49+
50+
Random first = randoms.get(RUN_ID, NAME);
51+
long firstDraw = first.nextLong();
52+
53+
Random second = randoms.get(RUN_ID, NAME);
54+
long secondDraw = second.nextLong();
55+
56+
assertSame(first, second);
57+
assertNotEquals(firstDraw, secondDraw);
58+
}
59+
60+
/**
61+
* Interleaving draws across two names yields the same sequence per name as drawing from each on
62+
* its own, so how often a workflow draws from one name cannot shift another.
63+
*/
64+
@Test
65+
public void getRandomStreamNamesAreIndependent() {
66+
WorkflowRandomStreams randoms = new WorkflowRandomStreams();
67+
Random first = randoms.get(RUN_ID, NAME);
68+
Random second = randoms.get(RUN_ID, "other");
69+
70+
List<Long> interleavedA = new ArrayList<>();
71+
List<Long> interleavedB = new ArrayList<>();
72+
for (int i = 0; i < 3; i++) {
73+
interleavedA.add(first.nextLong());
74+
interleavedB.add(second.nextLong());
75+
}
76+
77+
assertEquals(solo(NAME, 3), interleavedA);
78+
assertEquals(solo("other", 3), interleavedB);
79+
assertNotEquals(interleavedA, interleavedB);
80+
}
81+
82+
@Test
83+
public void reseedRandomsInPlace() {
84+
WorkflowRandomStreams randoms = new WorkflowRandomStreams();
85+
86+
Random first = randoms.get(RUN_ID, NAME);
87+
randoms.reseed("other");
88+
Random second = randoms.get("other", NAME);
89+
90+
assertSame(first, second);
91+
assertEquals(new WorkflowRandomStreams().get("other", NAME).nextLong(), second.nextLong());
92+
}
93+
94+
private static List<Long> solo(String name, int draws) {
95+
Random random = new WorkflowRandomStreams().get(RUN_ID, name);
96+
List<Long> result = new ArrayList<>();
97+
for (int i = 0; i < draws; i++) {
98+
result.add(random.nextLong());
99+
}
100+
return result;
101+
}
102+
}

0 commit comments

Comments
 (0)