-
Notifications
You must be signed in to change notification settings - Fork 249
Add getRandomStream and isSubjectToReplay #3049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package io.temporal.internal.statemachines; | ||
|
|
||
| import com.google.common.hash.HashCode; | ||
| import com.google.common.hash.Hashing; | ||
| import java.nio.ByteBuffer; | ||
| import java.nio.charset.StandardCharsets; | ||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
| import java.util.Random; | ||
| import javax.annotation.Nonnull; | ||
|
|
||
| final class WorkflowRandomStreams { | ||
| private static final String SEED_VERSION = "temporal.sdk.random.v1"; | ||
|
|
||
| private final Map<String, Random> streams = new HashMap<>(); | ||
|
|
||
| long deriveSeed(@Nonnull String runId, @Nonnull String name) { | ||
| // The separators keep ("ab", "c") from colliding with ("a", "bc") | ||
| String seed = String.join("\0", SEED_VERSION, runId, name); | ||
| HashCode hash = Hashing.sha256().hashString(seed, StandardCharsets.UTF_8); | ||
| return ByteBuffer.wrap(hash.asBytes()).getLong(); | ||
| } | ||
|
|
||
| Random get(@Nonnull String runId, @Nonnull String name) { | ||
| return streams.computeIfAbsent(name, key -> new Random(deriveSeed(runId, key))); | ||
| } | ||
|
|
||
| void reseed(@Nonnull String runId) { | ||
| streams.forEach((name, stream) -> stream.setSeed(deriveSeed(runId, name))); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -718,6 +718,17 @@ public static <T> T readOnly(Functions.Func<T> func) { | |
| } | ||
| } | ||
|
|
||
| public static <T> T notSubjectToReplay(Functions.Func<T> func) { | ||
| SyncWorkflowContext workflowContext = getRootWorkflowContext(); | ||
| boolean previousSubjectToReplay = workflowContext.isSubjectToReplay(); | ||
| workflowContext.setSubjectToReplay(false); | ||
| try { | ||
| return func.apply(); | ||
| } finally { | ||
| workflowContext.setSubjectToReplay(previousSubjectToReplay); | ||
| } | ||
| } | ||
|
|
||
| public static WorkflowInfo getWorkflowInfo() { | ||
| return new WorkflowInfoImpl(getRootWorkflowContext().getReplayContext()); | ||
| } | ||
|
|
@@ -744,6 +755,10 @@ public static Random newRandom() { | |
| return getRootWorkflowContext().newRandom(); | ||
| } | ||
|
|
||
| public static Random getRandomStream(String name) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P1] Guard getRandomStream against read-only contexts Unlike Acquisition from a query handler fails the workflow task. Draws in read-only or non-replayed contexts silently desynchronize the stream. A held stream drawn inside a query, a fresh stream drawn inside an Please add |
||
| return getRootWorkflowContext().getReplayContext().getRandomStream(name); | ||
| } | ||
|
|
||
| public static Logger getLogger(Class<?> clazz) { | ||
| Logger logger = LoggerFactory.getLogger(clazz); | ||
| return new ReplayAwareLogger( | ||
|
|
@@ -929,6 +944,10 @@ static void assertNotReadOnly(String action) { | |
| } | ||
| } | ||
|
|
||
| public static boolean isSubjectToReplay() { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] Match isReplaying() off workflow threads
|
||
| return getRootWorkflowContext().isSubjectToReplay(); | ||
| } | ||
|
|
||
| static void assertNotInUpdateHandler(String message) { | ||
| if (getCurrentUpdateInfo().isPresent()) { | ||
| throw new UnsupportedContinueAsNewRequest(message); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| package io.temporal.internal.statemachines; | ||
|
|
||
| import static org.junit.Assert.assertEquals; | ||
| import static org.junit.Assert.assertNotEquals; | ||
| import static org.junit.Assert.assertSame; | ||
|
|
||
| import com.google.common.io.BaseEncoding; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Random; | ||
| 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 deriveSeed() { | ||
| WorkflowRandomStreams randoms = new WorkflowRandomStreams(); | ||
| long seed = randoms.deriveSeed(RUN_ID, NAME); | ||
| assertNotEquals(seed, randoms.deriveSeed("other", NAME)); | ||
| assertNotEquals(seed, randoms.deriveSeed(RUN_ID, "other")); | ||
| assertNotEquals(seed, randoms.deriveSeed("other", "other")); | ||
| } | ||
|
|
||
| /** Pins the seed derivation and resulting byte stream. Changing either breaks replay. */ | ||
| @Test | ||
| public void getRandomStreamGolden() { | ||
| WorkflowRandomStreams randoms = new WorkflowRandomStreams(); | ||
| assertEquals(8181915698088084985L, randoms.deriveSeed(RUN_ID, NAME)); | ||
|
|
||
| byte[] bytes = new byte[32]; | ||
| randoms.get(RUN_ID, NAME).nextBytes(bytes); | ||
| assertEquals( | ||
| "1c1d4dd36999ff851d72aa41f660ecd3220d83499109ba3ba24e455a1b776c21", | ||
| BaseEncoding.base16().lowerCase().encode(bytes)); | ||
| } | ||
|
|
||
| @Test | ||
| public void deriveSeedSeparators() { | ||
| WorkflowRandomStreams randoms = new WorkflowRandomStreams(); | ||
| assertNotEquals(randoms.deriveSeed("ab", "c"), randoms.deriveSeed("a", "bc")); | ||
| } | ||
|
|
||
| /** A second lookup under the same name continues the sequence rather than restarting it. */ | ||
| @Test | ||
| public void getRandomStreamMemoizes() { | ||
| WorkflowRandomStreams randoms = new WorkflowRandomStreams(); | ||
|
|
||
| Random first = randoms.get(RUN_ID, NAME); | ||
| long firstDraw = first.nextLong(); | ||
|
|
||
| Random second = randoms.get(RUN_ID, NAME); | ||
| long secondDraw = second.nextLong(); | ||
|
|
||
| assertSame(first, second); | ||
| assertNotEquals(firstDraw, secondDraw); | ||
| } | ||
|
|
||
| /** | ||
| * Interleaving draws across two names yields the same sequence per name as drawing from each on | ||
| * its own, so how often a workflow draws from one name cannot shift another. | ||
| */ | ||
| @Test | ||
| public void getRandomStreamNamesAreIndependent() { | ||
| WorkflowRandomStreams randoms = new WorkflowRandomStreams(); | ||
| Random first = randoms.get(RUN_ID, NAME); | ||
| Random second = randoms.get(RUN_ID, "other"); | ||
|
|
||
| List<Long> interleavedA = new ArrayList<>(); | ||
| List<Long> interleavedB = new ArrayList<>(); | ||
| for (int i = 0; i < 3; i++) { | ||
| interleavedA.add(first.nextLong()); | ||
| interleavedB.add(second.nextLong()); | ||
| } | ||
|
|
||
| assertEquals(solo(NAME, 3), interleavedA); | ||
| assertEquals(solo("other", 3), interleavedB); | ||
| assertNotEquals(interleavedA, interleavedB); | ||
| } | ||
|
|
||
| @Test | ||
| public void reseedRandomsInPlace() { | ||
| WorkflowRandomStreams randoms = new WorkflowRandomStreams(); | ||
|
|
||
| Random first = randoms.get(RUN_ID, NAME); | ||
| randoms.reseed("other"); | ||
| Random second = randoms.get("other", NAME); | ||
|
|
||
| assertSame(first, second); | ||
| assertEquals(new WorkflowRandomStreams().get("other", NAME).nextLong(), second.nextLong()); | ||
| } | ||
|
|
||
| private static List<Long> solo(String name, int draws) { | ||
| Random random = new WorkflowRandomStreams().get(RUN_ID, name); | ||
| List<Long> result = new ArrayList<>(); | ||
| for (int i = 0; i < draws; i++) { | ||
| result.add(random.nextLong()); | ||
| } | ||
| return result; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] Preserve more than 48 bits of generator state
Although the seed is hashed with SHA-256 and reduced to a
long,new Random(seed)retains only 48 bits. At roughly 2^24 workflow-run/name streams the birthday probability of at least one duplicate state becomes material, and a collision produces the same entire sequence—not merely one repeated value. The stated consumer is OpenTelemetry IDs, so this can merge otherwise unrelated traces in a large installation; documenting thatRandomis not cryptographically secure does not address global ID uniqueness.The earlier implementation on this branch used a full-seed SHA-counter stream. Please retain substantially wider deterministic state (for example through a
Randomsubclass if the return type must remainRandom) and pin that sequence in replay tests.