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