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