restate-sdk 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40a987e80017804da9524cecb061cb5dd798f63d78b7b9d5297eafd0f7a3f08c
4
- data.tar.gz: 7911f4dc802ffc13b5065ee3d7c0447c04a0741dee2df87aa023b7dfd8196cee
3
+ metadata.gz: 55781f5d8508c22ecf7c6e2a161f658403790fc0538e1028725e0c9c5d33ef0b
4
+ data.tar.gz: ec0b68be695aaafd60188b54544533a0ee58166a3898a9d9d194b71754678fde
5
5
  SHA512:
6
- metadata.gz: 8ed57c28f1a19c800602f98d47f8956850fa72962b8a35f6fe80f1d9e27c28d16de8803d504e0000455d8bfdfe45e0e402e063ea330307f4ace1c13c19196316
7
- data.tar.gz: e48404e2aa84ee83852b65015fd70330d6407ad3874da6f639af741e623133bb59007294cffdc526ec5b0c1bfe35b20dbe4d9557ae4c1e3f0a92df7c603f6ba8
6
+ metadata.gz: 31521448b4cb29fc65cf59edbe4c46bfb88216bb565dbe18eff31ed0a58d7b68e80bd2d59ac338d422df424d2b121a96e2171eee692e9cd6a35373746313c69c
7
+ data.tar.gz: a89ab1dc4a5921fd8cb73f8e9ca85229fb67a6d1468b1109f067bb298158c7022a18ba392f6f9cfd74265b2d0210289a006765e7e267ee77f56f6823eda07db2
data/Cargo.lock CHANGED
@@ -561,7 +561,7 @@ dependencies = [
561
561
 
562
562
  [[package]]
563
563
  name = "restate_internal"
564
- version = "0.10.0"
564
+ version = "0.11.0"
565
565
  dependencies = [
566
566
  "magnus",
567
567
  "rb-sys",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "restate_internal"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  edition = "2021"
5
5
  publish = false
6
6
 
@@ -794,6 +794,49 @@ impl RbVM {
794
794
  .map_err(core_error_to_magnus)
795
795
  }
796
796
 
797
+ // ── Signals ──
798
+
799
+ fn sys_signal(&self, signal_name: String) -> Result<u32, Error> {
800
+ self.vm
801
+ .borrow_mut()
802
+ .create_signal_handle(signal_name)
803
+ .map(Into::into)
804
+ .map_err(core_error_to_magnus)
805
+ }
806
+
807
+ fn sys_complete_signal_success(
808
+ &self,
809
+ target_invocation_id: String,
810
+ signal_name: String,
811
+ buffer: RString,
812
+ ) -> Result<(), Error> {
813
+ let bytes: Vec<u8> = unsafe { buffer.as_slice().to_vec() };
814
+ self.vm
815
+ .borrow_mut()
816
+ .sys_complete_signal(
817
+ target_invocation_id,
818
+ signal_name,
819
+ NonEmptyValue::Success(bytes.into()),
820
+ )
821
+ .map_err(core_error_to_magnus)
822
+ }
823
+
824
+ fn sys_complete_signal_failure(
825
+ &self,
826
+ target_invocation_id: String,
827
+ signal_name: String,
828
+ failure: &RbFailure,
829
+ ) -> Result<(), Error> {
830
+ self.vm
831
+ .borrow_mut()
832
+ .sys_complete_signal(
833
+ target_invocation_id,
834
+ signal_name,
835
+ NonEmptyValue::Failure(failure.clone().into()),
836
+ )
837
+ .map_err(core_error_to_magnus)
838
+ }
839
+
797
840
  // ── Cancel invocation ──
798
841
 
799
842
  fn sys_cancel_invocation(&self, target_invocation_id: String) -> Result<(), Error> {
@@ -1074,6 +1117,15 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1074
1117
  "sys_cancel_invocation",
1075
1118
  method!(RbVM::sys_cancel_invocation, 1),
1076
1119
  )?;
1120
+ vm_class.define_method("sys_signal", method!(RbVM::sys_signal, 1))?;
1121
+ vm_class.define_method(
1122
+ "sys_complete_signal_success",
1123
+ method!(RbVM::sys_complete_signal_success, 3),
1124
+ )?;
1125
+ vm_class.define_method(
1126
+ "sys_complete_signal_failure",
1127
+ method!(RbVM::sys_complete_signal_failure, 3),
1128
+ )?;
1077
1129
 
1078
1130
  // IdentityVerifier
1079
1131
  let iv_class = internal.define_class("IdentityVerifier", ruby.class_object())?;
@@ -135,6 +135,18 @@ module Restate
135
135
  # Reject an awakeable with a terminal failure.
136
136
  def reject_awakeable(awakeable_id, message, code: 500); end
137
137
 
138
+ # Wait for a named signal addressed to this invocation.
139
+ # Returns a DurableFuture that resolves once another invocation calls
140
+ # +resolve_signal+ or +reject_signal+ with the same name targeting this
141
+ # invocation's id.
142
+ def signal(name, serde: JsonSerde); end
143
+
144
+ # Send a success value to a named signal on another invocation.
145
+ def resolve_signal(invocation_id, name, payload, serde: JsonSerde); end
146
+
147
+ # Send a terminal failure to a named signal on another invocation.
148
+ def reject_signal(invocation_id, name, message, code: 500); end
149
+
138
150
  # Request cancellation of another invocation.
139
151
  def cancel_invocation(invocation_id); end
140
152
 
@@ -0,0 +1,216 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ require 'base64'
5
+ require 'set'
6
+
7
+ module Restate
8
+ module Middleware
9
+ # Detects VirtualObject deadlocks caused by re-entrant calls to a VO whose
10
+ # exclusive handler is still running higher up the call chain.
11
+ #
12
+ # == The problem
13
+ #
14
+ # Restate VirtualObjects serialize exclusive handler access per key. If handler A
15
+ # on VO key "x" calls handler B on the same VO key "x", the call will block
16
+ # forever — the key is already locked by A. This is a deadlock.
17
+ #
18
+ # == How it works
19
+ #
20
+ # This middleware tracks which VO keys are held by the current call chain and
21
+ # propagates that information via a header on every outbound call.
22
+ #
23
+ # === Inbound side
24
+ #
25
+ # 1. Reads the held-locks header from the incoming request.
26
+ # 2. If the current handler is an exclusive VO handler targeting a key already
27
+ # in the set → deadlock. Raises a {DeadlockError} immediately.
28
+ # 3. If this handler is an exclusive VO handler, appends its lock to the set
29
+ # so further downstream calls propagate it.
30
+ #
31
+ # === Outbound side
32
+ #
33
+ # Injects the held-locks header into every outbound service call. When
34
+ # handler metadata is available (the target service class is known), only
35
+ # raises for exclusive handlers — shared handler calls are safe. Falls
36
+ # back to raising for any same-service call when metadata is unavailable
37
+ # (e.g., calling by string name to an external service).
38
+ #
39
+ # == Wire format
40
+ #
41
+ # Lock entries are encoded as +base64url(service).base64url(key)+ and
42
+ # separated by commas. Base64url encoding ensures arbitrary service names
43
+ # and keys (including those containing +.+, +,+, or non-ASCII characters)
44
+ # are handled correctly.
45
+ #
46
+ # == Journal determinism
47
+ #
48
+ # The held-locks header is deterministic across replays: its value depends only
49
+ # on the execution path, which Restate's journal guarantees is identical on
50
+ # every replay.
51
+ #
52
+ # == Usage
53
+ #
54
+ # endpoint = Restate.endpoint(MyVirtualObject)
55
+ # endpoint.use(Restate::Middleware::DeadlockDetection::Inbound)
56
+ # endpoint.use_outbound(Restate::Middleware::DeadlockDetection::Outbound)
57
+ #
58
+ module DeadlockDetection
59
+ HEADER = 'x-restate-held-locks'
60
+ ENTRY_SEPARATOR = ','
61
+ FIELD_SEPARATOR = '.'
62
+ DEADLOCK_STATUS_CODE = 409
63
+
64
+ THREAD_KEY = :restate_held_exclusive_locks
65
+
66
+ class << self
67
+ # Returns the current set of held exclusive locks for this fiber.
68
+ # Each entry is a two-element array: [service_name, key].
69
+ #
70
+ # @return [Set<Array<String>>]
71
+ def held_locks
72
+ Thread.current[THREAD_KEY] || Set.new
73
+ end
74
+
75
+ # @param locks [Set<Array<String>>]
76
+ def held_locks=(locks)
77
+ Thread.current[THREAD_KEY] = locks
78
+ end
79
+
80
+ # Encodes a [service, key] pair into a wire-safe string.
81
+ def encode_lock(service, key)
82
+ b64_svc = Base64.urlsafe_encode64(service, padding: false)
83
+ b64_key = Base64.urlsafe_encode64(key, padding: false)
84
+ "#{b64_svc}#{FIELD_SEPARATOR}#{b64_key}"
85
+ end
86
+
87
+ # Decodes a wire-format lock string into [service, key].
88
+ # Returns nil if the format is invalid.
89
+ def decode_lock(encoded)
90
+ parts = encoded.split(FIELD_SEPARATOR, 2)
91
+ return nil unless parts.length == 2
92
+
93
+ svc = Base64.urlsafe_decode64(parts[0]).force_encoding('UTF-8')
94
+ key = Base64.urlsafe_decode64(parts[1]).force_encoding('UTF-8')
95
+ [svc, key]
96
+ rescue ArgumentError
97
+ nil
98
+ end
99
+
100
+ # Serializes a set of [service, key] lock pairs into a header value.
101
+ def encode_header(locks)
102
+ locks.map { |svc, key| encode_lock(svc, key) }.join(ENTRY_SEPARATOR)
103
+ end
104
+
105
+ # Deserializes a header value into a Set of [service, key] pairs.
106
+ def decode_header(raw)
107
+ return Set.new if raw.nil? || raw.to_s.empty?
108
+
109
+ entries = raw.to_s.split(ENTRY_SEPARATOR).filter_map do |entry|
110
+ decode_lock(entry.strip)
111
+ end
112
+ Set.new(entries)
113
+ end
114
+ end
115
+
116
+ # Error raised when a deadlock is detected.
117
+ #
118
+ # Uses status code 409 (Conflict) to signal that retrying won't help.
119
+ class DeadlockError < Restate::TerminalError
120
+ def initialize(message)
121
+ super(message, status_code: DEADLOCK_STATUS_CODE)
122
+ end
123
+ end
124
+
125
+ # Inbound middleware that checks for and tracks VO locks.
126
+ #
127
+ # Register with: +endpoint.use(Restate::Middleware::DeadlockDetection::Inbound)+
128
+ #
129
+ # @example
130
+ # endpoint = Restate.endpoint(MyVirtualObject)
131
+ # endpoint.use(Restate::Middleware::DeadlockDetection::Inbound)
132
+ class Inbound
133
+ def call(handler, ctx)
134
+ previous = DeadlockDetection.held_locks
135
+ incoming = parse_locks(ctx)
136
+ check_and_track_lock!(handler, ctx, incoming)
137
+ DeadlockDetection.held_locks = incoming
138
+ yield
139
+ ensure
140
+ DeadlockDetection.held_locks = previous
141
+ end
142
+
143
+ private
144
+
145
+ def check_and_track_lock!(handler, ctx, incoming)
146
+ return unless handler.service_tag.kind == 'object'
147
+ return unless handler.kind == 'exclusive'
148
+
149
+ key = ctx.respond_to?(:key) ? ctx.key : nil
150
+ return unless key
151
+
152
+ svc = handler.service_tag.name
153
+ lock = [svc, key]
154
+ raise_deadlock!(svc, handler.name, key, incoming) if incoming.include?(lock)
155
+ incoming << lock
156
+ end
157
+
158
+ def raise_deadlock!(svc, handler_name, key, locks)
159
+ held = locks.map { |s, k| "#{s}:#{k}" }.join(', ')
160
+ msg = "Deadlock detected: #{svc}##{handler_name} on key '#{key}' " \
161
+ 'called while an exclusive handler holds the same VO key. ' \
162
+ "Held locks: #{held}. " \
163
+ 'This call will never complete.'
164
+ Kernel.raise DeadlockError, msg
165
+ end
166
+
167
+ def parse_locks(ctx)
168
+ headers = ctx.request.headers
169
+ raw = headers.is_a?(Hash) ? headers[HEADER] : nil
170
+ DeadlockDetection.decode_header(raw)
171
+ end
172
+ end
173
+
174
+ # Outbound middleware that propagates held locks via headers.
175
+ #
176
+ # When handler metadata is available (via Thread.current[:restate_outbound_handler_meta]),
177
+ # shared handler calls are allowed through — only exclusive handlers can deadlock.
178
+ # When metadata is unavailable (external service called by string name), falls
179
+ # back to raising for any same-service call.
180
+ #
181
+ # Register with: +endpoint.use_outbound(Restate::Middleware::DeadlockDetection::Outbound)+
182
+ #
183
+ # @example
184
+ # endpoint = Restate.endpoint(MyVirtualObject)
185
+ # endpoint.use_outbound(Restate::Middleware::DeadlockDetection::Outbound)
186
+ class Outbound
187
+ def call(service, handler, headers)
188
+ locks = DeadlockDetection.held_locks
189
+ propagate_and_check!(service, handler, headers, locks) if locks.any?
190
+ yield
191
+ end
192
+
193
+ private
194
+
195
+ def propagate_and_check!(service, handler, headers, locks)
196
+ headers[HEADER] = DeadlockDetection.encode_header(locks)
197
+
198
+ held_lock = locks.find { |svc, _key| svc == service }
199
+ return unless held_lock
200
+
201
+ return if target_shared?
202
+
203
+ msg = "Deadlock detected: outbound call to #{service}##{handler} " \
204
+ "while exclusive lock held on #{held_lock[0]}:#{held_lock[1]}. " \
205
+ 'This call will block forever.'
206
+ Kernel.raise DeadlockError, msg
207
+ end
208
+
209
+ def target_shared?
210
+ meta = Thread.current[:restate_outbound_handler_meta]
211
+ meta&.kind == 'shared'
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -206,7 +206,7 @@ module Restate
206
206
  in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
207
207
  out_serde = resolve_serde(output_serde, handler_meta, :output_serde)
208
208
  parameter = in_serde.serialize(arg)
209
- with_outbound_middleware(svc_name, handler_name, headers) do |hdrs|
209
+ with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs|
210
210
  call_handle = @vm.sys_call(
211
211
  service: svc_name, handler: handler_name, parameter: parameter,
212
212
  key: key, idempotency_key: idempotency_key, headers: hdrs
@@ -223,7 +223,7 @@ module Restate
223
223
  in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
224
224
  parameter = in_serde.serialize(arg)
225
225
  delay_ms = delay ? (delay * 1000).to_i : nil
226
- with_outbound_middleware(svc_name, handler_name, headers) do |hdrs|
226
+ with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs|
227
227
  invocation_id_handle = @vm.sys_send(
228
228
  service: svc_name, handler: handler_name, parameter: parameter,
229
229
  key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs
@@ -239,7 +239,7 @@ module Restate
239
239
  in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
240
240
  out_serde = resolve_serde(output_serde, handler_meta, :output_serde)
241
241
  parameter = in_serde.serialize(arg)
242
- with_outbound_middleware(svc_name, handler_name, headers) do |hdrs|
242
+ with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs|
243
243
  call_handle = @vm.sys_call(
244
244
  service: svc_name, handler: handler_name, parameter: parameter,
245
245
  key: key, idempotency_key: idempotency_key, headers: hdrs
@@ -256,7 +256,7 @@ module Restate
256
256
  in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
257
257
  parameter = in_serde.serialize(arg)
258
258
  delay_ms = delay ? (delay * 1000).to_i : nil
259
- with_outbound_middleware(svc_name, handler_name, headers) do |hdrs|
259
+ with_outbound_middleware(svc_name, handler_name, headers, handler_meta: handler_meta) do |hdrs|
260
260
  invocation_id_handle = @vm.sys_send(
261
261
  service: svc_name, handler: handler_name, parameter: parameter,
262
262
  key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: hdrs
@@ -298,6 +298,25 @@ module Restate
298
298
  @vm.sys_complete_awakeable_failure(awakeable_id, failure)
299
299
  end
300
300
 
301
+ # ── Signals ──
302
+
303
+ # Wait for a named signal addressed to this invocation. Returns a DurableFuture.
304
+ def signal(name, serde: JsonSerde)
305
+ handle = @vm.sys_signal(name)
306
+ DurableFuture.new(self, handle, serde: serde)
307
+ end
308
+
309
+ # Send a success value to a named signal on another invocation.
310
+ def resolve_signal(invocation_id, name, payload, serde: JsonSerde)
311
+ @vm.sys_complete_signal_success(invocation_id, name, serde.serialize(payload))
312
+ end
313
+
314
+ # Send a terminal failure to a named signal on another invocation.
315
+ def reject_signal(invocation_id, name, message, code: 500)
316
+ failure = Failure.new(code: code, message: message)
317
+ @vm.sys_complete_signal_failure(invocation_id, name, failure)
318
+ end
319
+
301
320
  # ── Promises (Workflow API) ──
302
321
 
303
322
  # Gets a durable promise value, blocking until resolved.
@@ -472,18 +491,25 @@ module Restate
472
491
  # Runs outbound middleware chain (Sidekiq client middleware pattern).
473
492
  # Each middleware gets +call(service, handler, headers)+ and must +yield+
474
493
  # to continue the chain. The block at the end performs the actual VM call.
475
- def with_outbound_middleware(service, handler, headers, &action)
476
- if @outbound_middleware.empty?
477
- action.call(headers)
478
- else
479
- h = headers || {}
480
- chain = ->(hdrs) { action.call(hdrs) }
481
- @outbound_middleware.reverse_each do |mw|
482
- prev = chain
483
- chain = ->(hdrs) { mw.call(service, handler, hdrs) { prev.call(hdrs) } }
484
- end
485
- chain.call(h)
494
+ #
495
+ # The optional +handler_meta+ (a Handler struct from resolve_call_target)
496
+ # is exposed via Thread.current[:restate_outbound_handler_meta] so that
497
+ # middleware can inspect the target handler's kind without changing the
498
+ # middleware interface.
499
+ def with_outbound_middleware(service, handler, headers, handler_meta: nil, &action)
500
+ return action.call(headers) if @outbound_middleware.empty?
501
+
502
+ h = headers || {}
503
+ previous_meta = Thread.current[:restate_outbound_handler_meta]
504
+ Thread.current[:restate_outbound_handler_meta] = handler_meta
505
+ chain = ->(hdrs) { action.call(hdrs) }
506
+ @outbound_middleware.reverse_each do |mw|
507
+ prev = chain
508
+ chain = ->(hdrs) { mw.call(service, handler, hdrs) { prev.call(hdrs) } }
486
509
  end
510
+ chain.call(h)
511
+ ensure
512
+ Thread.current[:restate_outbound_handler_meta] = previous_meta
487
513
  end
488
514
 
489
515
  # ── Call target resolution ──
@@ -2,5 +2,5 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Restate
5
- VERSION = '0.10.0'
5
+ VERSION = '0.11.0'
6
6
  end
data/lib/restate/vm.rb CHANGED
@@ -223,6 +223,19 @@ module Restate
223
223
  @vm.sys_cancel_invocation(invocation_id)
224
224
  end
225
225
 
226
+ def sys_signal(name)
227
+ @vm.sys_signal(name)
228
+ end
229
+
230
+ def sys_complete_signal_success(invocation_id, name, value)
231
+ @vm.sys_complete_signal_success(invocation_id, name, value)
232
+ end
233
+
234
+ def sys_complete_signal_failure(invocation_id, name, failure)
235
+ native_failure = Internal::Failure.new(failure.code, failure.message, nil)
236
+ @vm.sys_complete_signal_failure(invocation_id, name, native_failure)
237
+ end
238
+
226
239
  private
227
240
 
228
241
  def map_do_progress(result)
data/lib/restate.rb CHANGED
@@ -18,6 +18,7 @@ require_relative 'restate/endpoint'
18
18
  require_relative 'restate/service_proxy'
19
19
  require_relative 'restate/config'
20
20
  require_relative 'restate/client'
21
+ require_relative 'restate/middleware/deadlock_detection'
21
22
  require_relative 'restate/railtie' if defined?(Rails::Railtie)
22
23
 
23
24
  # Restate Ruby SDK — build resilient applications with durable execution.
@@ -241,6 +242,23 @@ module Restate # rubocop:disable Metrics/ModuleLength
241
242
  fetch_context!.reject_awakeable(awakeable_id, message, code: code)
242
243
  end
243
244
 
245
+ # ── Signals ──
246
+
247
+ # Wait for a named signal addressed to this invocation. Returns a DurableFuture.
248
+ def signal(name, serde: JsonSerde)
249
+ fetch_context!.signal(name, serde: serde)
250
+ end
251
+
252
+ # Send a success value to a named signal on another invocation.
253
+ def resolve_signal(invocation_id, name, payload, serde: JsonSerde)
254
+ fetch_context!.resolve_signal(invocation_id, name, payload, serde: serde)
255
+ end
256
+
257
+ # Send a terminal failure to a named signal on another invocation.
258
+ def reject_signal(invocation_id, name, message, code: 500)
259
+ fetch_context!.reject_signal(invocation_id, name, message, code: code)
260
+ end
261
+
244
262
  # ── Promises (Workflow only) ──
245
263
 
246
264
  # Get a durable promise value, blocking until resolved.
data/sig/restate.rbs CHANGED
@@ -44,6 +44,12 @@ module Restate
44
44
  def self.resolve_awakeable: (String awakeable_id, untyped payload, ?serde: untyped) -> void
45
45
  def self.reject_awakeable: (String awakeable_id, String message, ?code: Integer) -> void
46
46
 
47
+ # ── Signals ──
48
+
49
+ def self.signal: (String name, ?serde: untyped) -> DurableFuture
50
+ def self.resolve_signal: (String invocation_id, String name, untyped payload, ?serde: untyped) -> void
51
+ def self.reject_signal: (String invocation_id, String name, String message, ?code: Integer) -> void
52
+
47
53
  # ── Promises ──
48
54
 
49
55
  def self.promise: (String name, ?serde: untyped) -> untyped
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: restate-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Restate Developers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-11 00:00:00.000000000 Z
11
+ date: 2026-05-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async
@@ -64,6 +64,7 @@ files:
64
64
  - lib/restate/errors.rb
65
65
  - lib/restate/handler.rb
66
66
  - lib/restate/introspection.rb
67
+ - lib/restate/middleware/deadlock_detection.rb
67
68
  - lib/restate/railtie.rb
68
69
  - lib/restate/serde.rb
69
70
  - lib/restate/server.rb