From b220cd8333908d99941a53e70960c0f7d71eec34 Mon Sep 17 00:00:00 2001 From: junyuanz1 Date: Thu, 21 May 2026 10:30:21 -0400 Subject: [PATCH 1/3] Add DurableFuture#or_timeout + Restate::TimeoutError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Ruby SDK to feature parity with the TypeScript and Java SDKs for the "race a future against a deadline" use case. Today the Ruby SDK has no direct equivalent of: * TypeScript: +RestatePromise.orTimeout(duration)+ → https://github.com/restatedev/sdk-typescript/blob/main/packages/libs/restate-sdk/src/promises.ts * Java: +Awaitable.orTimeout(Duration)+ → https://github.com/restatedev/sdk-java/blob/main/sdk-common/src/main/java/dev/restate/sdk/common/TimeoutException.java Ruby users currently have to hand-roll +Restate.sleep+ + +Restate.wait_any+ + +completed?+ branching at every call site, which is verbose and easy to get wrong (especially around when to .cancel the call invocation on timeout). Changes: * +Restate::TimeoutError+ — subclass of +Restate::TerminalError+, default message "Timeout occurred", HTTP status 408. Inheriting from TerminalError lets the existing +rescue Restate::TerminalError+ idiom in user handlers catch timeouts uniformly with other terminal failures, matching the TS type hierarchy (+TimeoutError extends TerminalError+ in +types/errors.ts+). * +DurableFuture#or_timeout(duration)+ — race against +Restate.sleep+. Returns the future's value on win; raises +TimeoutError+ if the sleep wins. * +DurableCallFuture#or_timeout(duration)+ — refines the base to call +#cancel+ on the remote invocation when the timeout wins, so the callee doesn't keep running after the caller gave up. Same refinement TS makes — see the +InvocationPromise+ specialization in TS that calls +ctx.cancel(invocationId)+ on timeout. * RSpec coverage at +spec/or_timeout_spec.rb+ — 8 examples covering happy/timeout paths on both future types plus error-class invariants. Stubs +Restate.sleep+/+Restate.wait_any+ so the spec runs without a live VM, matching the existing +server_context_outbound_middleware_spec.rb+ style. * +docs/USER_GUIDE.md+ "Timeouts" subsection with the usage pattern and a documented caveat about the orphan-sleep footprint (see "Design notes" below). == Why HTTP status 408 (not 409) 408 (Request Timeout) is the correct HTTP semantic for a timeout and matches the TypeScript SDK (+packages/libs/restate-sdk/src/types/errors.ts+): export const TIMEOUT_ERROR_CODE = 408; export const CANCEL_ERROR_CODE = 409; The Java SDK's +TimeoutException+ uses 409, but 409 is what TS reserves for +CancelledError+ — Java appears to be the outlier and the choice there looks like a copy-paste from CancelledException. Picking 408 here keeps the Ruby SDK aligned with both standard HTTP semantics and the larger TS ecosystem. == Design note: the orphan-sleep footprint Both this implementation and the existing TS +orTimeout+ have the same property: when the work future wins the race, the sleep handle remains in the journal because +restate-sdk-shared-core+ 0.7.0 exposes no +sys_cancel_handle+ primitive. The wake-up is a no-op against a completed handler but keeps the invocation row alive in Restate's state until the timer fires — meaningful on long deadlines. The TS implementation has this footprint too (see +packages/libs/restate-sdk/src/promises.ts+'s +orTimeout+, which uses raw +ctx.sleep+ inside the combinator). This PR matches that behavior 1:1 and documents the caveat + the workaround (cancellable-deadline pattern via a separate scheduled invocation + +SendHandle#cancel+) in the user guide. A follow-up could either: * Add a +#with_cancellable_deadline+ helper that routes the timer through a small bundled +DeadlineTrigger+ service, or * Raise the gap against +restate-sdk-shared-core+ for a real +sys_cancel_handle+ primitive — which would let every SDK fix the leak at the source. Out of scope for this PR. Test results: +bundle exec rspec+ — 82 examples, 0 failures. --- docs/USER_GUIDE.md | 27 +++++++ lib/restate/durable_future.rb | 63 ++++++++++++++++ lib/restate/errors.rb | 12 ++++ spec/or_timeout_spec.rb | 130 ++++++++++++++++++++++++++++++++++ 4 files changed, 232 insertions(+) create mode 100644 spec/or_timeout_spec.rb diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9601903..b65797e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -270,6 +270,33 @@ Restate.sleep(5.0).await # Sleep for 5 seconds (durable timer) The timer survives crashes — if the handler restarts, it resumes waiting for the remaining time. +### Timeouts + +Race any `DurableFuture` against a deadline via `#or_timeout(seconds)`. +Returns the future's value if it wins; raises `Restate::TimeoutError` +(a `TerminalError` subclass, HTTP 408) if the sleep wins. + +```ruby +# Bound a service call to 5 seconds. On timeout, the remote +# invocation is cancelled automatically before the error is raised. +result = Worker.call.process(task).or_timeout(5) + +# Works on any DurableFuture — sleeps, run-blocks, etc. +Restate.run('expensive') { compute }.or_timeout(10) +``` + +**Caveat — orphan sleep**: the underlying shared-core VM has no +primitive to cancel an in-flight sleep handle (only +`sys_cancel_invocation` for a separate invocation), so when the +future wins the race the sleep stays in the journal until the +duration elapses. The wake-up is a no-op against a completed +handler but keeps the invocation row alive in Restate's state for +the deadline window. For long deadlines on workflows whose +retention you care about, route the timer through a separate +cancellable invocation (delayed `Restate.service_send` to a small +trigger service that resolves an awakeable) and cancel the +`SendHandle` on success. + ### Service Communication #### Fluent Call API (Recommended) diff --git a/lib/restate/durable_future.rb b/lib/restate/durable_future.rb index 422235f..de5e5b7 100644 --- a/lib/restate/durable_future.rb +++ b/lib/restate/durable_future.rb @@ -33,6 +33,43 @@ def await def completed? @resolved || @ctx.completed?(@handle) end + + # Race +self+ against a durable sleep of +duration+ seconds. Returns + # the future's value if it completes first; raises + # {Restate::TimeoutError} if the sleep wins. + # + # Mirrors +RestatePromise.orTimeout+ in the TypeScript SDK and + # +Awaitable.orTimeout+ in the Java SDK. + # + # == Caveat: the sleep is not cancelled when this future wins + # + # The sleep timer is journaled and the underlying shared-core VM + # exposes no primitive to cancel an in-flight sleep handle (only + # +sys_cancel_invocation+ on a separate invocation). When the + # future wins the race the sleep entry remains in this invocation's + # journal and Restate's scheduler keeps a wake-up registered until + # the duration elapses. The wake-up is a no-op against a completed + # handler, but it keeps the invocation row alive in Restate's + # state until the timer fires — meaningful on long durations. + # + # For long-running deadlines whose retention you care about, + # route the timer through a separate cancellable invocation + # (delayed +ctx.service_send+ to a small trigger service that + # resolves an awakeable) and cancel the +SendHandle+ on success. + # + # @example + # ctx.service_call(MyService, :handler, payload).or_timeout(5) + # + # @param duration [Numeric] timeout in seconds + # @return [Object] the future's value when it wins the race + # @raise [Restate::TimeoutError] when the sleep wins + def or_timeout(duration) + sleep_future = Restate.sleep(duration) + Restate.wait_any(self, sleep_future) + return await if completed? + + raise TimeoutError + end end # A durable future for service/object/workflow calls. @@ -76,6 +113,32 @@ def invocation_id def cancel @ctx.cancel_invocation(invocation_id) end + + # Race +self+ against a durable sleep of +duration+ seconds. On + # success returns the call's value. On timeout the underlying + # remote invocation is cancelled (via +sys_cancel_invocation+) so + # the callee doesn't continue running after the caller has + # given up. + # + # Refines {DurableFuture#or_timeout} by cleaning up the *call* + # side of the race when the timer wins. The sleep side itself + # cannot be cancelled today — see the parent method's docstring. + # + # @example + # result = ctx.service_call(MyService, :handler, payload).or_timeout(5) + # + # @param duration [Numeric] timeout in seconds + # @return [Object] the call result when this future wins + # @raise [Restate::TimeoutError] when the sleep wins; the remote + # invocation has been cancelled before the error is raised + def or_timeout(duration) + sleep_future = Restate.sleep(duration) + Restate.wait_any(self, sleep_future) + return await if completed? + + cancel + raise TimeoutError + end end # A handle for fire-and-forget send operations. diff --git a/lib/restate/errors.rb b/lib/restate/errors.rb index 6853124..60ded74 100644 --- a/lib/restate/errors.rb +++ b/lib/restate/errors.rb @@ -17,6 +17,18 @@ def initialize(message = 'Internal Server Error', status_code: 500, metadata: ni end end + # Raised by {DurableFuture#or_timeout} when the timeout elapses + # before the future completes. Mirrors +TimeoutError+ in the + # TypeScript SDK (408 Request Timeout) and +TimeoutException+ in + # the Java SDK. Inherits from {TerminalError} so the same + # +rescue Restate::TerminalError+ block in user handlers catches + # timeouts alongside other terminal failures. + class TimeoutError < TerminalError + def initialize(message = 'Timeout occurred', metadata: nil) + super(message, status_code: 408, metadata: metadata) + end + end + # Internal: raised when the VM suspends execution. # User code should NOT catch this. class SuspendedError < StandardError diff --git a/spec/or_timeout_spec.rb b/spec/or_timeout_spec.rb new file mode 100644 index 0000000..1033810 --- /dev/null +++ b/spec/or_timeout_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'restate' + +# Unit coverage for +DurableFuture#or_timeout+ and +# +DurableCallFuture#or_timeout+. Stubs the +Restate+ module-level +# helpers (+sleep+, +wait_any+) so the spec doesn't need a live VM — +# this is intentionally an SDK-shape check, not an end-to-end +# integration. The +test-services/+ suite covers the live-VM path. +RSpec.describe Restate::DurableFuture do + # Minimal fake context that just records calls. Real Server::Context + # is too heavy for this spec — we only need +resolve_handle+ and + # +completed?+ to respond. + let(:ctx) { double('ctx', resolve_handle: 'ignored', completed?: false) } + + describe '#or_timeout' do + context 'when the future completes before the sleep' do + it "returns the future's value" do + future = described_class.new(ctx, :handle_a) + sleep_future = described_class.new(ctx, :handle_sleep) + + allow(Restate).to receive(:sleep).with(5).and_return(sleep_future) + allow(Restate).to receive(:wait_any) do + # Race outcome: the work future wins. + allow(future).to receive(:completed?).and_return(true) + allow(future).to receive(:await).and_return('done') + nil + end + + expect(future.or_timeout(5)).to eq('done') + end + end + + context 'when the sleep wins the race' do + it 'raises Restate::TimeoutError' do + future = described_class.new(ctx, :handle_a) + sleep_future = described_class.new(ctx, :handle_sleep) + + allow(Restate).to receive(:sleep).with(5).and_return(sleep_future) + allow(Restate).to receive(:wait_any) do + # Race outcome: the sleep wins; the work future stays incomplete. + allow(future).to receive(:completed?).and_return(false) + nil + end + + expect { future.or_timeout(5) }.to raise_error(Restate::TimeoutError) + end + + it 'attaches HTTP status 408 (Request Timeout)' do + future = described_class.new(ctx, :handle_a) + allow(Restate).to receive(:sleep).and_return(described_class.new(ctx, :sleep)) + allow(Restate).to receive(:wait_any) do + allow(future).to receive(:completed?).and_return(false) + nil + end + + future.or_timeout(5) + rescue Restate::TimeoutError => e + expect(e.status_code).to eq(408) + end + end + end +end + +RSpec.describe Restate::DurableCallFuture do + let(:ctx) do + double('ctx', resolve_handle: 'inv_xyz', completed?: false, cancel_invocation: nil) + end + + def build_call_future + described_class.new(ctx, :result_handle, :invocation_id_handle, output_serde: nil) + end + + describe '#or_timeout' do + context 'when the call completes before the sleep' do + it "returns the call's value and does NOT cancel the call" do + future = build_call_future + sleep_future = Restate::DurableFuture.new(ctx, :handle_sleep) + + allow(Restate).to receive(:sleep).with(5).and_return(sleep_future) + allow(Restate).to receive(:wait_any) do + allow(future).to receive(:completed?).and_return(true) + allow(future).to receive(:await).and_return({ 'ok' => true }) + nil + end + + expect(future.or_timeout(5)).to eq({ 'ok' => true }) + expect(ctx).not_to have_received(:cancel_invocation), + 'happy path must not call cancel — the remote call ' \ + 'is the winner of the race and should be allowed to ' \ + 'finish/return its value normally' + end + end + + context 'when the sleep wins the race' do + it 'cancels the remote invocation and raises Restate::TimeoutError' do + future = build_call_future + sleep_future = Restate::DurableFuture.new(ctx, :handle_sleep) + + allow(Restate).to receive(:sleep).with(5).and_return(sleep_future) + allow(Restate).to receive(:wait_any) do + allow(future).to receive(:completed?).and_return(false) + nil + end + + expect { future.or_timeout(5) }.to raise_error(Restate::TimeoutError) + expect(ctx).to have_received(:cancel_invocation).with('inv_xyz'), + 'timeout path must cancel the remote invocation so the ' \ + 'callee does not continue running after the caller has ' \ + 'given up — this is the refinement that DurableCallFuture ' \ + 'adds on top of the base DurableFuture#or_timeout' + end + end + end +end + +RSpec.describe Restate::TimeoutError do + it 'inherits from TerminalError so user rescue blocks catch it uniformly' do + expect(described_class.ancestors).to include(Restate::TerminalError) + end + + it 'defaults to HTTP status 408 (Request Timeout) matching the TS SDK' do + expect(described_class.new.status_code).to eq(408) + end + + it 'has a meaningful default message' do + expect(described_class.new.message).to eq('Timeout occurred') + end +end From f28da46437945e84037de60a1fe4d94631c35121 Mon Sep 17 00:00:00 2001 From: junyuanz1 Date: Thu, 21 May 2026 10:42:58 -0400 Subject: [PATCH 2/3] Round out PR with RBS sigs, errors-section + INTERNALS docs, example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the four remaining surfaces that callers of the new API touch: * +sig/restate.rbs+ — adds +TimeoutError < TerminalError+ and +DurableFuture#or_timeout+ / +DurableCallFuture#or_timeout+ signatures alongside the existing ones. +bundle exec steep check+ passes. * +docs/USER_GUIDE.md+ — adds a +TimeoutError+ subsection inside +## Error Handling+ so the +rescue Restate::TimeoutError+ pattern is discoverable from the canonical error docs, not just from the +Timeouts+ subsection. Also adds +or_timeout+ to the +service_communication.rb+ row in the examples-mapping table so the table stays accurate. * +docs/INTERNALS.md+ — extends the Durable Futures section so the +or_timeout+ method shows up on both +DurableFuture+ and +DurableCallFuture+ alongside the existing +cancel+ docs. Notes the orphan-handle footprint at the same source-of-truth as the rest of the future internals. * +examples/service_communication.rb+ — adds a +with_deadline+ handler that demonstrates +Worker.call.process(task).or_timeout(5)+ with a +rescue Restate::TimeoutError+ block, so the example matches the entry now listed in the user-guide table. No code or behavior changes — pure docs/sig fill-in. Test results: +bundle exec rspec+ — 82 examples, 0 failures. +bundle exec steep check+ — no type errors. --- docs/INTERNALS.md | 2 ++ docs/USER_GUIDE.md | 21 ++++++++++++++++++++- examples/service_communication.rb | 11 +++++++++++ sig/restate.rbs | 6 ++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index b7aa312..a43bc71 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -227,11 +227,13 @@ 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` 0.7 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) - `invocation_id` — lazily resolved on first access - `cancel` — calls `Restate.cancel_invocation(invocation_id)` +- `or_timeout(duration)` — overrides the base to also `cancel` the remote invocation when the sleep wins, so the callee doesn't keep running after the caller has given up. The sleep side still has the orphan-handle footprint noted on the parent. **`SendHandle`** — returned by `Restate.service_send`, `Restate.object_send`, `Restate.workflow_send`. - `invocation_id` — lazily resolved diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b65797e..b5dbb3e 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -939,6 +939,25 @@ rescue Restate::TerminalError => e end ``` +#### TimeoutError + +`Restate::TimeoutError` is a `TerminalError` subclass raised by +`DurableFuture#or_timeout` when the deadline elapses before the +future completes (HTTP status 408). Because it inherits from +`TerminalError`, the same `rescue` block catches it uniformly: + +```ruby +begin + Worker.call.process(task).or_timeout(5) +rescue Restate::TimeoutError => e + # specific timeout handling +rescue Restate::TerminalError => e + # any other terminal failure +end +``` + +See the [Timeouts](#timeouts) section above for the full API. + ### Transient Errors Any `StandardError` (other than `TerminalError`) triggers a retry of the entire invocation. @@ -1164,7 +1183,7 @@ The `examples/` directory contains runnable examples: | `durable_execution.rb` | `Restate.run`, `Restate.run_sync`, `background: true`, `RunRetryPolicy`, `TerminalError` | | `virtual_objects.rb` | Declarative state, `handler` vs `shared`, `state_keys`, `clear_all` | | `workflow.rb` | Declarative state, promises, signals | -| `service_communication.rb` | Fluent call API, fan-out/fan-in, `wait_any`, awakeables | +| `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 | | `deadlock_detection.rb` | Built-in deadlock detection middleware for VirtualObjects | diff --git a/examples/service_communication.rb b/examples/service_communication.rb index d65c3a3..1ae6f09 100644 --- a/examples/service_communication.rb +++ b/examples/service_communication.rb @@ -13,6 +13,7 @@ # - Restate.service_call / Restate.service_send — explicit RPC (same thing, verbose) # - Fan-out/fan-in — launch concurrent calls, collect results # - Restate.wait_any — race multiple futures, handle first completer +# - future.or_timeout(seconds) — bound a single future with a deadline # - Restate.awakeable — pause until an external system calls back # # Try it: @@ -59,6 +60,16 @@ class FanOut < Restate::Service completed.first.await end + # Bound a single call with a deadline. +or_timeout+ races the call + # against a +Restate.sleep+; on timeout the remote invocation is + # cancelled and +Restate::TimeoutError+ (a +TerminalError+ subclass) + # is raised. + handler def with_deadline(task) + Worker.call.process(task).or_timeout(5) + rescue Restate::TimeoutError => e + { 'task' => task, 'error' => e.message, 'status_code' => e.status_code } + end + # Awakeable: pause until an external system resolves the callback. handler def with_callback(task) awakeable_id, future = Restate.awakeable diff --git a/sig/restate.rbs b/sig/restate.rbs index 66c2c89..18f31d0 100644 --- a/sig/restate.rbs +++ b/sig/restate.rbs @@ -73,6 +73,10 @@ module Restate def initialize: (?String message, ?status_code: Integer, ?metadata: Hash[String, String]?) -> void end + class TimeoutError < TerminalError + def initialize: (?String message, ?metadata: Hash[String, String]?) -> void + end + class SuspendedError < StandardError def initialize: () -> void end @@ -92,12 +96,14 @@ module Restate def initialize: (untyped ctx, Integer handle, ?serde: untyped) -> void def await: () -> untyped def completed?: () -> bool + def or_timeout: (Numeric duration) -> untyped end class DurableCallFuture < DurableFuture def initialize: (untyped ctx, Integer result_handle, Integer invocation_id_handle, output_serde: untyped) -> void def invocation_id: () -> String def cancel: () -> void + def or_timeout: (Numeric duration) -> untyped end class SendHandle From 4e78b69d85125ab5a1613e1534b77d158d649ac2 Mon Sep 17 00:00:00 2001 From: junyuanz1 Date: Thu, 21 May 2026 17:23:26 -0400 Subject: [PATCH 3/3] Drop auto-cancel from DurableCallFuture#or_timeout Match TS RestatePromise.orTimeout / Java DurableFuture.withTimeout: the timer firing raises Restate::TimeoutError without cancelling the underlying call. Callers who want the remote invocation stopped rescue the error and invoke #cancel themselves. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/INTERNALS.md | 2 +- docs/USER_GUIDE.md | 17 +++++++-- examples/service_communication.rb | 10 +++--- lib/restate/durable_future.rb | 60 +++---------------------------- sig/restate.rbs | 1 - spec/or_timeout_spec.rb | 17 ++++----- 6 files changed, 33 insertions(+), 74 deletions(-) diff --git a/docs/INTERNALS.md b/docs/INTERNALS.md index a43bc71..09fef9e 100644 --- a/docs/INTERNALS.md +++ b/docs/INTERNALS.md @@ -233,7 +233,7 @@ Three classes for async result handling: - Two handles: `result_handle` (for await) and `invocation_id_handle` (for ID) - `invocation_id` — lazily resolved on first access - `cancel` — calls `Restate.cancel_invocation(invocation_id)` -- `or_timeout(duration)` — overrides the base to also `cancel` the remote invocation when the sleep wins, so the callee doesn't keep running after the caller has given up. The sleep side still has the orphan-handle footprint noted on the parent. +- Inherits `or_timeout` from the parent — does **not** auto-cancel the remote invocation on timeout (matches TS/Java SDKs). Callers rescue `Restate::TimeoutError` and invoke `#cancel` themselves if they want the callee stopped. **`SendHandle`** — returned by `Restate.service_send`, `Restate.object_send`, `Restate.workflow_send`. - `invocation_id` — lazily resolved diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index b5dbb3e..b9b54c3 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -277,14 +277,27 @@ Returns the future's value if it wins; raises `Restate::TimeoutError` (a `TerminalError` subclass, HTTP 408) if the sleep wins. ```ruby -# Bound a service call to 5 seconds. On timeout, the remote -# invocation is cancelled automatically before the error is raised. +# Bound a service call to 5 seconds. result = Worker.call.process(task).or_timeout(5) # Works on any DurableFuture — sleeps, run-blocks, etc. Restate.run('expensive') { compute }.or_timeout(10) ``` +Timeout does **not** cancel the underlying work — matches the TS and +Java SDKs. To stop the remote invocation on a service call, rescue +the error and call `#cancel`: + +```ruby +future = Worker.call.process(task) +begin + future.or_timeout(5) +rescue Restate::TimeoutError + future.cancel + raise +end +``` + **Caveat — orphan sleep**: the underlying shared-core VM has no primitive to cancel an in-flight sleep handle (only `sys_cancel_invocation` for a separate invocation), so when the diff --git a/examples/service_communication.rb b/examples/service_communication.rb index 1ae6f09..6af8cb1 100644 --- a/examples/service_communication.rb +++ b/examples/service_communication.rb @@ -61,12 +61,14 @@ class FanOut < Restate::Service end # Bound a single call with a deadline. +or_timeout+ races the call - # against a +Restate.sleep+; on timeout the remote invocation is - # cancelled and +Restate::TimeoutError+ (a +TerminalError+ subclass) - # is raised. + # against a +Restate.sleep+ and raises +Restate::TimeoutError+ if the + # sleep wins. The remote invocation keeps running unless you cancel + # it explicitly — see the rescue below. handler def with_deadline(task) - Worker.call.process(task).or_timeout(5) + future = Worker.call.process(task) + future.or_timeout(5) rescue Restate::TimeoutError => e + future.cancel { 'task' => task, 'error' => e.message, 'status_code' => e.status_code } end diff --git a/lib/restate/durable_future.rb b/lib/restate/durable_future.rb index de5e5b7..85887c3 100644 --- a/lib/restate/durable_future.rb +++ b/lib/restate/durable_future.rb @@ -34,35 +34,11 @@ def completed? @resolved || @ctx.completed?(@handle) end - # Race +self+ against a durable sleep of +duration+ seconds. Returns - # the future's value if it completes first; raises - # {Restate::TimeoutError} if the sleep wins. - # - # Mirrors +RestatePromise.orTimeout+ in the TypeScript SDK and - # +Awaitable.orTimeout+ in the Java SDK. - # - # == Caveat: the sleep is not cancelled when this future wins - # - # The sleep timer is journaled and the underlying shared-core VM - # exposes no primitive to cancel an in-flight sleep handle (only - # +sys_cancel_invocation+ on a separate invocation). When the - # future wins the race the sleep entry remains in this invocation's - # journal and Restate's scheduler keeps a wake-up registered until - # the duration elapses. The wake-up is a no-op against a completed - # handler, but it keeps the invocation row alive in Restate's - # state until the timer fires — meaningful on long durations. - # - # For long-running deadlines whose retention you care about, - # route the timer through a separate cancellable invocation - # (delayed +ctx.service_send+ to a small trigger service that - # resolves an awakeable) and cancel the +SendHandle+ on success. - # - # @example - # ctx.service_call(MyService, :handler, payload).or_timeout(5) - # - # @param duration [Numeric] timeout in seconds - # @return [Object] the future's value when it wins the race - # @raise [Restate::TimeoutError] when the sleep wins + # Race +self+ against +Restate.sleep(duration)+. Returns the value + # if the future wins; raises {Restate::TimeoutError} otherwise. + # Does not cancel the underlying work (matches TS/Java SDKs); on + # a {DurableCallFuture}, call +#cancel+ in the rescue if you want + # the remote invocation stopped. def or_timeout(duration) sleep_future = Restate.sleep(duration) Restate.wait_any(self, sleep_future) @@ -113,32 +89,6 @@ def invocation_id def cancel @ctx.cancel_invocation(invocation_id) end - - # Race +self+ against a durable sleep of +duration+ seconds. On - # success returns the call's value. On timeout the underlying - # remote invocation is cancelled (via +sys_cancel_invocation+) so - # the callee doesn't continue running after the caller has - # given up. - # - # Refines {DurableFuture#or_timeout} by cleaning up the *call* - # side of the race when the timer wins. The sleep side itself - # cannot be cancelled today — see the parent method's docstring. - # - # @example - # result = ctx.service_call(MyService, :handler, payload).or_timeout(5) - # - # @param duration [Numeric] timeout in seconds - # @return [Object] the call result when this future wins - # @raise [Restate::TimeoutError] when the sleep wins; the remote - # invocation has been cancelled before the error is raised - def or_timeout(duration) - sleep_future = Restate.sleep(duration) - Restate.wait_any(self, sleep_future) - return await if completed? - - cancel - raise TimeoutError - end end # A handle for fire-and-forget send operations. diff --git a/sig/restate.rbs b/sig/restate.rbs index 18f31d0..4ed8d4c 100644 --- a/sig/restate.rbs +++ b/sig/restate.rbs @@ -103,7 +103,6 @@ module Restate def initialize: (untyped ctx, Integer result_handle, Integer invocation_id_handle, output_serde: untyped) -> void def invocation_id: () -> String def cancel: () -> void - def or_timeout: (Numeric duration) -> untyped end class SendHandle diff --git a/spec/or_timeout_spec.rb b/spec/or_timeout_spec.rb index 1033810..710c0c4 100644 --- a/spec/or_timeout_spec.rb +++ b/spec/or_timeout_spec.rb @@ -72,9 +72,11 @@ def build_call_future described_class.new(ctx, :result_handle, :invocation_id_handle, output_serde: nil) end + # Matches TS/Java SDKs: timeout never auto-cancels the underlying call. + # Users who want that behavior rescue +TimeoutError+ and call +#cancel+. describe '#or_timeout' do context 'when the call completes before the sleep' do - it "returns the call's value and does NOT cancel the call" do + it "returns the call's value and does not cancel" do future = build_call_future sleep_future = Restate::DurableFuture.new(ctx, :handle_sleep) @@ -86,15 +88,12 @@ def build_call_future end expect(future.or_timeout(5)).to eq({ 'ok' => true }) - expect(ctx).not_to have_received(:cancel_invocation), - 'happy path must not call cancel — the remote call ' \ - 'is the winner of the race and should be allowed to ' \ - 'finish/return its value normally' + expect(ctx).not_to have_received(:cancel_invocation) end end context 'when the sleep wins the race' do - it 'cancels the remote invocation and raises Restate::TimeoutError' do + it 'raises TimeoutError without cancelling the remote invocation' do future = build_call_future sleep_future = Restate::DurableFuture.new(ctx, :handle_sleep) @@ -105,11 +104,7 @@ def build_call_future end expect { future.or_timeout(5) }.to raise_error(Restate::TimeoutError) - expect(ctx).to have_received(:cancel_invocation).with('inv_xyz'), - 'timeout path must cancel the remote invocation so the ' \ - 'callee does not continue running after the caller has ' \ - 'given up — this is the refinement that DurableCallFuture ' \ - 'adds on top of the base DurableFuture#or_timeout' + expect(ctx).not_to have_received(:cancel_invocation) end end end