Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/integration.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ jobs:
run: docker pull ${{ inputs.serviceImage }}

- name: Run test tool
uses: restatedev/e2e/sdk-tests@v1.0
uses: restatedev/e2e/sdk-tests@v2.2
with:
restateContainerImage: ${{ inputs.restateCommit != '' && 'localhost/restatedev/restate-commit-download:latest' || (inputs.restateImage != '' && inputs.restateImage || 'ghcr.io/restatedev/restate:main') }}
serviceContainerImage: ${{ inputs.serviceImage != '' && inputs.serviceImage || 'restatedev/test-services-ruby' }}
Expand Down
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Metrics/ParameterLists:
- "lib/restate/server/context.rb"
- "lib/restate/virtual_object.rb"
- "lib/restate/vm.rb"
- "lib/restate/service_proxy.rb"

# The default branch handles the empty accept header case
Lint/DuplicateBranch:
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions docs/INTERNALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ lib/
├── vm.rb VMWrapper — Ruby bridge to native VM
└── workflow.rb Workflow class + main/handler DSL + .call/.send!
ext/restate_internal/
├── Cargo.toml Depends on restate-sdk-shared-core 7.0.0 (crates.io), magnus 0.8
├── Cargo.toml Depends on restate-sdk-shared-core 7.0.1 (crates.io), magnus 0.8
└── src/lib.rs Rust ↔ Ruby bindings (~1265 lines)

spec/
Expand Down Expand Up @@ -227,7 +227,7 @@ Three classes for async result handling:
- `await` — first call resolves via the internal context's `resolve_handle(handle)`, subsequent calls return cached value
- `completed?` — non-blocking check via the internal context's `completed?(handle)`
- `handle` — the raw VM notification handle (Integer)
- `or_timeout(duration)` — races `self` against `Restate.sleep(duration)` via `Restate.wait_any`. Returns the future's value if it completes first; raises `Restate::TimeoutError` if the sleep wins. The sleep handle is **not** cancelled when this future wins — `restate-sdk-shared-core` 7.0.0 exposes no `sys_cancel_handle` primitive (only `sys_cancel_invocation` on a different invocation), so the journal entry remains until the timer fires. Same footprint as TS `RestatePromise.orTimeout` and Java `DurableFuture.withTimeout`.
- `or_timeout(duration)` — races `self` against `Restate.sleep(duration)` via `Restate.wait_any`. Returns the future's value if it completes first; raises `Restate::TimeoutError` if the sleep wins. The sleep handle is **not** cancelled when this future wins — `restate-sdk-shared-core` 7.0.1 exposes no `sys_cancel_handle` primitive (only `sys_cancel_invocation` on a different invocation), so the journal entry remains until the timer fires. Same footprint as TS `RestatePromise.orTimeout` and Java `DurableFuture.withTimeout`.

**`DurableCallFuture` < `DurableFuture`** — returned by `Restate.service_call`, `Restate.object_call`, `Restate.workflow_call`.
- Two handles: `result_handle` (for await) and `invocation_id_handle` (for ID)
Expand Down
75 changes: 71 additions & 4 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,60 @@ Restate.service_call(
| `input_serde:` | yes | yes | Override input serializer |
| `output_serde:` | yes | — | Override output serializer |

### Scoped Calls & Concurrency Limits

> **Preview.** This API is not enabled by default. On restate-server 1.7 it requires the
> experimental protocol v7 + vqueues features (`RESTATE_EXPERIMENTAL_ENABLE_PROTOCOL_V7=true`
> and `RESTATE_EXPERIMENTAL_ENABLE_VQUEUES=true`), and scoped virtual objects additionally
> require `RESTATE_EXPERIMENTAL_ENABLE_SCOPED_VIRTUAL_OBJECTS=true`. If the features aren't
> enabled the call fails with a retryable error and keeps retrying until they are.
> See [Flow control](https://docs.restate.dev/services/flow-control).

Route a call within a *scope* so Restate applies the concurrency / rate-limit rules configured for
that scope. A typical use is rate-limiting a third-party API per user-provided API key. Both the
fluent and explicit call APIs support it.

```ruby
# Fluent (recommended): scope: / limit_key: on .call / .send!
AmazonMerchantService.call(scope: api_key).checkout(order).await
Counter.call('my-key', scope: 'tenant1', limit_key: 'tenant1/user42').add(5).await
Worker.send!(scope: 'tenant1', delay: 60).process(task) # fire-and-forget

# Explicit: Restate.scope(scope) returns a ScopedContext
Restate.scope(api_key).service_call(AmazonMerchantService, :checkout, order).await
```

A **scope** is a sub-grouping of resources (invocations, workflow instances, concurrency limits).
It becomes part of the target identity and contributes to the partition key, so resources in a scope
are co-located. Omitting the scope (the regular `Worker.call.…` / `Restate.service_call` methods) is
equivalent to calling with no scope — the existing behavior. A scope must match `[a-zA-Z0-9_.-]`,
1–36 characters.

The optional **`limit_key:`** is a hierarchical concurrency limit key with one or two `/`-separated
levels (e.g. `"tenant1"` or `"tenant1/user42"`), each level matching `[a-zA-Z0-9_.-]`, 1–36
characters. The limit key is **not** part of the request identity — two calls to the same target with
the same scope and key but different limit keys refer to the *same* resource instance; the limit key
only affects concurrency limits.

`Restate.scope(scope)` (or `ctx.scope(scope)`) returns a **`ScopedContext`** that mirrors the regular
explicit call surface — `service_call` / `service_send`, `object_call` / `object_send`,
`workflow_call` / `workflow_send`, same arguments as their `Restate.*` counterparts, plus
`limit_key:`:

```ruby
scoped = Restate.scope('tenant1')
scoped.service_call(Greeter, :greet, 'World', limit_key: 'tenant1/user42').await
scoped.object_call(Counter, :add, 'my-key', 5, limit_key: 'tenant1').await
scoped.workflow_send(UserSignup, :run, 'user42', email) # fire-and-forget
```

For raw proxying, `Restate.generic_call` / `Restate.generic_send` also accept `scope:` and
`limit_key:` directly.

The scope and limit key an invocation was made with are readable from `Restate.request` (see
[Request Metadata](#request-metadata)). A full runnable example lives in
[`examples/concurrency_limit.rb`](../examples/concurrency_limit.rb).

### Fan-Out / Fan-In

Launch multiple calls concurrently, then collect all results.
Expand Down Expand Up @@ -536,9 +590,12 @@ Restate.reject_promise('approval', 'denied', code: 400)

```ruby
request = Restate.request
request.id # Invocation ID (String)
request.headers # Request headers (Hash)
request.body # Raw input bytes (String)
request.id # Invocation ID (String)
request.headers # Request headers (Hash)
request.body # Raw input bytes (String)
request.scope # Scope this invocation was routed within (String or nil)
request.limit_key # Concurrency limit key it was made with (String or nil)
request.idempotency_key # Idempotency key it was made with (String or nil)

key = Restate.key # Object/workflow key (String)
```
Expand Down Expand Up @@ -1239,6 +1296,7 @@ The `examples/` directory contains runnable examples:
| `service_communication.rb` | Fluent call API, fan-out/fan-in, `wait_any`, `or_timeout`, awakeables |
| `typed_handlers.rb` | `input:`/`output:` with `Dry::Struct`, JSON Schema generation |
| `service_configuration.rb` | Service-level config: timeouts, retention, retry policy, lazy state |
| `concurrency_limit.rb` | Scoped calls & flow-control limit keys (`Restate.scope`, `limit_key:`) |
| `deadlock_detection.rb` | Built-in deadlock detection middleware for VirtualObjects |
| [`middleware_example/`](../middleware_example/) | Real OpenTelemetry tracing + tenant isolation middleware (self-contained) |

Expand Down Expand Up @@ -1361,6 +1419,15 @@ Restate.service_send(svc, handler, arg, delay: nil) -> SendHandle
Restate.object_send(svc, handler, key, arg, delay: nil) -> SendHandle
Restate.workflow_send(svc, handler, key, arg, delay: nil) -> SendHandle

# Scoped calls & concurrency limits (preview)
Restate.scope(scope) -> ScopedContext
scoped.service_call(svc, handler, arg, limit_key: nil) -> DurableCallFuture
scoped.object_call(svc, handler, key, arg, limit_key: nil) -> DurableCallFuture
scoped.workflow_call(svc, handler, key, arg, limit_key: nil) -> DurableCallFuture
scoped.service_send / object_send / workflow_send (…, limit_key: nil) -> SendHandle
Restate.generic_call(svc, handler, arg, scope: nil, limit_key: nil) -> DurableCallFuture
Restate.generic_send(svc, handler, arg, scope: nil, limit_key: nil) -> SendHandle

# Awakeables
Restate.awakeable -> [id, DurableFuture]
Restate.resolve_awakeable(id, payload)
Expand All @@ -1380,7 +1447,7 @@ Restate.all_settled(*futures) -> CombinedFuture (awaits to Array of outcome Hash
Restate.wait_any(*futures) -> [completed, remaining] # eager, not a future

# Metadata
Restate.request -> Request{id, headers, body}
Restate.request -> Request{id, headers, body, scope, limit_key, idempotency_key}
Restate.request.attempt_finished_event -> AttemptFinishedEvent
Restate.key -> String

Expand Down
74 changes: 74 additions & 0 deletions examples/concurrency_limit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# frozen_string_literal: true

#
# Example: Concurrency Limit (scoped calls + flow-control limit keys)
#
# Shows how to route service-to-service calls within a *scope* so that Restate
# can enforce concurrency / rate-limit rules per scope. A common use case is
# wrapping a third-party API and rate-limiting calls per user-provided API key,
# so you never exceed the third-party quota.
#
# NOTE: This API is in preview and is not enabled by default. On restate-server
# 1.7 it requires the experimental protocol v7 + vqueues features:
# RESTATE_EXPERIMENTAL_ENABLE_PROTOCOL_V7=true
# RESTATE_EXPERIMENTAL_ENABLE_VQUEUES=true
# See https://docs.restate.dev/services/flow-control
#
# Features:
# - Service.call(scope:, limit_key:) — fluent call routed within a scope
# - limit_key: — hierarchical concurrency limit key
#
# Try it:
# curl localhost:8080/OrderFulfillment/process_order \
# -H 'content-type: application/json' \
# -d '{"orderId": "order-1", "amazonApiKey": "amz-api-key-123"}'

require 'restate'

# --- Amazon Merchant Service: a third-party API wrapper ---
class AmazonMerchantService < Restate::Service
handler def checkout(req)
# In a real app, this would call the Amazon Merchant API
# using the user-provided API key from the scope.
{ 'confirmationId' => "conf-#{req['orderId']}" }
end
end

# --- Order Fulfillment: uses scoped calls to rate-limit per API key ---
class OrderFulfillment < Restate::Service
# Process an order by calling AmazonMerchantService within a scope keyed by
# the user's Amazon API key.
#
# The scope + configured rate limit rules ensure that calls sharing the same
# API key are rate-limited (e.g. 10 requests every 2 hours), preventing us
# from exceeding the third-party API quota.
#
# Rate limit rules are configured externally in Restate:
#
# # Default rule: on any scope, rate limit AmazonMerchantService to 10 req / 2h
# {
# "scope": { "any": true },
# "match": { "service": "AmazonMerchantService" },
# "limit": {
# "rateLimit": { "count": 10, "interval": { "hours": 2 } }
# }
# }
#
# # Override for a specific API key: increase limit to 100 req / 2h
# {
# "scope": { "equals": "amz-api-key-123" },
# "match": { "service": "AmazonMerchantService" },
# "limit": {
# "rateLimit": { "count": 100, "interval": { "hours": 2 } }
# }
# }
handler def process_order(req)
# Scope the call by the user's Amazon API key.
# Restate enforces the rate limit rules configured above.
response = AmazonMerchantService.call(scope: req['amazonApiKey']).checkout(
{ 'orderId' => req['orderId'], 'productId' => 'product-42', 'quantity' => 1 }
).await

"Order #{req['orderId']} confirmed: #{response['confirmationId']}"
end
end
4 changes: 3 additions & 1 deletion examples/config.ru
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require_relative 'workflow'
require_relative 'service_communication'
require_relative 'typed_handlers'
require_relative 'service_configuration'
require_relative 'concurrency_limit'

endpoint = Restate.endpoint(
Greeter,
Expand All @@ -23,7 +24,8 @@ endpoint = Restate.endpoint(
UserSignup,
Worker, FanOut,
TicketService,
OrderProcessor
OrderProcessor,
AmazonMerchantService, OrderFulfillment
)

run endpoint.app
2 changes: 1 addition & 1 deletion ext/restate_internal/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ doc = false
[dependencies]
magnus = { version = "0.8", features = ["rb-sys"] }
rb-sys = { version = "0.9", features = ["stable-api-compiled-fallback"] }
restate-sdk-shared-core = { version = "7.0.0" , features = ["request_identity", "sha2_random_seed", "rust_crypto"] }
restate-sdk-shared-core = { version = "7.0.1" , features = ["request_identity", "sha2_random_seed", "rust_crypto"] }
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
Loading
Loading