diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index a52c13c..1a1a9e9 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -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' }} diff --git a/.rubocop.yml b/.rubocop.yml index 5a4ab7c..84a73dd 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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: diff --git a/Cargo.lock b/Cargo.lock index 938a15a..8e142d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -830,9 +830,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "restate-sdk-shared-core" -version = "7.0.0" +version = "7.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bf02583379fc28d6386bd864629ba6553ce942124956b862fa9e95a4d836558" +checksum = "8bd1f1d1961ac32aea52923f089e668a07630ca9c4edcadf18d38bc24e5f14c0" dependencies = [ "base64", "bs58", diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index 43b1bac..9a37224 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -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/ @@ -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) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index a79b404..c0d439b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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. @@ -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) ``` @@ -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) | @@ -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) @@ -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 diff --git a/examples/concurrency_limit.rb b/examples/concurrency_limit.rb new file mode 100644 index 0000000..e91d76a --- /dev/null +++ b/examples/concurrency_limit.rb @@ -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 diff --git a/examples/config.ru b/examples/config.ru index 7c88fe6..bc60665 100644 --- a/examples/config.ru +++ b/examples/config.ru @@ -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, @@ -23,7 +24,8 @@ endpoint = Restate.endpoint( UserSignup, Worker, FanOut, TicketService, - OrderProcessor + OrderProcessor, + AmazonMerchantService, OrderFulfillment ) run endpoint.app diff --git a/ext/restate_internal/Cargo.toml b/ext/restate_internal/Cargo.toml index 569d4e3..a22420c 100644 --- a/ext/restate_internal/Cargo.toml +++ b/ext/restate_internal/Cargo.toml @@ -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"] } diff --git a/ext/restate_internal/src/lib.rs b/ext/restate_internal/src/lib.rs index 115902b..1a69e69 100644 --- a/ext/restate_internal/src/lib.rs +++ b/ext/restate_internal/src/lib.rs @@ -215,6 +215,9 @@ struct RbInput { key: String, headers: Vec, input: Vec, + scope: Option, + limit_key: Option, + idempotency_key: Option, } impl RbInput { @@ -227,6 +230,15 @@ impl RbInput { fn key(&self) -> &str { &self.key } + fn scope(&self) -> Option<&str> { + self.scope.as_deref() + } + fn limit_key(&self) -> Option<&str> { + self.limit_key.as_deref() + } + fn idempotency_key(&self) -> Option<&str> { + self.idempotency_key.as_deref() + } fn headers_array(&self) -> Result { let ruby = Ruby::get().map_err(|_| Error::new(vm_error_class(), "Ruby not available"))?; let ary = ruby.ary_new_capa(self.headers.len()); @@ -249,6 +261,9 @@ impl From for RbInput { key: i.key, headers: i.headers.into_iter().map(Into::into).collect(), input: i.input.into(), + scope: i.scope, + limit_key: i.limit_key, + idempotency_key: i.idempotency_key, } } } @@ -584,7 +599,9 @@ impl RbVM { .map_err(core_error_to_magnus) } - // sys_call(service, handler, buffer, key_or_nil, idempotency_key_or_nil, headers_or_nil) + // sys_call(service, handler, buffer, key_or_nil, idempotency_key_or_nil, headers_or_nil, + // scope_or_nil, limit_key_or_nil) + #[allow(clippy::too_many_arguments)] fn sys_call( &self, service: String, @@ -593,6 +610,8 @@ impl RbVM { key: Value, idempotency_key: Value, headers: Value, + scope: Value, + limit_key: Value, ) -> Result { let bytes: Vec = unsafe { buffer.as_slice().to_vec() }; let key_opt: Option = if key.is_nil() { @@ -610,6 +629,16 @@ impl RbVM { } else { parse_headers_array(RArray::try_convert(headers)?)? }; + let scope_opt: Option = if scope.is_nil() { + None + } else { + Some(String::try_convert(scope)?) + }; + let limit_key_opt: Option = if limit_key.is_nil() { + None + } else { + Some(String::try_convert(limit_key)?) + }; self.vm .borrow_mut() .sys_call( @@ -618,8 +647,8 @@ impl RbVM { handler, key: key_opt, idempotency_key: idem_opt, - scope: None, - limit_key: None, + scope: scope_opt, + limit_key: limit_key_opt, headers: hdr_vec, }, bytes.into(), @@ -630,7 +659,9 @@ impl RbVM { .map_err(core_error_to_magnus) } - // sys_send(service, handler, buffer, key_or_nil, delay_or_nil, idempotency_key_or_nil, headers_or_nil) + // sys_send(service, handler, buffer, key_or_nil, delay_or_nil, idempotency_key_or_nil, + // headers_or_nil, scope_or_nil, limit_key_or_nil) + #[allow(clippy::too_many_arguments)] fn sys_send( &self, service: String, @@ -640,6 +671,8 @@ impl RbVM { delay: Value, idempotency_key: Value, headers: Value, + scope: Value, + limit_key: Value, ) -> Result { let bytes: Vec = unsafe { buffer.as_slice().to_vec() }; let key_opt: Option = if key.is_nil() { @@ -662,6 +695,16 @@ impl RbVM { } else { parse_headers_array(RArray::try_convert(headers)?)? }; + let scope_opt: Option = if scope.is_nil() { + None + } else { + Some(String::try_convert(scope)?) + }; + let limit_key_opt: Option = if limit_key.is_nil() { + None + } else { + Some(String::try_convert(limit_key)?) + }; self.vm .borrow_mut() .sys_send( @@ -670,8 +713,8 @@ impl RbVM { handler, key: key_opt, idempotency_key: idem_opt, - scope: None, - limit_key: None, + scope: scope_opt, + limit_key: limit_key_opt, headers: hdr_vec, }, bytes.into(), @@ -1135,6 +1178,9 @@ fn init(ruby: &Ruby) -> Result<(), Error> { input_class.define_method("invocation_id", method!(RbInput::invocation_id, 0))?; input_class.define_method("random_seed", method!(RbInput::random_seed, 0))?; input_class.define_method("key", method!(RbInput::key, 0))?; + input_class.define_method("scope", method!(RbInput::scope, 0))?; + input_class.define_method("limit_key", method!(RbInput::limit_key, 0))?; + input_class.define_method("idempotency_key", method!(RbInput::idempotency_key, 0))?; input_class.define_method("headers", method!(RbInput::headers_array, 0))?; input_class.define_method("input", method!(RbInput::input_bytes, 0))?; @@ -1189,8 +1235,8 @@ fn init(ruby: &Ruby) -> Result<(), Error> { vm_class.define_method("sys_clear_state", method!(RbVM::sys_clear_state, 1))?; vm_class.define_method("sys_clear_all_state", method!(RbVM::sys_clear_all_state, 0))?; vm_class.define_method("sys_sleep", method!(RbVM::sys_sleep, 2))?; - vm_class.define_method("sys_call", method!(RbVM::sys_call, 6))?; - vm_class.define_method("sys_send", method!(RbVM::sys_send, 7))?; + vm_class.define_method("sys_call", method!(RbVM::sys_call, 8))?; + vm_class.define_method("sys_send", method!(RbVM::sys_send, 9))?; vm_class.define_method("sys_run", method!(RbVM::sys_run, 1))?; vm_class.define_method( "propose_run_completion_success", diff --git a/lib/restate.rb b/lib/restate.rb index 2057169..2c74917 100644 --- a/lib/restate.rb +++ b/lib/restate.rb @@ -219,15 +219,27 @@ def workflow_send(service, handler, key, arg, delay: nil, idempotency_key: nil, end # Durably call a handler using raw bytes (no serialization). - def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil) - fetch_context!.generic_call(service, handler, arg, key: key, - idempotency_key: idempotency_key, headers: headers) + def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) + fetch_context!.generic_call(service, handler, arg, key: key, idempotency_key: idempotency_key, + headers: headers, scope: scope, limit_key: limit_key) end # Fire-and-forget send using raw bytes (no serialization). - def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil) + def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) fetch_context!.generic_send(service, handler, arg, key: key, delay: delay, - idempotency_key: idempotency_key, headers: headers) + idempotency_key: idempotency_key, headers: headers, + scope: scope, limit_key: limit_key) + end + + # Returns a +ScopedContext+ that routes all outgoing calls within the given scope. + # See {Restate::Context#scope} for the full semantics and constraints. + # + # @example + # Restate.scope("tenant1").service_call(Greeter, :greet, "World", limit_key: "tenant1/user42") + def scope(scope) + fetch_context!.scope(scope) end # ── Awakeables ── diff --git a/lib/restate/context.rb b/lib/restate/context.rb index 196029d..77027d7 100644 --- a/lib/restate/context.rb +++ b/lib/restate/context.rb @@ -63,7 +63,14 @@ def set! # @return [String] raw input bytes # @!attribute [r] attempt_finished_event # @return [AttemptFinishedEvent] signaled when this attempt ends - Request = Struct.new(:id, :headers, :body, :attempt_finished_event, keyword_init: true) + # @!attribute [r] scope + # @return [String, nil] the scope this invocation was routed within, if any + # @!attribute [r] limit_key + # @return [String, nil] the concurrency limit key this invocation was made with, if any + # @!attribute [r] idempotency_key + # @return [String, nil] the idempotency key this invocation was made with, if any + Request = Struct.new(:id, :headers, :body, :attempt_finished_event, + :scope, :limit_key, :idempotency_key, keyword_init: true) # Base context interface for all Restate handlers. # @@ -120,10 +127,37 @@ def workflow_send(service, handler, key, arg, delay: nil, idempotency_key: nil, end # Durably call a handler using raw bytes (no serialization). - def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil); end + def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) + end # Fire-and-forget send using raw bytes (no serialization). - def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil); end + def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) + end + + # Returns a +ScopedContext+ that routes all outgoing calls within the given scope. + # + # 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=true+ and + # +RESTATE_EXPERIMENTAL_ENABLE_VQUEUES=true+. These can be enabled only on new + # clusters. If they aren't enabled, the call fails with a retryable error and + # keeps retrying until they are. + # + # A scope is a sub-grouping of resources (invocations, workflow instances, + # concurrency limits) within the Restate cluster. It becomes part of the target + # identity tuple and contributes to the partition key, so all resources in a + # scope get co-located by the restate-server. Omitting the scope (i.e. using the + # regular +service_call+ / +workflow_call+ methods) is equivalent to calling + # with no scope, which is the existing behavior. + # + # The scope must consist only of +[a-zA-Z0-9_.-]+ characters, 1 <= length <= 36. + # + # @param scope [String] the scope identifier + # @return [ScopedContext] + # @see https://docs.restate.dev/services/flow-control + def scope(scope); end # Create an awakeable for external callbacks. # Returns [awakeable_id, DurableFuture]. diff --git a/lib/restate/server/context.rb b/lib/restate/server/context.rb index 724d29a..cbe0170 100644 --- a/lib/restate/server/context.rb +++ b/lib/restate/server/context.rb @@ -243,8 +243,11 @@ def run_sync(name, serde: JsonSerde, retry_policy: nil, background: false, &acti # ── Service calls ── # Durably calls a handler on a Restate service and returns a future for its result. + # + # +scope+ and +limit_key+ are internal parameters set by +ScopedContext+ + # (via +ctx.scope(...)+); prefer that API over passing them directly. def service_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil, - input_serde: NOT_SET, output_serde: NOT_SET) + input_serde: NOT_SET, output_serde: NOT_SET, scope: nil, limit_key: nil) svc_name, handler_name, handler_meta = resolve_call_target(service, handler) in_serde = resolve_serde(input_serde, handler_meta, :input_serde) out_serde = resolve_serde(output_serde, handler_meta, :output_serde) @@ -252,7 +255,7 @@ def service_call(service, handler, arg, key: nil, idempotency_key: nil, headers: with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs| call_handle = @vm.sys_call( service: svc_name, handler: handler_name, parameter: parameter, - key: key, idempotency_key: idempotency_key, headers: hdrs + key: key, idempotency_key: idempotency_key, headers: hdrs, scope: scope, limit_key: limit_key ) DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle, output_serde: out_serde) @@ -260,8 +263,11 @@ def service_call(service, handler, arg, key: nil, idempotency_key: nil, headers: end # Sends a one-way invocation to a Restate service handler (fire-and-forget). + # + # +scope+ and +limit_key+ are internal parameters set by +ScopedContext+ + # (via +ctx.scope(...)+); prefer that API over passing them directly. def service_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil, - input_serde: NOT_SET) + input_serde: NOT_SET, scope: nil, limit_key: nil) svc_name, handler_name, handler_meta = resolve_call_target(service, handler) in_serde = resolve_serde(input_serde, handler_meta, :input_serde) parameter = in_serde.serialize(arg) @@ -269,15 +275,19 @@ def service_send(service, handler, arg, key: nil, delay: nil, idempotency_key: n with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs| invocation_id_handle = @vm.sys_send( service: svc_name, handler: handler_name, parameter: parameter, - key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs + key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs, + scope: scope, limit_key: limit_key ) SendHandle.new(self, invocation_id_handle) end end # Durably calls a handler on a Restate virtual object, keyed by +key+. + # + # +scope+ and +limit_key+ are internal parameters set by +ScopedContext+ + # (via +ctx.scope(...)+); prefer that API over passing them directly. def object_call(service, handler, key, arg, idempotency_key: nil, headers: nil, - input_serde: NOT_SET, output_serde: NOT_SET) + input_serde: NOT_SET, output_serde: NOT_SET, scope: nil, limit_key: nil) svc_name, handler_name, handler_meta = resolve_call_target(service, handler) in_serde = resolve_serde(input_serde, handler_meta, :input_serde) out_serde = resolve_serde(output_serde, handler_meta, :output_serde) @@ -285,7 +295,7 @@ def object_call(service, handler, key, arg, idempotency_key: nil, headers: nil, with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs| call_handle = @vm.sys_call( service: svc_name, handler: handler_name, parameter: parameter, - key: key, idempotency_key: idempotency_key, headers: hdrs + key: key, idempotency_key: idempotency_key, headers: hdrs, scope: scope, limit_key: limit_key ) DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle, output_serde: out_serde) @@ -293,8 +303,11 @@ def object_call(service, handler, key, arg, idempotency_key: nil, headers: nil, end # Sends a one-way invocation to a Restate virtual object handler (fire-and-forget). + # + # +scope+ and +limit_key+ are internal parameters set by +ScopedContext+ + # (via +ctx.scope(...)+); prefer that API over passing them directly. def object_send(service, handler, key, arg, delay: nil, idempotency_key: nil, headers: nil, - input_serde: NOT_SET) + input_serde: NOT_SET, scope: nil, limit_key: nil) svc_name, handler_name, handler_meta = resolve_call_target(service, handler) in_serde = resolve_serde(input_serde, handler_meta, :input_serde) parameter = in_serde.serialize(arg) @@ -302,24 +315,38 @@ def object_send(service, handler, key, arg, delay: nil, idempotency_key: nil, he with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs| invocation_id_handle = @vm.sys_send( service: svc_name, handler: handler_name, parameter: parameter, - key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs + key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs, + scope: scope, limit_key: limit_key ) SendHandle.new(self, invocation_id_handle) end end # Durably calls a handler on a Restate workflow, keyed by +key+. + # + # +scope+ and +limit_key+ are internal parameters set by +ScopedContext+ + # (via +ctx.scope(...)+); prefer that API over passing them directly. def workflow_call(service, handler, key, arg, idempotency_key: nil, headers: nil, - input_serde: NOT_SET, output_serde: NOT_SET) - object_call(service, handler, key, arg, idempotency_key: idempotency_key, headers: headers, - input_serde: input_serde, output_serde: output_serde) # rubocop:disable Layout/HashAlignment + input_serde: NOT_SET, output_serde: NOT_SET, scope: nil, limit_key: nil) + object_call( + service, handler, key, arg, + idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, output_serde: output_serde, + scope: scope, limit_key: limit_key + ) end # Sends a one-way invocation to a Restate workflow handler (fire-and-forget). + # + # +scope+ and +limit_key+ are internal parameters set by +ScopedContext+ + # (via +ctx.scope(...)+); prefer that API over passing them directly. def workflow_send(service, handler, key, arg, delay: nil, idempotency_key: nil, headers: nil, - input_serde: NOT_SET) - object_send(service, handler, key, arg, delay: delay, idempotency_key: idempotency_key, headers: headers, - input_serde: input_serde) # rubocop:disable Layout/HashAlignment + input_serde: NOT_SET, scope: nil, limit_key: nil) + object_send( + service, handler, key, arg, + delay: delay, idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, scope: scope, limit_key: limit_key + ) end # ── Awakeables ── @@ -403,11 +430,15 @@ def cancel_invocation(invocation_id) # ── Generic calls (raw bytes, no serde) ── # Durably calls a handler using raw bytes (no serialization). Useful for proxying. - def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil) + # + # Optionally accepts a +scope+ to route the call within (see +#scope+) and a + # +limit_key+ to enforce hierarchical concurrency limits within that scope. + def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) with_outbound_middleware(service, handler, headers) do |hdrs| call_handle = @vm.sys_call( service: service, handler: handler, parameter: arg, - key: key, idempotency_key: idempotency_key, headers: hdrs + key: key, idempotency_key: idempotency_key, headers: hdrs, scope: scope, limit_key: limit_key ) DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle, output_serde: nil) @@ -415,26 +446,42 @@ def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: end # Sends a one-way invocation using raw bytes (no serialization). Useful for proxying. - def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil) + # + # Optionally accepts a +scope+ to route the send within (see +#scope+) and a + # +limit_key+ to enforce hierarchical concurrency limits within that scope. + def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) delay_ms = delay ? (delay * 1000).to_i : nil with_outbound_middleware(service, handler, headers) do |hdrs| invocation_id_handle = @vm.sys_send( service: service, handler: handler, parameter: arg, - key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs + key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs, + scope: scope, limit_key: limit_key ) SendHandle.new(self, invocation_id_handle) end end + # Returns a +ScopedContext+ that routes all outgoing calls within the given scope. + # See {Restate::Context#scope} for the full semantics and constraints. + def scope(scope) + ScopedContext.new(self, scope) + end + # ── Request metadata ── - # Returns metadata about the current invocation (id, headers, raw body). + # Returns metadata about the current invocation (id, headers, raw body, + # and — when present — the scope, limit_key, and idempotency_key it was + # invoked with). def request @request ||= Request.new( id: @invocation.invocation_id, headers: @invocation.headers.to_h, body: @invocation.input_buffer, - attempt_finished_event: @attempt_finished_event + attempt_finished_event: @attempt_finished_event, + scope: @invocation.scope, + limit_key: @invocation.limit_key, + idempotency_key: @invocation.idempotency_key ) end @@ -737,5 +784,92 @@ def ensure_started end end end + + # A context for making RPC calls within a specific scope. + # + # NOTE: This API is in preview and is not enabled by default. See + # {Restate::Context#scope} for how to enable it and for the full semantics. + # + # Returned by +ctx.scope(scope)+: calls and sends made through this context + # carry the captured scope, and each method additionally accepts an optional + # +limit_key+. + # + # The limit key enforces hierarchical concurrency limits on invocations sharing + # the same scope. It can have one or two levels separated by +/+ (e.g. +"tenant1"+ + # or +"tenant1/user42"+). Each level must consist only of +[a-zA-Z0-9_.-]+ + # characters, 1 <= length <= 36. + # + # The limit key is *not* part of the request identity: two calls to the same + # target with the same scope and object key but different limit keys refer to the + # *same* resource instance. The limit key only affects concurrency limits. + class ScopedContext + def initialize(ctx, scope) + @ctx = ctx + @scope = scope + end + + # Durably calls a handler on a Restate service, within this scope. + def service_call(service, handler, arg, key: nil, limit_key: nil, idempotency_key: nil, + headers: nil, input_serde: NOT_SET, output_serde: NOT_SET) + @ctx.service_call( + service, handler, arg, + key: key, idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, output_serde: output_serde, + scope: @scope, limit_key: limit_key + ) + end + + # Fire-and-forget send to a Restate service handler, within this scope. + def service_send(service, handler, arg, key: nil, delay: nil, limit_key: nil, + idempotency_key: nil, headers: nil, input_serde: NOT_SET) + @ctx.service_send( + service, handler, arg, + key: key, delay: delay, idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, scope: @scope, limit_key: limit_key + ) + end + + # Durably calls a handler on a Restate virtual object, within this scope. + def object_call(service, handler, key, arg, limit_key: nil, idempotency_key: nil, + headers: nil, input_serde: NOT_SET, output_serde: NOT_SET) + @ctx.object_call( + service, handler, key, arg, + idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, output_serde: output_serde, + scope: @scope, limit_key: limit_key + ) + end + + # Fire-and-forget send to a Restate virtual object handler, within this scope. + def object_send(service, handler, key, arg, delay: nil, limit_key: nil, + idempotency_key: nil, headers: nil, input_serde: NOT_SET) + @ctx.object_send( + service, handler, key, arg, + delay: delay, idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, scope: @scope, limit_key: limit_key + ) + end + + # Durably calls a handler on a Restate workflow, within this scope. + def workflow_call(service, handler, key, arg, limit_key: nil, idempotency_key: nil, + headers: nil, input_serde: NOT_SET, output_serde: NOT_SET) + @ctx.workflow_call( + service, handler, key, arg, + idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, output_serde: output_serde, + scope: @scope, limit_key: limit_key + ) + end + + # Fire-and-forget send to a Restate workflow handler, within this scope. + def workflow_send(service, handler, key, arg, delay: nil, limit_key: nil, + idempotency_key: nil, headers: nil, input_serde: NOT_SET) + @ctx.workflow_send( + service, handler, key, arg, + delay: delay, idempotency_key: idempotency_key, headers: headers, + input_serde: input_serde, scope: @scope, limit_key: limit_key + ) + end + end end end diff --git a/lib/restate/service.rb b/lib/restate/service.rb index 0f899a0..0ac3e0c 100644 --- a/lib/restate/service.rb +++ b/lib/restate/service.rb @@ -33,10 +33,13 @@ def self.handler(method_name = nil, **opts) # # @example # Greeter.call.greet("World").await + # Greeter.call(scope: "tenant1", limit_key: "tenant1/user42").greet("World").await # + # @param scope [String, nil] optional scope to route the call within (see {Restate.scope}) + # @param limit_key [String, nil] optional concurrency limit key within the scope # @return [ServiceCallProxy] - def self.call - ServiceCallProxy.new(self, call_method: :service_call) + def self.call(scope: nil, limit_key: nil) + ServiceCallProxy.new(self, call_method: :service_call, scope: scope, limit_key: limit_key) end # Returns a send proxy for fluent fire-and-forget sends to this service. @@ -44,11 +47,14 @@ def self.call # @example # Greeter.send!.greet("World") # Greeter.send!(delay: 60).greet("World") + # Greeter.send!(scope: "tenant1", limit_key: "tenant1/user42").greet("World") # # @param delay [Numeric, nil] optional delay in seconds + # @param scope [String, nil] optional scope to route the send within (see {Restate.scope}) + # @param limit_key [String, nil] optional concurrency limit key within the scope # @return [ServiceSendProxy] - def self.send!(delay: nil) - ServiceSendProxy.new(self, send_method: :service_send, delay: delay) + def self.send!(delay: nil, scope: nil, limit_key: nil) + ServiceSendProxy.new(self, send_method: :service_send, delay: delay, scope: scope, limit_key: limit_key) end def self._service_kind diff --git a/lib/restate/service_proxy.rb b/lib/restate/service_proxy.rb index 39827b5..d42ad21 100644 --- a/lib/restate/service_proxy.rb +++ b/lib/restate/service_proxy.rb @@ -14,16 +14,23 @@ module Restate # # Instead of: ctx.object_call(Counter, :add, "key", 5) # Counter.call("key").add(5) # + # # Scoped call (preview): route within a scope with an optional limit key + # Greeter.call(scope: "tenant1", limit_key: "tenant1/user42").greet("World") + # # @!visibility private class ServiceCallProxy - def initialize(service_class, key: nil, call_method: :service_call) + def initialize(service_class, key: nil, call_method: :service_call, scope: nil, limit_key: nil) @service_class = service_class @key = key @call_method = call_method + @scope = scope + @limit_key = limit_key end def method_missing(handler_name, arg = nil, **opts) ctx = Restate.fetch_context! + opts[:scope] = @scope unless @scope.nil? || opts.key?(:scope) + opts[:limit_key] = @limit_key unless @limit_key.nil? || opts.key?(:limit_key) if @key ctx.public_send(@call_method, @service_class, handler_name, @key, arg, **opts) else @@ -47,18 +54,25 @@ def respond_to_missing?(method_name, include_private = false) # # Instead of: ctx.object_send(Counter, :add, "key", 5, delay: 60) # Counter.send!("key", delay: 60).add(5) # + # # Scoped send (preview): route within a scope with an optional limit key + # Greeter.send!(scope: "tenant1", limit_key: "tenant1/user42").greet("World") + # # @!visibility private class ServiceSendProxy - def initialize(service_class, key: nil, send_method: :service_send, delay: nil) + def initialize(service_class, key: nil, send_method: :service_send, delay: nil, scope: nil, limit_key: nil) @service_class = service_class @key = key @send_method = send_method @delay = delay + @scope = scope + @limit_key = limit_key end def method_missing(handler_name, arg = nil, **opts) ctx = Restate.fetch_context! opts[:delay] = @delay if @delay + opts[:scope] = @scope unless @scope.nil? || opts.key?(:scope) + opts[:limit_key] = @limit_key unless @limit_key.nil? || opts.key?(:limit_key) if @key ctx.public_send(@send_method, @service_class, handler_name, @key, arg, **opts) else diff --git a/lib/restate/virtual_object.rb b/lib/restate/virtual_object.rb index bad1d12..96130f7 100644 --- a/lib/restate/virtual_object.rb +++ b/lib/restate/virtual_object.rb @@ -52,11 +52,14 @@ def self.shared(method_name, **opts) # # @example # Counter.call("my-key").add(5).await + # Counter.call("my-key", scope: "tenant1", limit_key: "tenant1/user42").add(5).await # # @param key [String] the object key + # @param scope [String, nil] optional scope to route the call within (see {Restate.scope}) + # @param limit_key [String, nil] optional concurrency limit key within the scope # @return [ServiceCallProxy] - def self.call(key) - ServiceCallProxy.new(self, key: key, call_method: :object_call) + def self.call(key, scope: nil, limit_key: nil) + ServiceCallProxy.new(self, key: key, call_method: :object_call, scope: scope, limit_key: limit_key) end # Returns a send proxy for fluent fire-and-forget sends to this virtual object. @@ -64,12 +67,15 @@ def self.call(key) # @example # Counter.send!("my-key").add(5) # Counter.send!("my-key", delay: 60).add(5) + # Counter.send!("my-key", scope: "tenant1", limit_key: "tenant1/user42").add(5) # # @param key [String] the object key # @param delay [Numeric, nil] optional delay in seconds + # @param scope [String, nil] optional scope to route the send within (see {Restate.scope}) + # @param limit_key [String, nil] optional concurrency limit key within the scope # @return [ServiceSendProxy] - def self.send!(key, delay: nil) - ServiceSendProxy.new(self, key: key, send_method: :object_send, delay: delay) + def self.send!(key, delay: nil, scope: nil, limit_key: nil) + ServiceSendProxy.new(self, key: key, send_method: :object_send, delay: delay, scope: scope, limit_key: limit_key) end def self._service_kind diff --git a/lib/restate/vm.rb b/lib/restate/vm.rb index 9260676..f7c7b94 100644 --- a/lib/restate/vm.rb +++ b/lib/restate/vm.rb @@ -18,7 +18,8 @@ module Restate # Ruby-side data types for VM results - Invocation = Struct.new(:invocation_id, :random_seed, :headers, :input_buffer, :key, keyword_init: true) + Invocation = Struct.new(:invocation_id, :random_seed, :headers, :input_buffer, :key, + :scope, :limit_key, :idempotency_key, keyword_init: true) Failure = Struct.new(:code, :message, :stacktrace, :metadata, keyword_init: true) class NotReady; end @@ -116,7 +117,10 @@ def sys_input random_seed: inp.random_seed, headers: headers, input_buffer: inp.input.b, - key: inp.key + key: inp.key, + scope: inp.scope, + limit_key: inp.limit_key, + idempotency_key: inp.idempotency_key ) end @@ -145,16 +149,19 @@ def sys_sleep(millis, name = nil) @vm.sys_sleep(millis, name) end - def sys_call(service:, handler:, parameter:, key: nil, idempotency_key: nil, headers: nil) - # Rust side expects 6 args: (service, handler, buffer, key_or_nil, idem_key_or_nil, headers_or_nil) + def sys_call(service:, handler:, parameter:, key: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) + # Rust side expects 8 args: + # (service, handler, buffer, key_or_nil, idem_key_or_nil, headers_or_nil, scope_or_nil, limit_key_or_nil) hdr_array = headers&.map { |k, v| [k, v] } - @vm.sys_call(service, handler, parameter, key, idempotency_key, hdr_array) + @vm.sys_call(service, handler, parameter, key, idempotency_key, hdr_array, scope, limit_key) end - def sys_send(service:, handler:, parameter:, key: nil, delay: nil, idempotency_key: nil, headers: nil) - # Rust side expects 7 args + def sys_send(service:, handler:, parameter:, key: nil, delay: nil, idempotency_key: nil, headers: nil, + scope: nil, limit_key: nil) + # Rust side expects 9 args hdr_array = headers&.map { |k, v| [k, v] } - @vm.sys_send(service, handler, parameter, key, delay, idempotency_key, hdr_array) + @vm.sys_send(service, handler, parameter, key, delay, idempotency_key, hdr_array, scope, limit_key) end def sys_run(name) diff --git a/lib/restate/workflow.rb b/lib/restate/workflow.rb index 449c2a8..771715a 100644 --- a/lib/restate/workflow.rb +++ b/lib/restate/workflow.rb @@ -52,11 +52,14 @@ def self.handler(method_name = nil, **opts) # # @example # UserSignup.call("user42").run("user@example.com").await + # UserSignup.call("user42", scope: "tenant1", limit_key: "tenant1/user42").run(email).await # # @param key [String] the workflow key + # @param scope [String, nil] optional scope to route the call within (see {Restate.scope}) + # @param limit_key [String, nil] optional concurrency limit key within the scope # @return [ServiceCallProxy] - def self.call(key) - ServiceCallProxy.new(self, key: key, call_method: :workflow_call) + def self.call(key, scope: nil, limit_key: nil) + ServiceCallProxy.new(self, key: key, call_method: :workflow_call, scope: scope, limit_key: limit_key) end # Returns a send proxy for fluent fire-and-forget sends to this workflow. @@ -64,12 +67,19 @@ def self.call(key) # @example # UserSignup.send!("user42").run("user@example.com") # UserSignup.send!("user42", delay: 60).run("user@example.com") + # UserSignup.send!("user42", scope: "tenant1", limit_key: "tenant1/user42").run(email) # # @param key [String] the workflow key # @param delay [Numeric, nil] optional delay in seconds + # @param scope [String, nil] optional scope to route the send within (see {Restate.scope}) + # @param limit_key [String, nil] optional concurrency limit key within the scope # @return [ServiceSendProxy] - def self.send!(key, delay: nil) - ServiceSendProxy.new(self, key: key, send_method: :workflow_send, delay: delay) + def self.send!(key, delay: nil, scope: nil, limit_key: nil) + ServiceSendProxy.new( + self, + key: key, send_method: :workflow_send, + delay: delay, scope: scope, limit_key: limit_key + ) end def self._service_kind diff --git a/sig/restate.rbs b/sig/restate.rbs index 5f40b20..fbad589 100644 --- a/sig/restate.rbs +++ b/sig/restate.rbs @@ -35,8 +35,9 @@ module Restate def self.object_send: (untyped service, (String | Symbol) handler, String key, untyped arg, ?delay: Numeric?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped) -> SendHandle def self.workflow_call: (untyped service, (String | Symbol) handler, String key, untyped arg, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped, ?output_serde: untyped) -> DurableCallFuture def self.workflow_send: (untyped service, (String | Symbol) handler, String key, untyped arg, ?delay: Numeric?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped) -> SendHandle - def self.generic_call: (String service, String handler, String arg, ?key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?) -> DurableCallFuture - def self.generic_send: (String service, String handler, String arg, ?key: String?, ?delay: Numeric?, ?idempotency_key: String?, ?headers: Hash[String, String]?) -> SendHandle + def self.generic_call: (String service, String handler, String arg, ?key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?scope: String?, ?limit_key: String?) -> DurableCallFuture + def self.generic_send: (String service, String handler, String arg, ?key: String?, ?delay: Numeric?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?scope: String?, ?limit_key: String?) -> SendHandle + def self.scope: (String scope) -> Server::ScopedContext # ── Awakeables ── @@ -197,11 +198,11 @@ module Restate # ── Service proxies ── class ServiceCallProxy - def initialize: (untyped service_class, ?key: String?, ?call_method: Symbol) -> void + def initialize: (untyped service_class, ?key: String?, ?call_method: Symbol, ?scope: String?, ?limit_key: String?) -> void end class ServiceSendProxy - def initialize: (untyped service_class, ?key: String?, ?send_method: Symbol, ?delay: Numeric?) -> void + def initialize: (untyped service_class, ?key: String?, ?send_method: Symbol, ?delay: Numeric?, ?scope: String?, ?limit_key: String?) -> void end # ── Service classes (declared but not checked — heavy metaprogramming) ── @@ -225,6 +226,16 @@ module Restate class Context end + + class ScopedContext + def initialize: (untyped ctx, String scope) -> void + def service_call: (untyped service, (String | Symbol) handler, untyped arg, ?key: String?, ?limit_key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped, ?output_serde: untyped) -> DurableCallFuture + def service_send: (untyped service, (String | Symbol) handler, untyped arg, ?key: String?, ?delay: Numeric?, ?limit_key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped) -> SendHandle + def object_call: (untyped service, (String | Symbol) handler, String key, untyped arg, ?limit_key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped, ?output_serde: untyped) -> DurableCallFuture + def object_send: (untyped service, (String | Symbol) handler, String key, untyped arg, ?delay: Numeric?, ?limit_key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped) -> SendHandle + def workflow_call: (untyped service, (String | Symbol) handler, String key, untyped arg, ?limit_key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped, ?output_serde: untyped) -> DurableCallFuture + def workflow_send: (untyped service, (String | Symbol) handler, String key, untyped arg, ?delay: Numeric?, ?limit_key: String?, ?idempotency_key: String?, ?headers: Hash[String, String]?, ?input_serde: untyped) -> SendHandle + end end module JsonSerde diff --git a/test-services/exclusions.yaml b/test-services/exclusions.yaml index 4f2136b..7831c23 100644 --- a/test-services/exclusions.yaml +++ b/test-services/exclusions.yaml @@ -1,8 +1 @@ -exclusions: - "alwaysSuspending": [] - "default": [] - "lazyState": [] - "persistedTimers": [] - "singleThreadSinglePartition": [] - "threeNodes": [] - "threeNodesAlwaysSuspending": [] +exclusions: {} diff --git a/test-services/services/proxy.rb b/test-services/services/proxy.rb index e553891..2ec2eb4 100644 --- a/test-services/services/proxy.rb +++ b/test-services/services/proxy.rb @@ -6,7 +6,8 @@ class Proxy < Restate::Service handler def call(req) result = Restate.generic_call( req['serviceName'], req['handlerName'], req['message'].pack('C*'), - key: req['virtualObjectKey'], idempotency_key: req['idempotencyKey'] + key: req['virtualObjectKey'], idempotency_key: req['idempotencyKey'], + scope: req['scope'], limit_key: req['limitKey'] ).await result.bytes end @@ -15,7 +16,8 @@ class Proxy < Restate::Service delay_seconds = req['delayMillis'] ? req['delayMillis'] / 1000.0 : nil handle = Restate.generic_send( req['serviceName'], req['handlerName'], req['message'].pack('C*'), - key: req['virtualObjectKey'], delay: delay_seconds, idempotency_key: req['idempotencyKey'] + key: req['virtualObjectKey'], delay: delay_seconds, idempotency_key: req['idempotencyKey'], + scope: req['scope'], limit_key: req['limitKey'] ) handle.invocation_id end @@ -26,12 +28,14 @@ class Proxy < Restate::Service pr = req['proxyRequest'] if req['oneWay'] Restate.generic_send(pr['serviceName'], pr['handlerName'], pr['message'].pack('C*'), - key: pr['virtualObjectKey'], idempotency_key: pr['idempotencyKey']) + key: pr['virtualObjectKey'], idempotency_key: pr['idempotencyKey'], + scope: pr['scope'], limit_key: pr['limitKey']) else future = Restate.generic_call(pr['serviceName'], pr['handlerName'], pr['message'].pack('C*'), key: pr['virtualObjectKey'], - idempotency_key: pr['idempotencyKey']) + idempotency_key: pr['idempotencyKey'], + scope: pr['scope'], limit_key: pr['limitKey']) to_await << future if req['awaitAtTheEnd'] end end diff --git a/test-services/services/test_utils.rb b/test-services/services/test_utils.rb index d2ad713..1910d4f 100644 --- a/test-services/services/test_utils.rb +++ b/test-services/services/test_utils.rb @@ -36,12 +36,6 @@ def rawEcho(input) # rubocop:disable Naming/MethodName nil end - handler def sleepConcurrently(millis_list) # rubocop:disable Naming/MethodName - futures = millis_list.map { |ms| Restate.sleep(ms / 1000.0) } - futures.each(&:await) - nil - end - handler def resolveSignal(req) # rubocop:disable Naming/MethodName Restate.resolve_signal(req['invocationId'], req['signalName'], req['value']) nil