Release v0.11.0
New Features
(Preview) Scope and limit key to enable flow control
This release adds the SDK API for flow control, the new Restate 1.7 feature to cap how many invocations run concurrently within a scope, with optional hierarchical limit keys. Use it to bound cost (especially for AI agents, where every concurrent invocation is real model/API spend), shield downstream systems from bursts, and share capacity fairly across tenants.
To use scope and limit keys in service to service communication:
// Route a call into a named scope
ctx.service_client::<MyServiceClient>()
.process(payload)
.scope("tenant-123")
.call()
.await?;
// Add a limit key for hierarchical concurrency limits within the scope
ctx.workflow_client::<MyWorkflowClient>("wf-key")
.run(input)
.scope("tenant-123")
.limit_key("api-key/user42")
.call()
.await?;Check out the flow control documentation for more details on how concurrency limits work and how to set them up.
NOTE: This API is in preview and is not enabled by default. To use it in restate-server 1.7, enable the flow control and protocol v7 experimental features via
RESTATE_EXPERIMENTAL_ENABLE_PROTOCOL_V7=trueandRESTATE_EXPERIMENTAL_ENABLE_VQUEUES=true(new clusters only). See https://docs.restate.dev/services/flow-control for details.
Signals and the InvocationHandle API
The Rust SDK gains signals, alongside a redesigned InvocationHandle API.
Signals let invocations communicate directly with each other: one invocation waits for a signal, and any other handler can resolve or reject it by referencing the target invocation's ID.
// Inside the invocation that wants to wait:
let approved: bool = ctx.signal::<bool>("approved").await?;
// Inside another handler that knows the target invocation's ID:
let target = ctx.invocation_handle(target_invocation_id);
target.signal("approved").resolve(true);
// or, to wake the waiter with a terminal error:
target.signal("approved").reject(TerminalError::new("Request denied"));New API to define services, objects and workflows
Services, virtual objects and workflows are now defined by putting #[restate_sdk::service] (or #[object]/#[workflow]) on the impl block of a plain struct, with #[handler] on each method.
No more separate trait + impl, no more .serve().
Before:
#[restate_sdk::service]
trait Greeter {
async fn greet(name: String) -> HandlerResult<String>;
}
struct GreeterImpl;
impl Greeter for GreeterImpl {
async fn greet(&self, _ctx: Context<'_>, name: String) -> HandlerResult<String> {
Ok(format!("Greetings {name}"))
}
}
Endpoint::builder().bind(GreeterImpl.serve()).build();After:
struct Greeter;
#[restate_sdk::service]
impl Greeter {
#[handler]
async fn greet(&self, _ctx: Context<'_>, name: String) -> HandlerResult<String> {
Ok(format!("Greetings {name}"))
}
}
Endpoint::builder().bind(Greeter).build();For virtual objects and workflows, whether a handler is shared or exclusive is inferred directly from the context type in its signature (ObjectContext/SharedObjectContext, WorkflowContext/SharedWorkflowContext):
struct Counter;
#[restate_sdk::object]
impl Counter {
#[handler]
async fn add(&self, ctx: ObjectContext<'_>, val: u64) -> HandlerResult<u64> {
let new = ctx.get::<u64>("count").await?.unwrap_or(0) + val;
ctx.set("count", new);
Ok(new)
}
#[handler]
async fn get(&self, ctx: SharedObjectContext<'_>) -> HandlerResult<u64> {
Ok(ctx.get::<u64>("count").await?.unwrap_or(0))
}
}The new API supports overriding service/handler names and client visibility, and fully generic services (type params, bounds, where-clauses):
#[restate_sdk::service(name = "greeter", client_visibility = "pub(crate)")]
impl Greeter {
#[handler(name = "myGreet")]
async fn my_greet(&self, _ctx: Context<'_>, name: String) -> HandlerResult<String> {
Ok(name)
}
}Together with this change, you can now express service and handler level configuration directly in the macro arguments:
struct MyService;
#[restate_sdk::service(
inactivity_timeout = "1m",
idempotency_retention = "1 day",
journal_retention = "3 days",
invocation_retry_policy(
initial_interval = "100ms",
max_interval = "10s",
factor = 2.0,
max_attempts = 10,
on_max_attempts = "pause"
)
)]
impl MyService {
#[handler(journal_retention = "1h", lazy_state)]
async fn my_handler(&self, ctx: Context<'_>) -> HandlerResult<()> {
// ...
Ok(())
}
}See the configuration module docs for the complete reference.
NOTE: The old trait-based definition API still works but is deprecated and will emit a warning; please migrate to the struct-based API above.
Durable map / map_ok / map_err combinators
DurableFuture gains map, map_ok and map_err. Unlike futures' FutureExt::map / TryFutureExt::map_ok, these preserve the durable future type, so the mapped result is still a DurableFuture and can keep being used in select! and in DurableFuturesUnordered.
use restate_sdk::prelude::*;
use restate_sdk::context::DurableFuture; // brings map / map_ok / map_err into scope
async fn example(ctx: Context<'_>) -> Result<(), TerminalError> {
// map: transform the whole Output. Here Result<(), _> becomes String, so no `?` needed.
let outcome: String = ctx
.sleep(std::time::Duration::from_secs(1))
.map(|res| match res {
Ok(()) => "woke up".to_owned(),
Err(e) => format!("cancelled: {e}"),
})
.await;
// map_ok: transform only the success value; a terminal error passes through untouched.
let len: usize = ctx.signal::<String>("count").map_ok(|s| s.len()).await?;
// map_err: transform only the terminal error; the success value passes through untouched.
let token: String = ctx
.signal::<String>("token")
.map_err(|e| TerminalError::new(format!("token signal failed: {}", e.message())))
.await?;
let _ = (outcome, len, token);
Ok(())
}Turn any error into a terminal error with .terminal()
A new TerminalErrorExt trait provides the shorthand to convert Result error value to terminal error using .terminal():
use restate_sdk::prelude::*;
async fn handle() -> Result<(), HandlerError> {
let parsed: i32 = "not a number".parse().terminal()?;
Ok(())
}attach() and the redesigned InvocationHandle
InvocationHandle was redesigned to hold attach() and the new signal() handle.
You can obtain one when you already know an invocation id (ctx.invocation_handle(id)), by .await-ing the handle returned from a one-way send(), or from a call() future:
let handle = ctx.invocation_handle(invocation_id);
// Known synchronously, no await:
let id: &str = handle.invocation_id();
// Await the invocation's output/result:
let output: MyOutput = handle.attach::<MyOutput>().await?;
// Fire-and-forget cancellation:
handle.cancel();This is a breaking change to the previous InvocationHandle trait and their usage:
invocation_id()is now a synchronous-> &str(wasasync -> Result<String, _>).cancel()is now a synchronous fire-and-forget (was async).CallFuturedoesn't implementInvocationHandleanymore, now you need to callinvocation_handle(&self).send()/send_after()now return an awaitableSendHandle:.await-ing it yields anInvocationHandleto the new invocation.
Additional changes
- Re-exported the
randanduuidcrates (#123). - Bumped the shared e2e test suite to
restatedev/e2e/sdk-tests@v2.2and conformedtest-servicesto the v2.0/v2.2 contract (#124). - CI now reuses a prebuilt
test-servicesimage:integration.yamlaccepts aserviceImageinput, and a newdocker.yamlbuilds and pushesghcr.io/restatedev/test-services-rust(#124).
Full Changelog: v0.10.0...v0.11.0