diff --git a/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/RestateContextImpl.kt b/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/RestateContextImpl.kt index 3d52c8e4..0a8c3527 100644 --- a/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/RestateContextImpl.kt +++ b/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/RestateContextImpl.kt @@ -9,6 +9,7 @@ package dev.restate.sdk.kotlin import com.google.protobuf.ByteString +import dev.restate.sdk.common.InvocationId import dev.restate.sdk.common.Serde import dev.restate.sdk.common.StateKey import dev.restate.sdk.common.TerminalException @@ -184,4 +185,8 @@ internal class RestateContextImpl internal constructor(private val syscalls: Sys override fun awakeableHandle(id: String): AwakeableHandle { return AwakeableHandleImpl(syscalls, id) } + + override fun random(): RestateRandom { + return RestateRandom(InvocationId.current().toRandomSeed(), syscalls) + } } diff --git a/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/api.kt b/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/api.kt index 22ca917a..6ae35beb 100644 --- a/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/api.kt +++ b/sdk-api-kotlin/src/main/kotlin/dev/restate/sdk/kotlin/api.kt @@ -15,6 +15,7 @@ import dev.restate.sdk.common.StateKey import dev.restate.sdk.common.syscalls.Syscalls import io.grpc.MethodDescriptor import java.util.* +import kotlin.random.Random import kotlin.time.Duration /** @@ -198,6 +199,33 @@ sealed interface RestateContext { * @see Awakeable */ fun awakeableHandle(id: String): AwakeableHandle + + /** + * Create a [RestateRandom] instance inherently predictable, seeded on the + * [dev.restate.sdk.common.InvocationId], which is not secret. + * + * This instance is useful to generate identifiers, idempotency keys, and for uniform sampling + * from a set of options. If a cryptographically secure value is needed, please generate that + * externally using [sideEffect]. + * + * You MUST NOT use this [Random] instance inside a [sideEffect]. + * + * @return the [Random] instance. + */ + fun random(): RestateRandom +} + +class RestateRandom(seed: Long, private val syscalls: Syscalls) : Random() { + private val r = Random(seed) + + override fun nextBits(bitCount: Int): Int { + check(!syscalls.isInsideSideEffect) { "You can't use RestateRandom inside a side effect!" } + return r.nextBits(bitCount) + } + + fun nextUUID(): UUID { + return UUID(this.nextLong(), this.nextLong()) + } } /** diff --git a/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/KotlinCoroutinesTests.kt b/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/KotlinCoroutinesTests.kt index 66574997..a6732a36 100644 --- a/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/KotlinCoroutinesTests.kt +++ b/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/KotlinCoroutinesTests.kt @@ -20,7 +20,7 @@ class KotlinCoroutinesTests : TestRunner() { return Stream.of(MockSingleThread.INSTANCE, MockMultiThreaded.INSTANCE) } - override fun definitions(): Stream { + public override fun definitions(): Stream { return Stream.of( AwakeableIdTest(), DeferredTest(), @@ -31,6 +31,7 @@ class KotlinCoroutinesTests : TestRunner() { SideEffectTest(), SleepTest(), StateMachineFailuresTest(), - UserFailuresTest()) + UserFailuresTest(), + RandomTest()) } } diff --git a/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/RandomTest.kt b/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/RandomTest.kt new file mode 100644 index 00000000..106c8c62 --- /dev/null +++ b/sdk-api-kotlin/src/test/kotlin/dev/restate/sdk/kotlin/RandomTest.kt @@ -0,0 +1,50 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.kotlin + +import dev.restate.sdk.core.RandomTestSuite +import dev.restate.sdk.core.testservices.GreeterGrpcKt +import dev.restate.sdk.core.testservices.GreetingRequest +import dev.restate.sdk.core.testservices.GreetingResponse +import dev.restate.sdk.core.testservices.greetingResponse +import io.grpc.BindableService +import kotlin.random.Random +import kotlinx.coroutines.Dispatchers + +class RandomTest : RandomTestSuite() { + private class RandomShouldBeDeterministic : + GreeterGrpcKt.GreeterCoroutineImplBase(Dispatchers.Unconfined), RestateKtService { + + override suspend fun greet(request: GreetingRequest): GreetingResponse { + val number = restateContext().random().nextInt() + return greetingResponse { message = number.toString() } + } + } + + override fun randomShouldBeDeterministic(): BindableService { + return RandomShouldBeDeterministic() + } + + private class RandomInsideSideEffect : + GreeterGrpcKt.GreeterCoroutineImplBase(Dispatchers.Unconfined), RestateKtService { + override suspend fun greet(request: GreetingRequest): GreetingResponse { + val ctx = restateContext() + ctx.sideEffect { ctx.random().nextInt() } + throw IllegalStateException("This should not unreachable") + } + } + + override fun randomInsideSideEffect(): BindableService { + return RandomInsideSideEffect() + } + + override fun getExpectedInt(seed: Long): Int { + return Random(seed).nextInt() + } +} diff --git a/sdk-api/src/main/java/dev/restate/sdk/RestateContext.java b/sdk-api/src/main/java/dev/restate/sdk/RestateContext.java index b41fa0f6..af97234d 100644 --- a/sdk-api/src/main/java/dev/restate/sdk/RestateContext.java +++ b/sdk-api/src/main/java/dev/restate/sdk/RestateContext.java @@ -207,6 +207,11 @@ default void sideEffect(ThrowingRunnable runnable) throws TerminalException { */ AwakeableHandle awakeableHandle(String id); + /** + * @see RestateRandom + */ + RestateRandom random(); + /** * Build a RestateContext from the underlying {@link Syscalls} object. * diff --git a/sdk-api/src/main/java/dev/restate/sdk/RestateContextImpl.java b/sdk-api/src/main/java/dev/restate/sdk/RestateContextImpl.java index 525c0976..33b475fa 100644 --- a/sdk-api/src/main/java/dev/restate/sdk/RestateContextImpl.java +++ b/sdk-api/src/main/java/dev/restate/sdk/RestateContextImpl.java @@ -9,10 +9,7 @@ package dev.restate.sdk; import com.google.protobuf.ByteString; -import dev.restate.sdk.common.AbortedExecutionException; -import dev.restate.sdk.common.Serde; -import dev.restate.sdk.common.StateKey; -import dev.restate.sdk.common.TerminalException; +import dev.restate.sdk.common.*; import dev.restate.sdk.common.function.ThrowingSupplier; import dev.restate.sdk.common.syscalls.DeferredResult; import dev.restate.sdk.common.syscalls.EnterSideEffectSyscallCallback; @@ -186,4 +183,9 @@ public void reject(String reason) { } }; } + + @Override + public RestateRandom random() { + return new RestateRandom(InvocationId.current().toRandomSeed(), this.syscalls); + } } diff --git a/sdk-api/src/main/java/dev/restate/sdk/RestateRandom.java b/sdk-api/src/main/java/dev/restate/sdk/RestateRandom.java new file mode 100644 index 00000000..7cda1d15 --- /dev/null +++ b/sdk-api/src/main/java/dev/restate/sdk/RestateRandom.java @@ -0,0 +1,66 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk; + +import dev.restate.sdk.common.InvocationId; +import dev.restate.sdk.common.Serde; +import dev.restate.sdk.common.function.ThrowingSupplier; +import dev.restate.sdk.common.syscalls.Syscalls; +import java.util.Random; +import java.util.UUID; + +/** + * Subclass of {@link Random} inherently predictable, seeded on the {@link InvocationId}, which is + * not secret. + * + *

This instance is useful to generate identifiers, idempotency keys, and for uniform sampling + * from a set of options. If a cryptographically secure value is needed, please generate that + * externally using {@link RestateContext#sideEffect(Serde, ThrowingSupplier)}. + * + *

You MUST NOT use this object inside a {@link RestateContext#sideEffect(Serde, + * ThrowingSupplier)}. + */ +public class RestateRandom extends Random { + + private final Syscalls syscalls; + private boolean seedInitialized = false; + + RestateRandom(long randomSeed, Syscalls syscalls) { + super(randomSeed); + this.syscalls = syscalls; + } + + /** + * @throws UnsupportedOperationException You cannot set the seed on RestateRandom + */ + @Override + public synchronized void setSeed(long seed) { + if (seedInitialized) { + throw new UnsupportedOperationException("You cannot set the seed on RestateRandom"); + } + super.setSeed(seed); + this.seedInitialized = true; + } + + /** + * @return a UUID generated using this RNG. + */ + public UUID nextUUID() { + return new UUID(this.nextLong(), this.nextLong()); + } + + @Override + protected int next(int bits) { + if (this.syscalls.isInsideSideEffect()) { + throw new IllegalStateException("You can't use RestateRandom inside a side effect!"); + } + + return super.next(bits); + } +} diff --git a/sdk-api/src/test/java/dev/restate/sdk/JavaBlockingTests.java b/sdk-api/src/test/java/dev/restate/sdk/JavaBlockingTests.java index 7ff33e5c..4e5f3b9e 100644 --- a/sdk-api/src/test/java/dev/restate/sdk/JavaBlockingTests.java +++ b/sdk-api/src/test/java/dev/restate/sdk/JavaBlockingTests.java @@ -23,7 +23,7 @@ protected Stream executors() { } @Override - protected Stream definitions() { + public Stream definitions() { return Stream.of( new AwakeableIdTest(), new DeferredTest(), @@ -36,6 +36,7 @@ protected Stream definitions() { new StateMachineFailuresTest(), new UserFailuresTest(), new GrpcChannelAdapterTest(), - new RestateCodegenTest()); + new RestateCodegenTest(), + new RandomTest()); } } diff --git a/sdk-api/src/test/java/dev/restate/sdk/RandomTest.java b/sdk-api/src/test/java/dev/restate/sdk/RandomTest.java new file mode 100644 index 00000000..fc240f6a --- /dev/null +++ b/sdk-api/src/test/java/dev/restate/sdk/RandomTest.java @@ -0,0 +1,54 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk; + +import dev.restate.sdk.common.TerminalException; +import dev.restate.sdk.core.RandomTestSuite; +import dev.restate.sdk.core.testservices.GreeterRestate; +import dev.restate.sdk.core.testservices.GreetingRequest; +import dev.restate.sdk.core.testservices.GreetingResponse; +import io.grpc.BindableService; +import java.util.Random; + +public class RandomTest extends RandomTestSuite { + + private static class RandomShouldBeDeterministic extends GreeterRestate.GreeterRestateImplBase { + @Override + public GreetingResponse greet(RestateContext context, GreetingRequest request) + throws TerminalException { + return GreetingResponse.newBuilder() + .setMessage(Integer.toString(context.random().nextInt())) + .build(); + } + } + + @Override + protected BindableService randomShouldBeDeterministic() { + return new RandomShouldBeDeterministic(); + } + + private static class RandomInsideSideEffect extends GreeterRestate.GreeterRestateImplBase { + @Override + public GreetingResponse greet(RestateContext context, GreetingRequest request) + throws TerminalException { + context.sideEffect(() -> context.random().nextInt()); + throw new IllegalStateException("This should not unreachable"); + } + } + + @Override + protected BindableService randomInsideSideEffect() { + return new RandomInsideSideEffect(); + } + + @Override + protected int getExpectedInt(long seed) { + return new Random(seed).nextInt(); + } +} diff --git a/sdk-common/src/main/java/dev/restate/sdk/common/InvocationId.java b/sdk-common/src/main/java/dev/restate/sdk/common/InvocationId.java index 83c46206..c99a9d71 100644 --- a/sdk-common/src/main/java/dev/restate/sdk/common/InvocationId.java +++ b/sdk-common/src/main/java/dev/restate/sdk/common/InvocationId.java @@ -28,6 +28,11 @@ static InvocationId current() { return INVOCATION_ID_KEY.get(); } + /** + * @return a seed to be used with {@link java.util.Random}. + */ + long toRandomSeed(); + @Override String toString(); } diff --git a/sdk-common/src/main/java/dev/restate/sdk/common/syscalls/Syscalls.java b/sdk-common/src/main/java/dev/restate/sdk/common/syscalls/Syscalls.java index e5e50258..7f0e6b7d 100644 --- a/sdk-common/src/main/java/dev/restate/sdk/common/syscalls/Syscalls.java +++ b/sdk-common/src/main/java/dev/restate/sdk/common/syscalls/Syscalls.java @@ -41,6 +41,11 @@ static Syscalls current() { + Thread.currentThread().getName()); } + /** + * @return true if it's inside a side effect block. + */ + boolean isInsideSideEffect(); + // ----- IO // Note: These are not supposed to be exposed to RestateContext, but they should be used through // gRPC APIs. diff --git a/sdk-core/src/main/java/dev/restate/sdk/core/ExecutorSwitchingWrappers.java b/sdk-core/src/main/java/dev/restate/sdk/core/ExecutorSwitchingWrappers.java index 0a8dd023..d39552a8 100644 --- a/sdk-core/src/main/java/dev/restate/sdk/core/ExecutorSwitchingWrappers.java +++ b/sdk-core/src/main/java/dev/restate/sdk/core/ExecutorSwitchingWrappers.java @@ -193,6 +193,12 @@ public InvocationState getInvocationState() { return syscalls.getInvocationState(); } + @Override + public boolean isInsideSideEffect() { + // We can read this from another thread + return syscalls.isInsideSideEffect(); + } + @Override public void close() { syscallsExecutor.execute(syscalls::close); diff --git a/sdk-core/src/main/java/dev/restate/sdk/core/InvocationIdImpl.java b/sdk-core/src/main/java/dev/restate/sdk/core/InvocationIdImpl.java index be6b4c2d..ac66c5b8 100644 --- a/sdk-core/src/main/java/dev/restate/sdk/core/InvocationIdImpl.java +++ b/sdk-core/src/main/java/dev/restate/sdk/core/InvocationIdImpl.java @@ -9,14 +9,19 @@ package dev.restate.sdk.core; import dev.restate.sdk.common.InvocationId; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.Objects; final class InvocationIdImpl implements InvocationId { private final String id; + private Long seed; InvocationIdImpl(String debugId) { this.id = debugId; + this.seed = null; } @Override @@ -32,6 +37,33 @@ public int hashCode() { return Objects.hash(id); } + @Override + public long toRandomSeed() { + if (seed == null) { + // Hash the seed to SHA-256 to increase entropy + MessageDigest md; + try { + md = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + byte[] digest = md.digest(id.getBytes(StandardCharsets.UTF_8)); + + // Generate the long + long n = 0; + n |= ((long) (digest[7] & 0xFF) << Byte.SIZE * 7); + n |= ((long) (digest[6] & 0xFF) << Byte.SIZE * 6); + n |= ((long) (digest[5] & 0xFF) << Byte.SIZE * 5); + n |= ((long) (digest[4] & 0xFF) << Byte.SIZE * 4); + n |= ((long) (digest[3] & 0xFF) << Byte.SIZE * 3); + n |= ((digest[2] & 0xFF) << Byte.SIZE * 2); + n |= ((digest[1] & 0xFF) << Byte.SIZE); + n |= (digest[0] & 0xFF); + seed = n; + } + return seed; + } + @Override public String toString() { return id; diff --git a/sdk-core/src/main/java/dev/restate/sdk/core/InvocationStateMachine.java b/sdk-core/src/main/java/dev/restate/sdk/core/InvocationStateMachine.java index 23c6a78a..5cd80b06 100644 --- a/sdk-core/src/main/java/dev/restate/sdk/core/InvocationStateMachine.java +++ b/sdk-core/src/main/java/dev/restate/sdk/core/InvocationStateMachine.java @@ -41,7 +41,7 @@ class InvocationStateMachine implements InvocationFlow.InvocationProcessor { private volatile InvocationState invocationState = InvocationState.WAITING_START; // Used for the side effect guard - private boolean insideSideEffect = false; + private volatile boolean insideSideEffect = false; // Obtained after WAITING_START private ByteString id; @@ -97,6 +97,10 @@ public InvocationState getInvocationState() { return this.invocationState; } + public boolean isInsideSideEffect() { + return this.insideSideEffect; + } + public String getFullyQualifiedMethodName() { return this.fullyQualifiedMethodName; } diff --git a/sdk-core/src/main/java/dev/restate/sdk/core/SyscallsImpl.java b/sdk-core/src/main/java/dev/restate/sdk/core/SyscallsImpl.java index 13911d21..ab543d50 100644 --- a/sdk-core/src/main/java/dev/restate/sdk/core/SyscallsImpl.java +++ b/sdk-core/src/main/java/dev/restate/sdk/core/SyscallsImpl.java @@ -327,6 +327,11 @@ public InvocationState getInvocationState() { return this.stateMachine.getInvocationState(); } + @Override + public boolean isInsideSideEffect() { + return this.stateMachine.isInsideSideEffect(); + } + @Override public void close() { this.stateMachine.end(); diff --git a/sdk-core/src/test/java/dev/restate/sdk/core/RandomTestSuite.java b/sdk-core/src/test/java/dev/restate/sdk/core/RandomTestSuite.java new file mode 100644 index 00000000..44265328 --- /dev/null +++ b/sdk-core/src/test/java/dev/restate/sdk/core/RandomTestSuite.java @@ -0,0 +1,57 @@ +// Copyright (c) 2023 - Restate Software, Inc., Restate GmbH +// +// This file is part of the Restate Java SDK, +// which is released under the MIT license. +// +// You can find a copy of the license in file LICENSE in the root +// directory of this repository or package, or at +// https://github.com/restatedev/sdk-java/blob/main/LICENSE +package dev.restate.sdk.core; + +import static dev.restate.sdk.core.AssertUtils.*; +import static dev.restate.sdk.core.ProtoUtils.*; +import static dev.restate.sdk.core.TestDefinitions.testInvocation; + +import dev.restate.generated.service.protocol.Protocol; +import dev.restate.sdk.core.TestDefinitions.TestDefinition; +import dev.restate.sdk.core.TestDefinitions.TestSuite; +import dev.restate.sdk.core.testservices.GreeterGrpc; +import dev.restate.sdk.core.testservices.GreetingRequest; +import io.grpc.BindableService; +import java.util.Random; +import java.util.stream.Stream; + +public abstract class RandomTestSuite implements TestSuite { + + protected abstract BindableService randomShouldBeDeterministic(); + + protected abstract BindableService randomInsideSideEffect(); + + protected abstract int getExpectedInt(long seed); + + @Override + public Stream definitions() { + String debugId = "my-id"; + + int expectedRandomNumber = new Random(new InvocationIdImpl(debugId).toRandomSeed()).nextInt(); + return Stream.of( + testInvocation(this::randomShouldBeDeterministic, GreeterGrpc.getGreetMethod()) + .withInput( + Protocol.StartMessage.newBuilder().setDebugId(debugId).setKnownEntries(1), + inputMessage(GreetingRequest.getDefaultInstance())) + .expectingOutput( + outputMessage( + greetingResponse( + Integer.toString( + getExpectedInt(new InvocationIdImpl(debugId).toRandomSeed())))), + END_MESSAGE), + testInvocation(this::randomInsideSideEffect, GreeterGrpc.getGreetMethod()) + .withInput( + Protocol.StartMessage.newBuilder().setDebugId(debugId).setKnownEntries(1), + inputMessage(GreetingRequest.getDefaultInstance())) + .assertingOutput( + containsOnlyExactErrorMessage( + new IllegalStateException( + "You can't use RestateRandom inside a side effect!")))); + } +} diff --git a/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt b/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt index f12a7d8a..d3d9418e 100644 --- a/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt +++ b/sdk-http-vertx/src/test/kotlin/dev/restate/sdk/http/vertx/HttpVertxTests.kt @@ -10,12 +10,14 @@ package dev.restate.sdk.http.vertx import com.google.protobuf.ByteString import dev.restate.generated.sdk.java.Java.SideEffectEntryMessage +import dev.restate.sdk.JavaBlockingTests import dev.restate.sdk.RestateService import dev.restate.sdk.core.ProtoUtils.* import dev.restate.sdk.core.TestDefinitions.* import dev.restate.sdk.core.testservices.GreeterGrpc import dev.restate.sdk.core.testservices.GreetingRequest import dev.restate.sdk.core.testservices.GreetingResponse +import dev.restate.sdk.kotlin.KotlinCoroutinesTests import dev.restate.sdk.kotlin.RestateKtService import io.grpc.stub.StreamObserver import io.vertx.core.Vertx @@ -101,28 +103,11 @@ class HttpVertxTests : dev.restate.sdk.core.TestRunner() { } override fun definitions(): Stream { - return Stream.of( - dev.restate.sdk.AwakeableIdTest(), - dev.restate.sdk.DeferredTest(), - dev.restate.sdk.EagerStateTest(), - dev.restate.sdk.StateTest(), - dev.restate.sdk.InvocationIdTest(), - dev.restate.sdk.OnlyInputAndOutputTest(), - dev.restate.sdk.SideEffectTest(), - dev.restate.sdk.SleepTest(), - dev.restate.sdk.StateMachineFailuresTest(), - dev.restate.sdk.UserFailuresTest(), - dev.restate.sdk.GrpcChannelAdapterTest(), - dev.restate.sdk.kotlin.AwakeableIdTest(), - dev.restate.sdk.kotlin.DeferredTest(), - dev.restate.sdk.kotlin.EagerStateTest(), - dev.restate.sdk.kotlin.StateTest(), - dev.restate.sdk.kotlin.InvocationIdTest(), - dev.restate.sdk.kotlin.OnlyInputAndOutputTest(), - dev.restate.sdk.kotlin.SideEffectTest(), - dev.restate.sdk.kotlin.SleepTest(), - dev.restate.sdk.kotlin.StateMachineFailuresTest(), - dev.restate.sdk.kotlin.UserFailuresTest(), - VertxExecutorsTest()) + return Stream.concat( + Stream.concat( + JavaBlockingTests().definitions(), + KotlinCoroutinesTests().definitions(), + ), + Stream.of(VertxExecutorsTest())) } }