restate-sdk 0.4.3

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.
@@ -0,0 +1,812 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'async'
5
+ require 'async/queue'
6
+ require 'logger'
7
+
8
+ module Restate
9
+ # The core execution context for a Restate handler invocation.
10
+ # Implements the progress loop and all context API methods (state, run, sleep, call, send).
11
+ #
12
+ # Concurrency model:
13
+ # - The handler runs inside a Fiber managed by Falcon/Async.
14
+ # - `run` blocks spawn child Async tasks.
15
+ # - When the progress loop needs input, it dequeues from @input_queue, yielding the Fiber.
16
+ # - The HTTP input reader (a separate Async task) feeds chunks into @input_queue.
17
+ # - Output chunks are written directly to the streaming response body.
18
+ class ServerContext
19
+ include WorkflowContext
20
+ include WorkflowSharedContext
21
+ extend T::Sig
22
+
23
+ LOGGER = T.let(Logger.new($stdout, progname: 'Restate::ServerContext'), Logger)
24
+
25
+ sig { returns(VMWrapper) }
26
+ attr_reader :vm
27
+
28
+ sig { returns(T.untyped) }
29
+ attr_reader :invocation
30
+
31
+ sig { params(vm: VMWrapper, handler: T.untyped, invocation: T.untyped, send_output: T.untyped, input_queue: Async::Queue).void }
32
+ def initialize(vm:, handler:, invocation:, send_output:, input_queue:)
33
+ @vm = T.let(vm, VMWrapper)
34
+ @handler = T.let(handler, T.untyped)
35
+ @invocation = T.let(invocation, T.untyped)
36
+ @send_output = T.let(send_output, T.untyped)
37
+ @input_queue = T.let(input_queue, Async::Queue)
38
+ @run_coros_to_execute = T.let({}, T::Hash[Integer, T.untyped])
39
+ @attempt_finished_event = T.let(AttemptFinishedEvent.new, AttemptFinishedEvent)
40
+ end
41
+
42
+ # ── Main entry point ──
43
+
44
+ # Runs the handler to completion, writing the output (or failure) to the journal.
45
+ sig { void }
46
+ def enter
47
+ Thread.current[:restate_context] = self
48
+ Thread.current[:restate_service_kind] = @handler.service_tag.kind
49
+ Thread.current[:restate_handler_kind] = @handler.kind
50
+ in_buffer = @invocation.input_buffer
51
+ out_buffer = Restate.invoke_handler(handler: @handler, in_buffer: in_buffer)
52
+ @vm.sys_write_output_success(out_buffer.b)
53
+ @vm.sys_end
54
+ rescue TerminalError => e
55
+ failure = Failure.new(code: e.status_code, message: e.message)
56
+ @vm.sys_write_output_failure(failure)
57
+ @vm.sys_end
58
+ rescue SuspendedError, InternalError
59
+ # These are expected internal control flow exceptions; do nothing.
60
+ rescue DisconnectedError
61
+ raise
62
+ rescue StandardError => e
63
+ # Walk the cause chain for TerminalError or internal exceptions
64
+ cause = T.let(e, T.nilable(Exception))
65
+ handled = T.let(false, T::Boolean)
66
+ while cause
67
+ if cause.is_a?(TerminalError)
68
+ f = Failure.new(code: cause.status_code, message: cause.message)
69
+ @vm.sys_write_output_failure(f)
70
+ @vm.sys_end
71
+ handled = true
72
+ break
73
+ elsif cause.is_a?(SuspendedError) || cause.is_a?(InternalError)
74
+ handled = true
75
+ break
76
+ end
77
+ cause = cause.cause
78
+ end
79
+ unless handled
80
+ @vm.notify_error(e.inspect, e.backtrace&.join("\n"))
81
+ raise
82
+ end
83
+ ensure
84
+ @run_coros_to_execute.clear
85
+ Thread.current[:restate_context] = nil
86
+ Thread.current[:restate_service_kind] = nil
87
+ Thread.current[:restate_handler_kind] = nil
88
+ end
89
+
90
+ # Called by the server when the attempt ends (handler completed, disconnected,
91
+ # or transient error). Signals the attempt_finished_event so that user code
92
+ # and background pool jobs can clean up.
93
+ sig { void }
94
+ def on_attempt_finished
95
+ @attempt_finished_event.set!
96
+ end
97
+
98
+ # ── State operations ──
99
+
100
+ # Durably retrieves a state entry by name. Returns nil if unset.
101
+ sig { override.params(name: String, serde: T.untyped).returns(T.untyped) }
102
+ def get(name, serde: JsonSerde)
103
+ get_async(name, serde: serde).await
104
+ end
105
+
106
+ # Returns a DurableFuture for a state entry. Resolves to nil if unset.
107
+ sig { override.params(name: String, serde: T.untyped).returns(DurableFuture) }
108
+ def get_async(name, serde: JsonSerde)
109
+ handle = @vm.sys_get_state(name)
110
+ DurableFuture.new(self, handle, serde: serde)
111
+ end
112
+
113
+ # Durably sets a state entry. The value is serialized via +serde+.
114
+ sig { override.params(name: String, value: T.untyped, serde: T.untyped).void }
115
+ def set(name, value, serde: JsonSerde)
116
+ @vm.sys_set_state(name, serde.serialize(value).b)
117
+ end
118
+
119
+ # Durably removes a single state entry by name.
120
+ sig { override.params(name: String).void }
121
+ def clear(name)
122
+ @vm.sys_clear_state(name)
123
+ end
124
+
125
+ # Durably removes all state entries for this virtual object or workflow.
126
+ sig { override.void }
127
+ def clear_all
128
+ @vm.sys_clear_all_state
129
+ end
130
+
131
+ # Returns the list of all state entry names for this virtual object or workflow.
132
+ sig { override.returns(T.untyped) }
133
+ def state_keys
134
+ state_keys_async.await
135
+ end
136
+
137
+ # Returns a DurableFuture for the list of all state entry names.
138
+ sig { override.returns(DurableFuture) }
139
+ def state_keys_async
140
+ handle = @vm.sys_get_state_keys
141
+ DurableFuture.new(self, handle)
142
+ end
143
+
144
+ # ── Sleep ──
145
+
146
+ # Returns a durable future that completes after the given duration.
147
+ # The timer survives handler restarts.
148
+ sig { params(seconds: Numeric).returns(DurableFuture) }
149
+ def sleep(seconds)
150
+ millis = (seconds * 1000).to_i
151
+ handle = @vm.sys_sleep(millis)
152
+ DurableFuture.new(self, handle)
153
+ end
154
+
155
+ # Block until a previously created handle completes. Returns the value.
156
+ sig { params(handle: Integer).returns(T.untyped) }
157
+ def resolve_handle(handle)
158
+ poll_and_take(handle)
159
+ end
160
+
161
+ # Wait until any of the given handles completes. Does not take notifications.
162
+ sig { params(handles: T::Array[Integer]).void }
163
+ def wait_any_handle(handles)
164
+ poll_or_cancel(handles) unless handles.any? { |h| @vm.is_completed(h) }
165
+ end
166
+
167
+ # Check if a handle is completed (non-blocking).
168
+ sig { params(handle: Integer).returns(T::Boolean) }
169
+ def completed?(handle)
170
+ @vm.is_completed(handle)
171
+ end
172
+
173
+ # Take a completed handle's notification, returning the value.
174
+ # Raises TerminalError if the handle resolved to a failure.
175
+ sig { params(handle: Integer).returns(T.untyped) }
176
+ def take_completed(handle)
177
+ must_take_notification(handle)
178
+ end
179
+
180
+ # Wait until any of the given futures completes. Returns [completed, remaining].
181
+ sig { override.params(futures: DurableFuture).returns([T::Array[DurableFuture], T::Array[DurableFuture]]) }
182
+ def wait_any(*futures)
183
+ handles = futures.map(&:handle)
184
+ wait_any_handle(handles)
185
+ completed = []
186
+ remaining = []
187
+ futures.each do |f|
188
+ if f.completed?
189
+ completed << f
190
+ else
191
+ remaining << f
192
+ end
193
+ end
194
+ [completed, remaining]
195
+ end
196
+
197
+ # ── Durable run (side effect) ──
198
+
199
+ # Executes a durable side effect. The block runs at most once; its result is
200
+ # journaled and replayed on retries. Returns a DurableFuture for the result.
201
+ #
202
+ # Pass +background: true+ to run the block in a real OS Thread, keeping the
203
+ # fiber event loop responsive for other concurrent handlers. Use this for
204
+ # CPU-intensive work.
205
+ sig do
206
+ override.params(
207
+ name: String,
208
+ serde: T.untyped,
209
+ retry_policy: T.nilable(RunRetryPolicy),
210
+ background: T::Boolean,
211
+ action: T.proc.returns(T.untyped)
212
+ ).returns(DurableFuture)
213
+ end
214
+ def run(name, serde: JsonSerde, retry_policy: nil, background: false, &action)
215
+ handle = @vm.sys_run(name)
216
+
217
+ executor = background ? :execute_run_threaded : :execute_run
218
+ @run_coros_to_execute[handle] = -> { send(executor, handle, action, serde, retry_policy) }
219
+
220
+ DurableFuture.new(self, handle, serde: serde)
221
+ end
222
+
223
+ # Convenience shortcut for +run(...).await+ — executes the durable side effect
224
+ # and returns the result directly.
225
+ #
226
+ # Accepts all the same options as +run+, including +background: true+.
227
+ sig do
228
+ override.params(
229
+ name: String,
230
+ serde: T.untyped,
231
+ retry_policy: T.nilable(RunRetryPolicy),
232
+ background: T::Boolean,
233
+ action: T.proc.returns(T.untyped)
234
+ ).returns(T.untyped)
235
+ end
236
+ def run_sync(name, serde: JsonSerde, retry_policy: nil, background: false, &action)
237
+ run(name, serde: serde, retry_policy: retry_policy, background: background, &action).await
238
+ end
239
+
240
+ # ── Service calls ──
241
+
242
+ # Durably calls a handler on a Restate service and returns a future for its result.
243
+ sig do
244
+ override.params(
245
+ service: T.any(String, T::Class[T.anything]),
246
+ handler: T.any(String, Symbol),
247
+ arg: T.untyped,
248
+ key: T.nilable(String),
249
+ idempotency_key: T.nilable(String),
250
+ headers: T.nilable(T::Hash[String, String]),
251
+ input_serde: T.untyped,
252
+ output_serde: T.untyped
253
+ ).returns(DurableCallFuture)
254
+ end
255
+ def service_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil,
256
+ input_serde: NOT_SET, output_serde: NOT_SET)
257
+ svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
258
+ in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
259
+ out_serde = resolve_serde(output_serde, handler_meta, :output_serde)
260
+ parameter = in_serde.serialize(arg)
261
+ call_handle = @vm.sys_call(
262
+ service: svc_name, handler: handler_name, parameter: parameter.b,
263
+ key: key, idempotency_key: idempotency_key, headers: headers
264
+ )
265
+ DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle,
266
+ output_serde: out_serde)
267
+ end
268
+
269
+ # Sends a one-way invocation to a Restate service handler (fire-and-forget).
270
+ sig do
271
+ override.params(
272
+ service: T.any(String, T::Class[T.anything]),
273
+ handler: T.any(String, Symbol),
274
+ arg: T.untyped,
275
+ key: T.nilable(String),
276
+ delay: T.nilable(Numeric),
277
+ idempotency_key: T.nilable(String),
278
+ headers: T.nilable(T::Hash[String, String]),
279
+ input_serde: T.untyped
280
+ ).returns(SendHandle)
281
+ end
282
+ def service_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil,
283
+ input_serde: NOT_SET)
284
+ svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
285
+ in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
286
+ parameter = in_serde.serialize(arg)
287
+ delay_ms = delay ? (delay * 1000).to_i : nil
288
+ invocation_id_handle = @vm.sys_send(
289
+ service: svc_name, handler: handler_name, parameter: parameter.b,
290
+ key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: headers
291
+ )
292
+ SendHandle.new(self, invocation_id_handle)
293
+ end
294
+
295
+ # Durably calls a handler on a Restate virtual object, keyed by +key+.
296
+ sig do
297
+ override.params(
298
+ service: T.any(String, T::Class[T.anything]),
299
+ handler: T.any(String, Symbol),
300
+ key: String,
301
+ arg: T.untyped,
302
+ idempotency_key: T.nilable(String),
303
+ headers: T.nilable(T::Hash[String, String]),
304
+ input_serde: T.untyped,
305
+ output_serde: T.untyped
306
+ ).returns(DurableCallFuture)
307
+ end
308
+ def object_call(service, handler, key, arg, idempotency_key: nil, headers: nil,
309
+ input_serde: NOT_SET, output_serde: NOT_SET)
310
+ svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
311
+ in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
312
+ out_serde = resolve_serde(output_serde, handler_meta, :output_serde)
313
+ parameter = in_serde.serialize(arg)
314
+ call_handle = @vm.sys_call(
315
+ service: svc_name, handler: handler_name, parameter: parameter.b,
316
+ key: key, idempotency_key: idempotency_key, headers: headers
317
+ )
318
+ DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle,
319
+ output_serde: out_serde)
320
+ end
321
+
322
+ # Sends a one-way invocation to a Restate virtual object handler (fire-and-forget).
323
+ sig do
324
+ override.params(
325
+ service: T.any(String, T::Class[T.anything]),
326
+ handler: T.any(String, Symbol),
327
+ key: String,
328
+ arg: T.untyped,
329
+ delay: T.nilable(Numeric),
330
+ idempotency_key: T.nilable(String),
331
+ headers: T.nilable(T::Hash[String, String]),
332
+ input_serde: T.untyped
333
+ ).returns(SendHandle)
334
+ end
335
+ def object_send(service, handler, key, arg, delay: nil, idempotency_key: nil, headers: nil,
336
+ input_serde: NOT_SET)
337
+ svc_name, handler_name, handler_meta = resolve_call_target(service, handler)
338
+ in_serde = resolve_serde(input_serde, handler_meta, :input_serde)
339
+ parameter = in_serde.serialize(arg)
340
+ delay_ms = delay ? (delay * 1000).to_i : nil
341
+ invocation_id_handle = @vm.sys_send(
342
+ service: svc_name, handler: handler_name, parameter: parameter.b,
343
+ key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: headers
344
+ )
345
+ SendHandle.new(self, invocation_id_handle)
346
+ end
347
+
348
+ # Durably calls a handler on a Restate workflow, keyed by +key+.
349
+ sig do
350
+ override.params(
351
+ service: T.any(String, T::Class[T.anything]),
352
+ handler: T.any(String, Symbol),
353
+ key: String,
354
+ arg: T.untyped,
355
+ idempotency_key: T.nilable(String),
356
+ headers: T.nilable(T::Hash[String, String]),
357
+ input_serde: T.untyped,
358
+ output_serde: T.untyped
359
+ ).returns(DurableCallFuture)
360
+ end
361
+ def workflow_call(service, handler, key, arg, idempotency_key: nil, headers: nil,
362
+ input_serde: NOT_SET, output_serde: NOT_SET)
363
+ object_call(service, handler, key, arg, idempotency_key: idempotency_key, headers: headers,
364
+ input_serde: input_serde, output_serde: output_serde) # rubocop:disable Layout/HashAlignment
365
+ end
366
+
367
+ # Sends a one-way invocation to a Restate workflow handler (fire-and-forget).
368
+ sig do
369
+ override.params(
370
+ service: T.any(String, T::Class[T.anything]),
371
+ handler: T.any(String, Symbol),
372
+ key: String,
373
+ arg: T.untyped,
374
+ delay: T.nilable(Numeric),
375
+ idempotency_key: T.nilable(String),
376
+ headers: T.nilable(T::Hash[String, String]),
377
+ input_serde: T.untyped
378
+ ).returns(SendHandle)
379
+ end
380
+ def workflow_send(service, handler, key, arg, delay: nil, idempotency_key: nil, headers: nil,
381
+ input_serde: NOT_SET)
382
+ object_send(service, handler, key, arg, delay: delay, idempotency_key: idempotency_key, headers: headers,
383
+ input_serde: input_serde) # rubocop:disable Layout/HashAlignment
384
+ end
385
+
386
+ # ── Awakeables ──
387
+
388
+ # Creates an awakeable and returns [awakeable_id, DurableFuture].
389
+ sig { override.params(serde: T.untyped).returns([String, DurableFuture]) }
390
+ def awakeable(serde: JsonSerde)
391
+ id, handle = @vm.sys_awakeable
392
+ [id, DurableFuture.new(self, handle, serde: serde)]
393
+ end
394
+
395
+ # Resolves an awakeable with a success value.
396
+ sig { override.params(awakeable_id: String, payload: T.untyped, serde: T.untyped).void }
397
+ def resolve_awakeable(awakeable_id, payload, serde: JsonSerde)
398
+ @vm.sys_complete_awakeable_success(awakeable_id, serde.serialize(payload).b)
399
+ end
400
+
401
+ # Rejects an awakeable with a terminal failure.
402
+ sig { override.params(awakeable_id: String, message: String, code: Integer).void }
403
+ def reject_awakeable(awakeable_id, message, code: 500)
404
+ failure = Failure.new(code: code, message: message)
405
+ @vm.sys_complete_awakeable_failure(awakeable_id, failure)
406
+ end
407
+
408
+ # ── Promises (Workflow API) ──
409
+
410
+ # Gets a durable promise value, blocking until resolved.
411
+ sig { override.params(name: String, serde: T.untyped).returns(T.untyped) }
412
+ def promise(name, serde: JsonSerde)
413
+ handle = @vm.sys_get_promise(name)
414
+ poll_and_take(handle) do |raw|
415
+ raw.nil? ? nil : serde.deserialize(raw)
416
+ end
417
+ end
418
+
419
+ # Peeks at a durable promise value without blocking. Returns nil if not yet resolved.
420
+ sig { override.params(name: String, serde: T.untyped).returns(T.untyped) }
421
+ def peek_promise(name, serde: JsonSerde)
422
+ handle = @vm.sys_peek_promise(name)
423
+ poll_and_take(handle) do |raw|
424
+ raw.nil? ? nil : serde.deserialize(raw)
425
+ end
426
+ end
427
+
428
+ # Resolves a durable promise with a success value.
429
+ sig { override.params(name: String, payload: T.untyped, serde: T.untyped).void }
430
+ def resolve_promise(name, payload, serde: JsonSerde)
431
+ handle = @vm.sys_complete_promise_success(name, serde.serialize(payload).b)
432
+ poll_and_take(handle)
433
+ nil
434
+ end
435
+
436
+ # Rejects a durable promise with a terminal failure.
437
+ sig { override.params(name: String, message: String, code: Integer).void }
438
+ def reject_promise(name, message, code: 500)
439
+ failure = Failure.new(code: code, message: message)
440
+ handle = @vm.sys_complete_promise_failure(name, failure)
441
+ poll_and_take(handle)
442
+ nil
443
+ end
444
+
445
+ # ── Cancel invocation ──
446
+
447
+ # Requests cancellation of another invocation by its id.
448
+ sig { override.params(invocation_id: String).void }
449
+ def cancel_invocation(invocation_id)
450
+ @vm.sys_cancel_invocation(invocation_id)
451
+ end
452
+
453
+ # ── Generic calls (raw bytes, no serde) ──
454
+
455
+ # Durably calls a handler using raw bytes (no serialization). Useful for proxying.
456
+ sig do
457
+ override.params(
458
+ service: String,
459
+ handler: String,
460
+ arg: String,
461
+ key: T.nilable(String),
462
+ idempotency_key: T.nilable(String),
463
+ headers: T.nilable(T::Hash[String, String])
464
+ ).returns(DurableCallFuture)
465
+ end
466
+ def generic_call(service, handler, arg, key: nil, idempotency_key: nil, headers: nil)
467
+ call_handle = @vm.sys_call(
468
+ service: service, handler: handler, parameter: arg.b,
469
+ key: key, idempotency_key: idempotency_key, headers: headers
470
+ )
471
+ DurableCallFuture.new(self, call_handle.result_handle, call_handle.invocation_id_handle,
472
+ output_serde: nil)
473
+ end
474
+
475
+ # Sends a one-way invocation using raw bytes (no serialization). Useful for proxying.
476
+ sig do
477
+ override.params(
478
+ service: String,
479
+ handler: String,
480
+ arg: String,
481
+ key: T.nilable(String),
482
+ delay: T.nilable(Numeric),
483
+ idempotency_key: T.nilable(String),
484
+ headers: T.nilable(T::Hash[String, String])
485
+ ).returns(SendHandle)
486
+ end
487
+ def generic_send(service, handler, arg, key: nil, delay: nil, idempotency_key: nil, headers: nil)
488
+ delay_ms = delay ? (delay * 1000).to_i : nil
489
+ invocation_id_handle = @vm.sys_send(
490
+ service: service, handler: handler, parameter: arg.b,
491
+ key: key, delay: delay_ms, idempotency_key: idempotency_key, headers: headers
492
+ )
493
+ SendHandle.new(self, invocation_id_handle)
494
+ end
495
+
496
+ # ── Request metadata ──
497
+
498
+ # Returns metadata about the current invocation (id, headers, raw body).
499
+ sig { override.returns(T.untyped) }
500
+ def request
501
+ @request ||= Request.new(
502
+ id: @invocation.invocation_id,
503
+ headers: @invocation.headers.to_h,
504
+ body: @invocation.input_buffer,
505
+ attempt_finished_event: @attempt_finished_event
506
+ )
507
+ end
508
+
509
+ # Returns the key for this virtual object or workflow invocation.
510
+ sig { override.returns(String) }
511
+ def key
512
+ @invocation.key
513
+ end
514
+
515
+ private
516
+
517
+ # ── Progress loop ──
518
+
519
+ # Polls until the given handle(s) complete, then takes the notification.
520
+ sig do
521
+ params(
522
+ handle: Integer,
523
+ block: T.nilable(T.proc.params(arg0: T.untyped).returns(T.untyped))
524
+ ).returns(T.untyped)
525
+ end
526
+ def poll_and_take(handle, &block)
527
+ poll_or_cancel([handle]) unless @vm.is_completed(handle)
528
+ must_take_notification(handle, &block)
529
+ end
530
+
531
+ sig { params(handles: T::Array[Integer]).void }
532
+ def poll_or_cancel(handles)
533
+ loop do
534
+ flush_output
535
+ response = @vm.do_progress(handles)
536
+
537
+ if response.is_a?(Exception)
538
+ LOGGER.error("Exception in do_progress: #{response}")
539
+ raise InternalError
540
+ end
541
+
542
+ case response
543
+ when Suspended
544
+ raise SuspendedError
545
+ when DoProgressAnyCompleted
546
+ return
547
+ when DoProgressCancelSignalReceived
548
+ raise TerminalError.new('cancelled', status_code: 409)
549
+ when DoProgressExecuteRun
550
+ fn = @run_coros_to_execute.delete(response.handle)
551
+ raise "Missing run coroutine for handle #{response.handle}" unless fn
552
+
553
+ # Spawn child task for the run action
554
+ Async do
555
+ fn.call
556
+ ensure
557
+ @input_queue.enqueue(:run_completed)
558
+ end
559
+ when DoWaitPendingRun, DoProgressReadFromInput
560
+ # Wait for input from the HTTP body reader or a run completion signal
561
+ event = @input_queue.dequeue
562
+
563
+ case event
564
+ when :run_completed
565
+ next
566
+ when :eof
567
+ @vm.notify_input_closed
568
+ when :disconnected
569
+ raise DisconnectedError
570
+ when String
571
+ @vm.notify_input(event)
572
+ end
573
+ end
574
+ end
575
+ end
576
+
577
+ sig do
578
+ params(
579
+ handle: Integer,
580
+ block: T.nilable(T.proc.params(arg0: T.untyped).returns(T.untyped))
581
+ ).returns(T.untyped)
582
+ end
583
+ def must_take_notification(handle, &block)
584
+ result = @vm.take_notification(handle)
585
+
586
+ if result.is_a?(Exception)
587
+ flush_output
588
+ LOGGER.error("Exception in take_notification: #{result}")
589
+ raise InternalError
590
+ end
591
+
592
+ case result
593
+ when Suspended
594
+ flush_output
595
+ raise SuspendedError
596
+ when NotReady
597
+ raise "Unexpected NotReady for handle #{handle}"
598
+ when Failure
599
+ raise TerminalError.new(result.message, status_code: result.code)
600
+ else
601
+ block ? yield(result) : result
602
+ end
603
+ end
604
+
605
+ sig { void }
606
+ def flush_output
607
+ loop do
608
+ output = @vm.take_output
609
+ break if output.nil? || output.empty?
610
+
611
+ @send_output.call(output)
612
+ end
613
+ end
614
+
615
+ # ── Call target resolution ──
616
+
617
+ # Resolves a service+handler pair from class/symbol or string/string.
618
+ # Returns [service_name, handler_name, handler_metadata_or_nil].
619
+ sig do
620
+ params(
621
+ service: T.any(String, T::Class[T.anything]),
622
+ handler: T.any(String, Symbol)
623
+ ).returns([String, String, T.nilable(Handler)])
624
+ end
625
+ def resolve_call_target(service, handler)
626
+ if service.is_a?(Class) && service.respond_to?(:service_name)
627
+ svc_name = T.unsafe(service).service_name
628
+ handler_name = handler.to_s
629
+ handler_meta = service.respond_to?(:handlers) ? T.unsafe(service).handlers[handler_name] : nil
630
+ [svc_name, handler_name, handler_meta]
631
+ else
632
+ [service.to_s, handler.to_s, nil]
633
+ end
634
+ end
635
+
636
+ # Resolves a serde value: if the caller passed NOT_SET, fall back to handler metadata, then JsonSerde.
637
+ sig { params(caller_serde: T.untyped, handler_meta: T.nilable(Handler), field: Symbol).returns(T.untyped) }
638
+ def resolve_serde(caller_serde, handler_meta, field)
639
+ return caller_serde unless caller_serde.equal?(NOT_SET)
640
+
641
+ if handler_meta
642
+ handler_meta.handler_io.public_send(field)
643
+ else
644
+ JsonSerde
645
+ end
646
+ end
647
+
648
+ # ── Run execution ──
649
+
650
+ sig do
651
+ params(
652
+ handle: Integer,
653
+ action: T.proc.returns(T.untyped),
654
+ serde: T.untyped,
655
+ retry_policy: T.nilable(RunRetryPolicy)
656
+ ).void
657
+ end
658
+ def execute_run(handle, action, serde, retry_policy)
659
+ propose_run_result(handle, action, serde, retry_policy)
660
+ end
661
+
662
+ # Like execute_run, but offloads the action to a real OS Thread.
663
+ # The fiber yields (via IO.pipe) while the thread runs, keeping the event loop responsive.
664
+ sig do
665
+ params(
666
+ handle: Integer,
667
+ action: T.proc.returns(T.untyped),
668
+ serde: T.untyped,
669
+ retry_policy: T.nilable(RunRetryPolicy)
670
+ ).void
671
+ end
672
+ def execute_run_threaded(handle, action, serde, retry_policy)
673
+ propose_run_result(handle, -> { offload_to_thread(action) }, serde, retry_policy)
674
+ end
675
+
676
+ # Runs the action and proposes the result (success/failure/transient) to the VM.
677
+ sig do
678
+ params(
679
+ handle: Integer,
680
+ action: T.proc.returns(T.untyped),
681
+ serde: T.untyped,
682
+ retry_policy: T.nilable(RunRetryPolicy)
683
+ ).void
684
+ end
685
+ def propose_run_result(handle, action, serde, retry_policy)
686
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
687
+ begin
688
+ result = action.call
689
+ buffer = serde.serialize(result)
690
+ @vm.propose_run_completion_success(handle, buffer.b)
691
+ rescue TerminalError => e
692
+ failure = Failure.new(code: e.status_code, message: e.message)
693
+ @vm.propose_run_completion_failure(handle, failure)
694
+ rescue SuspendedError, InternalError
695
+ raise
696
+ rescue StandardError => e
697
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
698
+ attempt_duration_ms = (elapsed * 1000).to_i
699
+ failure = Failure.new(
700
+ code: 500,
701
+ message: e.inspect,
702
+ stacktrace: e.backtrace&.join("\n")
703
+ )
704
+ config = RunRetryConfig.new(
705
+ initial_interval: retry_policy&.initial_interval,
706
+ max_attempts: retry_policy&.max_attempts,
707
+ max_duration: retry_policy&.max_duration,
708
+ max_interval: retry_policy&.max_interval,
709
+ interval_factor: retry_policy&.interval_factor
710
+ )
711
+ @vm.propose_run_completion_transient(
712
+ handle,
713
+ failure: failure,
714
+ attempt_duration_ms: attempt_duration_ms,
715
+ config: config
716
+ )
717
+ end
718
+ end
719
+
720
+ # Run a block in an OS thread from a shared pool, yielding the current fiber
721
+ # until it completes. Uses IO.pipe to yield the fiber to the Async event loop
722
+ # while the thread does work.
723
+ #
724
+ # The action is wrapped with a cancellation flag so that if the invocation
725
+ # finishes (e.g., suspended, terminal error) before the pool picks up the job,
726
+ # the action is skipped.
727
+ #
728
+ # Note: With Async 2.x and Ruby 3.1+, the Fiber Scheduler already intercepts
729
+ # most blocking I/O (Net::HTTP, TCPSocket, etc.) and yields the fiber
730
+ # automatically. +background: true+ is only needed for CPU-heavy native
731
+ # extensions that release the GVL (e.g., image processing, crypto).
732
+ sig { params(action: T.proc.returns(T.untyped)).returns(T.untyped) }
733
+ def offload_to_thread(action)
734
+ read_io, write_io = IO.pipe
735
+ result = T.let(nil, T.untyped)
736
+ error = T.let(nil, T.nilable(Exception))
737
+ event = @attempt_finished_event
738
+
739
+ begin
740
+ BackgroundPool.submit do
741
+ if event.set?
742
+ # Attempt already finished before pool picked up the job — skip.
743
+ next
744
+ end
745
+
746
+ result = action.call
747
+ rescue Exception => e # rubocop:disable Lint/RescueException
748
+ error = e
749
+ ensure
750
+ write_io.close unless write_io.closed?
751
+ end
752
+
753
+ # Yields the fiber in Async context; resumes when the worker closes write_io.
754
+ read_io.read(1)
755
+ read_io.close
756
+
757
+ raise error if error
758
+
759
+ result
760
+ ensure
761
+ read_io.close unless read_io.closed?
762
+ write_io.close unless write_io.closed?
763
+ end
764
+ end
765
+
766
+ # A simple fixed-size thread pool for background: true runs.
767
+ # Avoids creating a new Thread per call (~1ms + ~1MB stack each).
768
+ # Workers are daemon threads that do not prevent process exit.
769
+ module BackgroundPool
770
+ extend T::Sig
771
+
772
+ @queue = T.let(Queue.new, Queue)
773
+ @workers = T.let([], T::Array[Thread])
774
+ @mutex = T.let(Mutex.new, Mutex)
775
+ @size = T.let(0, Integer)
776
+
777
+ POOL_SIZE = T.let(Integer(ENV.fetch('RESTATE_BACKGROUND_POOL_SIZE', 8)), Integer)
778
+
779
+ module_function
780
+
781
+ # Submit a block to be executed by a pool worker.
782
+ sig { params(block: T.proc.void).void }
783
+ def submit(&block)
784
+ ensure_started
785
+ @queue.push(block)
786
+ end
787
+
788
+ sig { void }
789
+ def ensure_started
790
+ return if @size >= POOL_SIZE
791
+
792
+ @mutex.synchronize do
793
+ while @size < POOL_SIZE
794
+ @size += 1
795
+ worker = Thread.new do
796
+ Kernel.loop do
797
+ job = @queue.pop
798
+ break if job == :shutdown
799
+
800
+ job.call
801
+ end
802
+ end
803
+ worker.name = "restate-bg-#{@size}"
804
+ # Daemon thread: does not prevent the process from exiting.
805
+ worker.report_on_exception = false
806
+ @workers << worker
807
+ end
808
+ end
809
+ end
810
+ end
811
+ end
812
+ end