Skip to content

Commit 613231d

Browse files
Scope and limit key (#597)
1 parent 9186f8a commit 613231d

34 files changed

Lines changed: 1162 additions & 88 deletions

File tree

.github/workflows/integration-ci.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,12 @@ jobs:
107107

108108
- name: Run test tool
109109
continue-on-error: ${{ inputs.continueOnError == 'true' }}
110-
uses: restatedev/e2e/sdk-tests@v2.1
110+
uses: restatedev/e2e/sdk-tests@v2.2
111111
with:
112112
envVars: ${{ inputs.envVars }}
113+
# Scope/limit key need the FFM state machine (protocol v7, JRE >= 23). On the legacy
114+
# state machine (JRE < 23) those tests can't pass, so exclude them there.
115+
exclusionsFile: ${{ matrix.jreVersion < 23 && '.tools/exclusions-legacy-statemachine.yaml' || '' }}
113116
testArtifactOutput: ${{ inputs.testArtifactOutput != '' && format('{0}-jre{1}', inputs.testArtifactOutput, matrix.jreVersion) || format('sdk-java-jre{0}-integration-test-report', matrix.jreVersion) }}
114117
restateContainerImage: ${{ inputs.restateCommit != '' && 'localhost/restatedev/restate-commit-download:latest' || (inputs.restateImage != '' && inputs.restateImage || 'ghcr.io/restatedev/restate:main') }}
115118
serviceContainerImage: ${{ inputs.serviceImage != '' && inputs.serviceImage || 'restatedev/test-services-java' }}

.github/workflows/integration.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,12 @@ jobs:
117117

118118
- name: Run test tool
119119
continue-on-error: ${{ inputs.continueOnError == 'true' }}
120-
uses: restatedev/e2e/sdk-tests@v2.1
120+
uses: restatedev/e2e/sdk-tests@v2.2
121121
with:
122122
envVars: ${{ inputs.envVars }}
123+
# Scope/limit key need the FFM state machine (protocol v7, JRE >= 23). On the legacy
124+
# state machine (JRE < 23) those tests can't pass, so exclude them there.
125+
exclusionsFile: ${{ matrix.jreVersion < 23 && '.tools/exclusions-legacy-statemachine.yaml' || '' }}
123126
testArtifactOutput: ${{ inputs.testArtifactOutput != '' && format('{0}-jre{1}', inputs.testArtifactOutput, matrix.jreVersion) || format('sdk-java-jre{0}-integration-test-report', matrix.jreVersion) }}
124127
restateContainerImage: ${{ inputs.restateCommit != '' && 'localhost/restatedev/restate-commit-download:latest' || (inputs.restateImage != '' && inputs.restateImage || 'ghcr.io/restatedev/restate:main') }}
125128
serviceContainerImage: ${{ inputs.serviceImage != '' && inputs.serviceImage || 'restatedev/test-services-java' }}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Tests excluded when the SDK runs on the legacy (pure-Java) state machine, i.e. on JRE < 23,
2+
# where the Panama/FFM state machine is unavailable and only service protocol v6 is negotiated.
3+
#
4+
# Scope and limit key require service protocol v7 (native/FFM state machine, JRE >= 23), so these
5+
# tests must be skipped on the legacy state machine. This file is passed via `--exclusions-file`
6+
# only for the JRE 17 and 21 CI legs; the JRE 25 leg runs them.
7+
exclusions:
8+
"default":
9+
- "dev.restate.sdktesting.tests.ServiceToServiceScopeConcurrency.scopeAndLimitKeyArePropagatedOnServiceToServiceCalls"

client-kotlin/src/main/kotlin/dev/restate/client/kotlin/ingress.kt

Lines changed: 120 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,108 @@ val <Res> Response<Res>.response: Res
271271
val <Res> SendResponse<Res>.sendStatus: SendResponse.SendStatus
272272
get() = this.sendStatus()
273273

274+
/**
275+
* **PREVIEW:** Returns a [ScopedKotlinClient] that routes all outgoing calls within the given
276+
* scope.
277+
*
278+
* **NOTE:** This API is in preview and is not enabled by default. To use it in restate-server 1.7,
279+
* enable the flow control and protocol v7 experimental features, via
280+
* `RESTATE_EXPERIMENTAL_ENABLE_PROTOCOL_V7=true` and `RESTATE_EXPERIMENTAL_ENABLE_VQUEUES=true`.
281+
* These can be enabled only on **new clusters**, for more info check out
282+
* https://docs.restate.dev/services/flow-control#enabling-flow-control. If these experimental
283+
* features aren't enabled, the call fails with a retryable error and keeps retrying until they are.
284+
*
285+
* A scope is a sub-grouping of resources (invocations, virtual object instances, workflow
286+
* instances, concurrency limits) within the Restate cluster. It becomes part of the target identity
287+
* tuple:
288+
* - `scope, service, handler, idempotencyKey?`
289+
* - `scope, virtualObject, objectKey, handler, idempotencyKey?`
290+
* - `scope, workflow, workflowKey, handler`
291+
*
292+
* Under the hood, the scope contributes to the partition key, so all resources in a scope get
293+
* co-located by the restate-server.
294+
*
295+
* Omitting the scope (i.e. using the regular `service` / `workflow` methods) is equivalent to
296+
* calling with no scope, which is the existing behavior.
297+
*
298+
* The scope key must consist only of `[a-zA-Z0-9_.-]` characters, with `1 <= length <= 36` chars.
299+
*
300+
* Example usage:
301+
* ```kotlin
302+
* // Route a call into a named scope
303+
* client.scope("tenant-123").service<MyService>().process(payload)
304+
* ```
305+
*
306+
* @param scopeKey the scope identifier
307+
* @see <a
308+
* href="https://docs.restate.dev/services/flow-control">https://docs.restate.dev/services/flow-control</a>
309+
*/
310+
@org.jetbrains.annotations.ApiStatus.Experimental
311+
fun Client.scope(scopeKey: String): ScopedKotlinClient = ScopedKotlinClient(this, scopeKey)
312+
313+
/**
314+
* **PREVIEW:** A client for making RPC calls within a specific scope.
315+
*
316+
* Obtain an instance via [Client.scope].
317+
*
318+
* @see Client.scope
319+
*/
320+
@org.jetbrains.annotations.ApiStatus.Experimental
321+
class ScopedKotlinClient
322+
@PublishedApi
323+
internal constructor(
324+
@PublishedApi internal val client: Client,
325+
@PublishedApi internal val scopeKey: String,
326+
) {
327+
/** @see Client.service */
328+
@org.jetbrains.annotations.ApiStatus.Experimental
329+
inline fun <reified SVC : Any> service(): SVC {
330+
return service(client, SVC::class.java, scopeKey)
331+
}
332+
333+
/** @see Client.virtualObject */
334+
@org.jetbrains.annotations.ApiStatus.Experimental
335+
inline fun <reified SVC : Any> virtualObject(key: String): SVC {
336+
return virtualObject(client, SVC::class.java, key, scopeKey)
337+
}
338+
339+
/** @see Client.workflow */
340+
@org.jetbrains.annotations.ApiStatus.Experimental
341+
inline fun <reified SVC : Any> workflow(key: String): SVC {
342+
return workflow(client, SVC::class.java, key, scopeKey)
343+
}
344+
345+
/** @see Client.toService */
346+
@org.jetbrains.annotations.ApiStatus.Experimental
347+
inline fun <reified SVC : Any> toService(): KClientRequestBuilder<SVC> {
348+
ReflectionUtils.mustHaveServiceAnnotation(SVC::class.java)
349+
require(ReflectionUtils.isKotlinClass(SVC::class.java)) {
350+
"Using Java classes with Kotlin's API is not supported"
351+
}
352+
return KClientRequestBuilder(client, SVC::class.java, null, scopeKey)
353+
}
354+
355+
/** @see Client.toVirtualObject */
356+
@org.jetbrains.annotations.ApiStatus.Experimental
357+
inline fun <reified SVC : Any> toVirtualObject(key: String): KClientRequestBuilder<SVC> {
358+
ReflectionUtils.mustHaveVirtualObjectAnnotation(SVC::class.java)
359+
require(ReflectionUtils.isKotlinClass(SVC::class.java)) {
360+
"Using Java classes with Kotlin's API is not supported"
361+
}
362+
return KClientRequestBuilder(client, SVC::class.java, key, scopeKey)
363+
}
364+
365+
/** @see Client.toWorkflow */
366+
@org.jetbrains.annotations.ApiStatus.Experimental
367+
inline fun <reified SVC : Any> toWorkflow(key: String): KClientRequestBuilder<SVC> {
368+
ReflectionUtils.mustHaveWorkflowAnnotation(SVC::class.java)
369+
require(ReflectionUtils.isKotlinClass(SVC::class.java)) {
370+
"Using Java classes with Kotlin's API is not supported"
371+
}
372+
return KClientRequestBuilder(client, SVC::class.java, key, scopeKey)
373+
}
374+
}
375+
274376
/**
275377
* Create a proxy client for a Restate service.
276378
*
@@ -329,15 +431,15 @@ inline fun <reified SVC : Any> Client.workflow(key: String): SVC {
329431
* @return a proxy that intercepts method calls and executes them via the client
330432
*/
331433
@PublishedApi
332-
internal fun <SVC : Any> service(client: Client, clazz: Class<SVC>): SVC {
434+
internal fun <SVC : Any> service(client: Client, clazz: Class<SVC>, scope: String? = null): SVC {
333435
ReflectionUtils.mustHaveServiceAnnotation(clazz)
334436
require(ReflectionUtils.isKotlinClass(clazz)) {
335437
"Using Java classes with Kotlin's API is not supported"
336438
}
337439

338440
val serviceName = ReflectionUtils.extractServiceName(clazz)
339441
return ProxySupport.createProxy(clazz) { invocation ->
340-
val request = invocation.captureInvocation(serviceName, null).toRequest()
442+
val request = invocation.captureInvocation(serviceName, null, scope).toRequest()
341443
@Suppress("UNCHECKED_CAST") val continuation = invocation.arguments.last() as Continuation<Any?>
342444

343445
// Start a coroutine that calls the client and resumes the continuation
@@ -356,15 +458,20 @@ internal fun <SVC : Any> service(client: Client, clazz: Class<SVC>): SVC {
356458
* @return a proxy that intercepts method calls and executes them via the client
357459
*/
358460
@PublishedApi
359-
internal fun <SVC : Any> virtualObject(client: Client, clazz: Class<SVC>, key: String): SVC {
461+
internal fun <SVC : Any> virtualObject(
462+
client: Client,
463+
clazz: Class<SVC>,
464+
key: String,
465+
scope: String? = null,
466+
): SVC {
360467
ReflectionUtils.mustHaveVirtualObjectAnnotation(clazz)
361468
require(ReflectionUtils.isKotlinClass(clazz)) {
362469
"Using Java classes with Kotlin's API is not supported"
363470
}
364471

365472
val serviceName = ReflectionUtils.extractServiceName(clazz)
366473
return ProxySupport.createProxy(clazz) { invocation ->
367-
val request = invocation.captureInvocation(serviceName, key).toRequest()
474+
val request = invocation.captureInvocation(serviceName, key, scope).toRequest()
368475
@Suppress("UNCHECKED_CAST") val continuation = invocation.arguments.last() as Continuation<Any?>
369476

370477
// Start a coroutine that calls the client and resumes the continuation
@@ -383,15 +490,20 @@ internal fun <SVC : Any> virtualObject(client: Client, clazz: Class<SVC>, key: S
383490
* @return a proxy that intercepts method calls and executes them via the client
384491
*/
385492
@PublishedApi
386-
internal fun <SVC : Any> workflow(client: Client, clazz: Class<SVC>, key: String): SVC {
493+
internal fun <SVC : Any> workflow(
494+
client: Client,
495+
clazz: Class<SVC>,
496+
key: String,
497+
scope: String? = null,
498+
): SVC {
387499
ReflectionUtils.mustHaveWorkflowAnnotation(clazz)
388500
require(ReflectionUtils.isKotlinClass(clazz)) {
389501
"Using Java classes with Kotlin's API is not supported"
390502
}
391503

392504
val serviceName = ReflectionUtils.extractServiceName(clazz)
393505
return ProxySupport.createProxy(clazz) { invocation ->
394-
val request = invocation.captureInvocation(serviceName, key).toRequest()
506+
val request = invocation.captureInvocation(serviceName, key, scope).toRequest()
395507
@Suppress("UNCHECKED_CAST") val continuation = invocation.arguments.last() as Continuation<Any?>
396508

397509
// Start a coroutine that calls the client and resumes the continuation
@@ -414,6 +526,7 @@ internal constructor(
414526
private val client: Client,
415527
private val clazz: Class<SVC>,
416528
private val key: String?,
529+
private val scope: String? = null,
417530
) {
418531
/**
419532
* Create a request by invoking a method on the target.
@@ -428,7 +541,7 @@ internal constructor(
428541
suspend fun <Res> request(block: suspend SVC.() -> Res): KClientRequest<Any?, Res> {
429542
return KClientRequestImpl(
430543
client,
431-
RequestCaptureProxy(clazz, key).capture(block as suspend SVC.() -> Any?).toRequest(),
544+
RequestCaptureProxy(clazz, key, scope).capture(block as suspend SVC.() -> Any?).toRequest(),
432545
)
433546
as KClientRequest<Any?, Res>
434547
}

client/src/main/java/dev/restate/client/Client.java

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,62 @@ default Response<Output<Res>> getOutput() throws IngressException {
531531
}
532532
}
533533

534+
/**
535+
* <b>PREVIEW:</b> Returns a {@link ScopedClient} that routes all outgoing calls within the given
536+
* scope.
537+
*
538+
* <p><b>NOTE:</b> This API is in preview and is not enabled by default. To use it in
539+
* restate-server 1.7, enable the flow control and protocol v7 experimental features, via {@code
540+
* RESTATE_EXPERIMENTAL_ENABLE_PROTOCOL_V7=true} and {@code
541+
* RESTATE_EXPERIMENTAL_ENABLE_VQUEUES=true}. These can be enabled only on <b>new clusters</b>,
542+
* for more info check out https://docs.restate.dev/services/flow-control#enabling-flow-control.
543+
* If these experimental features aren't enabled, the call fails with a retryable error and keeps
544+
* retrying until they are.
545+
*
546+
* <p>A scope is a sub-grouping of resources (invocations, virtual object instances, workflow
547+
* instances, concurrency limits) within the Restate cluster. It becomes part of the target
548+
* identity tuple:
549+
*
550+
* <ul>
551+
* <li>{@code scope, service, handler, idempotencyKey?}
552+
* <li>{@code scope, virtualObject, objectKey, handler, idempotencyKey?}
553+
* <li>{@code scope, workflow, workflowKey, handler}
554+
* </ul>
555+
*
556+
* <p>Under the hood, the scope contributes to the partition key, so all resources in a scope get
557+
* co-located by the restate-server.
558+
*
559+
* <p>Omitting the scope (i.e. using the regular {@link #service(Class)} / {@link #workflow(Class,
560+
* String)} methods) is equivalent to calling with no scope, which is the existing behavior.
561+
*
562+
* <p>The scope key must consist only of {@code [a-zA-Z0-9_.-]} characters, with {@code 1 <=
563+
* length <= 36} chars.
564+
*
565+
* <pre>{@code
566+
* Client client = Client.connect("http://localhost:8080");
567+
*
568+
* // Route a call into a named scope
569+
* client.scope("tenant-123").service(MyService.class).process(payload);
570+
*
571+
* // Idempotency keys are scoped — "req-1" in "tenant-123" is distinct from "req-1" in "tenant-456"
572+
* client.scope("tenant-123").serviceHandle(MyService.class)
573+
* .call(MyService::process, payload, InvocationOptions.idempotencyKey("req-1").build());
574+
*
575+
* // Combine with a limit key to enforce per-scope concurrency limits
576+
* client.scope("tenant-123").workflowHandle(MyWorkflow.class, "wf-key")
577+
* .call(MyWorkflow::run, input, InvocationOptions.limitKey("api-key/user42").build());
578+
* }</pre>
579+
*
580+
* @param scopeKey the scope identifier
581+
* @return a {@link ScopedClient}
582+
* @see <a
583+
* href="https://docs.restate.dev/services/flow-control">https://docs.restate.dev/services/flow-control</a>
584+
*/
585+
@org.jetbrains.annotations.ApiStatus.Experimental
586+
default ScopedClient scope(String scopeKey) {
587+
return new ScopedClient(this, scopeKey);
588+
}
589+
534590
/**
535591
* Simple API to invoke a Restate service from the ingress.
536592
*

client/src/main/java/dev/restate/client/ClientServiceHandleImpl.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,21 @@ final class ClientServiceHandleImpl<SVC> implements ClientServiceHandle<SVC> {
2929
private final Class<SVC> clazz;
3030
private final String serviceName;
3131
private final @Nullable String key;
32+
private final @Nullable String scope;
3233

3334
private MethodInfoCollector<SVC> methodInfoCollector;
3435

3536
ClientServiceHandleImpl(Client innerClient, Class<SVC> clazz, @Nullable String key) {
37+
this(innerClient, clazz, key, null);
38+
}
39+
40+
ClientServiceHandleImpl(
41+
Client innerClient, Class<SVC> clazz, @Nullable String key, @Nullable String scope) {
3642
this.innerClient = innerClient;
3743
this.clazz = clazz;
3844
this.serviceName = ReflectionUtils.extractServiceName(clazz);
3945
this.key = key;
46+
this.scope = scope;
4047
}
4148

4249
@SuppressWarnings("unchecked")
@@ -46,6 +53,7 @@ public <I, O> CompletableFuture<Response<O>> callAsync(
4653
MethodInfo methodInfo = getMethodInfoCollector().resolve(s, input);
4754
return innerClient.callAsync(
4855
toRequest(
56+
scope,
4957
serviceName,
5058
key,
5159
methodInfo.getHandlerName(),
@@ -62,6 +70,7 @@ public <I> CompletableFuture<Response<Void>> callAsync(
6270
MethodInfo methodInfo = getMethodInfoCollector().resolve(s, input);
6371
return innerClient.callAsync(
6472
toRequest(
73+
scope,
6574
serviceName,
6675
key,
6776
methodInfo.getHandlerName(),
@@ -78,6 +87,7 @@ public <O> CompletableFuture<Response<O>> callAsync(
7887
MethodInfo methodInfo = getMethodInfoCollector().resolve(s);
7988
return innerClient.callAsync(
8089
toRequest(
90+
scope,
8191
serviceName,
8292
key,
8393
methodInfo.getHandlerName(),
@@ -93,6 +103,7 @@ public CompletableFuture<Response<Void>> callAsync(
93103
MethodInfo methodInfo = getMethodInfoCollector().resolve(s);
94104
return innerClient.callAsync(
95105
toRequest(
106+
scope,
96107
serviceName,
97108
key,
98109
methodInfo.getHandlerName(),
@@ -109,6 +120,7 @@ public <I, O> CompletableFuture<SendResponse<O>> sendAsync(
109120
MethodInfo methodInfo = getMethodInfoCollector().resolve(s, input);
110121
return innerClient.sendAsync(
111122
toRequest(
123+
scope,
112124
serviceName,
113125
key,
114126
methodInfo.getHandlerName(),
@@ -126,6 +138,7 @@ public <I> CompletableFuture<SendResponse<Void>> sendAsync(
126138
MethodInfo methodInfo = getMethodInfoCollector().resolve(s, input);
127139
return innerClient.sendAsync(
128140
toRequest(
141+
scope,
129142
serviceName,
130143
key,
131144
methodInfo.getHandlerName(),
@@ -143,6 +156,7 @@ public <O> CompletableFuture<SendResponse<O>> sendAsync(
143156
MethodInfo methodInfo = getMethodInfoCollector().resolve(s);
144157
return innerClient.sendAsync(
145158
toRequest(
159+
scope,
146160
serviceName,
147161
key,
148162
methodInfo.getHandlerName(),
@@ -159,6 +173,7 @@ public CompletableFuture<SendResponse<Void>> sendAsync(
159173
MethodInfo methodInfo = getMethodInfoCollector().resolve(s);
160174
return innerClient.sendAsync(
161175
toRequest(
176+
scope,
162177
serviceName,
163178
key,
164179
methodInfo.getHandlerName(),

0 commit comments

Comments
 (0)