cdc-parallel 0.2.2 → 0.2.4

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.
@@ -2,97 +2,281 @@
2
2
 
3
3
  module CDC
4
4
  module Parallel
5
- # Executes one Ractor-safe processor in pre-warmed persistent Ractor workers.
5
+ # Executes one Ractor-safe `cdc-core` processor across a fixed set of
6
+ # pre-warmed Ractor workers.
6
7
  #
7
- # Workers are created during initialization and reused for every dispatch.
8
- # This pays Ractor startup cost once, keeps workers alive after processor
9
- # failures, and provides both synchronous single-item processing and batched
10
- # dispatch for throughput-oriented benchmarks and runtimes.
8
+ # `ProcessorPool` is the low-level execution primitive used by
9
+ # {CDC::Parallel::Runtime}. It accepts normalized `cdc-core` work items,
10
+ # sends them across Ractor boundaries, invokes the configured processor, and
11
+ # returns `CDC::Core::ProcessorResult` objects in input order.
12
+ #
13
+ # This class is intentionally focused on **CPU-bound parallel execution**.
14
+ # Use it when the processor spends most of its time doing Ruby work such as
15
+ # transformation, enrichment, serialization, compression, scoring, or other
16
+ # in-memory computation. For I/O-heavy work, the CDC Ecosystem boundary is a
17
+ # future fiber-friendly runtime such as `cdc-concurrent`.
18
+ #
19
+ # ## Processor safety contract
20
+ #
21
+ # The supplied processor must declare `ractor_safe!` on its class. That
22
+ # declaration is treated as the processor author's explicit promise that the
23
+ # processor object and its dependencies can safely cross a Ractor boundary.
24
+ #
25
+ # `ProcessorPool` validates this declaration before booting workers:
26
+ #
27
+ # @example Declaring a processor as Ractor-safe
28
+ # class AnalyticsProcessor < CDC::Core::Processor
29
+ # ractor_safe!
30
+ #
31
+ # def process(event)
32
+ # CDC::Core::ProcessorResult.success(event)
33
+ # end
34
+ # end
35
+ #
36
+ # pool = CDC::Parallel::ProcessorPool.new(
37
+ # processor: AnalyticsProcessor.new,
38
+ # size: 4
39
+ # )
40
+ #
41
+ # Declaring `ractor_safe!` does not make unsafe code safe. It only allows the
42
+ # processor to be passed into worker Ractors. Mutable global state, database
43
+ # connections, sockets, caches, file handles, and non-shareable objects still
44
+ # need to be designed carefully by the processor implementor.
45
+ #
46
+ # ## Execution model
47
+ #
48
+ # Workers are created during initialization and reused for all dispatches.
49
+ # This pays Ractor startup cost once and keeps the pool stable even when
50
+ # individual processor calls fail.
51
+ #
52
+ # The pool uses a fan-out / fan-in pattern:
53
+ #
54
+ # ```text
55
+ # work items
56
+ # |
57
+ # v
58
+ # ProcessorPool
59
+ # |
60
+ # +----> Worker Ractor 1
61
+ # +----> Worker Ractor 2
62
+ # +----> Worker Ractor N
63
+ # |
64
+ # v
65
+ # ProcessorResult
66
+ # |
67
+ # v
68
+ # ordered results
69
+ # ```
70
+ #
71
+ # Fan-out uses round-robin worker selection. Fan-in collects responses from a
72
+ # reply port and reorders them by submission index, so `process_many` always
73
+ # returns results in the same order as the input array even when work
74
+ # completes out of order.
75
+ #
76
+ # @example Processing one item
77
+ # result = pool.process(event)
78
+ # result.success? #=> true
79
+ #
80
+ # @example Processing a batch while preserving result order
81
+ # results = pool.process_many([event_a, event_b, event_c])
82
+ # results.map(&:success?)
83
+ #
84
+ # @example Shutting down explicitly
85
+ # pool.shutdown
86
+ #
87
+ # @note `ProcessorPool` preserves the order of returned results, not the
88
+ # order in which independent items execute. If a sink needs strict ordering
89
+ # by transaction, relation, or primary key, use the ecosystem ordering
90
+ # contract and an ordered dispatcher/runtime above this primitive.
91
+ #
92
+ # @see CDC::Parallel::Runtime High-level facade for processing supported CDC work items
93
+ # @see CDC::Parallel::TransactionPool Transaction-envelope processing wrapper
94
+ # @see CDC::Parallel::ResultCollector Worker response normalization
95
+ # @api public
11
96
  class ProcessorPool # rubocop:disable Metrics/ClassLength
97
+ # Create a new pool and boot its worker Ractors.
98
+ #
12
99
  # @param processor [CDC::Core::Processor]
100
+ # Processor instance used by every worker. Its class must respond to
101
+ # `ractor_safe?` and return `true`.
13
102
  # @param size [Integer]
14
- # @param timeout [Float, nil]
103
+ # Number of worker Ractors to boot. Defaults to `Etc.nprocessors`.
104
+ # @param timeout [Numeric, nil]
105
+ # Optional timeout, in seconds, used when waiting for worker results and
106
+ # during shutdown. `nil` means wait indefinitely.
107
+ # @raise [UnsafeProcessorError]
108
+ # Raised when the processor class has not declared `ractor_safe!`.
109
+ # @raise [ArgumentError]
110
+ # Raised by {Configuration} when `size` or `timeout` is invalid.
15
111
  # @return [void]
16
- def initialize(processor:, size: Etc.nprocessors, timeout: nil)
112
+ # @param supervision [Boolean]
113
+ # Whether worker Ractors should be respawned after unexpected death.
114
+ # @param max_respawns [Integer]
115
+ # Maximum crash count inside `respawn_window` before a worker slot enters
116
+ # cooldown.
117
+ # @param respawn_window [Numeric]
118
+ # Time window, in seconds, used by the crash-loop circuit breaker.
119
+ # @param respawn_cooldown [Numeric]
120
+ # Cooldown, in seconds, before a crash-looping slot is revived again.
121
+ # @param manage_lifecycle [Boolean]
122
+ # When `true` (default), the pool calls `processor.start` during
123
+ # initialization and `processor.flush` + `processor.stop` during shutdown.
124
+ # Set to `false` when a higher-level runtime (e.g. {Runtime}) owns the
125
+ # processor lifecycle so that `start`/`stop`/`flush` are not called
126
+ # multiple times when the same processor is shared across pools.
127
+ # rubocop:disable Metrics/MethodLength
128
+ def initialize(
129
+ processor:,
130
+ size: Etc.nprocessors,
131
+ timeout: nil,
132
+ supervision: true,
133
+ max_respawns: 3,
134
+ respawn_window: 60,
135
+ respawn_cooldown: 5,
136
+ manage_lifecycle: true
137
+ )
17
138
  validate_processor!(processor)
18
139
 
140
+ processor.start if manage_lifecycle
19
141
  @processor = ::Ractor.make_shareable(processor)
142
+ @manage_lifecycle = manage_lifecycle
20
143
  @configuration = Configuration.new(size:, timeout:)
21
- @workers = Array.new(@configuration.size) do
22
- build_worker(@processor)
144
+ @slots = Array.new(@configuration.size) do |index|
145
+ WorkerSlot.new(
146
+ index:,
147
+ processor: @processor,
148
+ supervision:,
149
+ max_respawns:,
150
+ respawn_window:,
151
+ respawn_cooldown:
152
+ )
23
153
  end.freeze
154
+ @workers = @slots.map(&:worker).freeze
155
+ @worker_inboxes = @slots.map(&:inbox).freeze
24
156
 
25
157
  @next_worker = 0
158
+ @dispatch_mutex = Mutex.new
26
159
  @shutdown = false
27
160
  end
161
+ # rubocop:enable Metrics/MethodLength
162
+
163
+ # Return total worker-slot respawns since this pool booted.
164
+ #
165
+ # @return [Integer]
166
+ def respawns
167
+ @slots.sum(&:respawns)
168
+ end
169
+
170
+ # Return whether any worker slot is currently in crash-loop cooldown.
171
+ #
172
+ # @return [Boolean]
173
+ def degraded?
174
+ @slots.any?(&:degraded?)
175
+ end
28
176
 
29
177
  # Process one work item synchronously.
30
178
  #
179
+ # This is a convenience wrapper around {#process_many}. The work still
180
+ # executes inside a worker Ractor; the call blocks until the corresponding
181
+ # `CDC::Core::ProcessorResult` is available or until the optional timeout
182
+ # is reached.
183
+ #
31
184
  # @param item [Object]
185
+ # Shareable work item, usually a `CDC::Core::ChangeEvent`.
186
+ # @raise [ShutdownError]
187
+ # Raised when work is submitted after {#shutdown} has started.
32
188
  # @return [CDC::Core::ProcessorResult]
189
+ # Normalized processor result. Processor exceptions are captured as
190
+ # failure results rather than escaping directly from the worker Ractor.
33
191
  def process(item)
34
192
  process_many([item]).fetch(0)
35
193
  end
36
194
 
37
195
  # Process many work items using the pre-warmed worker pool.
38
196
  #
39
- # Results are returned in the same order as the supplied work items.
197
+ # Each item is made shareable before dispatch. Items are assigned to worker
198
+ # inboxes using round-robin selection. Responses are collected through a
199
+ # per-call reply port and returned in the same order as the input array.
40
200
  #
41
201
  # @param items [Array<Object>]
202
+ # Work items to process. Empty arrays are valid and return an empty
203
+ # frozen array.
204
+ # @raise [ShutdownError]
205
+ # Raised when work is submitted after {#shutdown} has started.
42
206
  # @return [Array<CDC::Core::ProcessorResult>]
207
+ # Frozen array of normalized results, ordered to match `items`.
43
208
  def process_many(items)
44
- raise ShutdownError, "processor pool has been shut down" if @shutdown
45
-
46
209
  work_items = items.map { |item| ::Ractor.make_shareable(item) }
47
210
  reply_port = ::Ractor::Port.new
48
211
 
49
- work_items.each_with_index do |item, index|
50
- next_worker.send([index, item, reply_port])
51
- end
212
+ assignments = dispatch(work_items, reply_port)
52
213
 
53
- collect_results(reply_port, work_items.length)
214
+ collect_results(reply_port, work_items.length, assignments).compact.freeze
54
215
  ensure
55
216
  reply_port&.close
56
217
  end
57
218
 
58
- # Shut down the pool.
219
+ # Shut down the pool and wait for worker Ractors to exit.
220
+ #
221
+ # Shutdown is idempotent. The first caller signals all worker inboxes with
222
+ # a stop message and waits for workers to join. Later calls return without
223
+ # doing anything.
59
224
  #
60
225
  # @return [void]
61
226
  def shutdown
62
- return if @shutdown
227
+ @dispatch_mutex.synchronize do
228
+ return if @shutdown
63
229
 
64
- @shutdown = true
230
+ @shutdown = true
231
+ signal_workers
232
+ end
65
233
 
66
- signal_workers
67
234
  wait_for_workers
235
+ return unless @manage_lifecycle
236
+
237
+ @processor.flush
238
+ @processor.stop
68
239
  end
69
240
 
70
241
  private
71
242
 
72
- def signal_workers
73
- @workers.each do |worker|
74
- worker.send(nil)
75
- rescue Ractor::ClosedError
76
- # Already stopped.
243
+ def dispatch(work_items, reply_port)
244
+ @dispatch_mutex.synchronize do
245
+ raise ShutdownError, "processor pool has been shut down" if @shutdown
246
+
247
+ assignments = Array.new(work_items.length)
248
+ work_items.each_with_index do |item, index|
249
+ slot = next_worker_slot
250
+ assignments[index] = slot
251
+ slot.send_work(index, item, reply_port)
252
+ end
253
+ assignments
77
254
  end
78
255
  end
79
256
 
257
+ def signal_workers
258
+ @slots.each(&:shutdown)
259
+ end
260
+
80
261
  def wait_for_workers
81
262
  if @configuration.timeout
82
263
  wait_for_workers_with_timeout
83
264
  else
84
- @workers.each(&:join)
265
+ @slots.each(&:join)
85
266
  end
86
267
  end
87
268
 
88
269
  def wait_for_workers_with_timeout
89
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @configuration.timeout
270
+ timeout = @configuration.timeout
271
+ return unless timeout
272
+
273
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
90
274
 
91
- @workers.each do |worker|
275
+ @slots.each do |slot|
92
276
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
93
277
  break unless remaining.positive?
94
278
 
95
- ::Timeout.timeout(remaining, TimeoutError) { worker.join }
279
+ ::Timeout.timeout(remaining, TimeoutError) { slot.join }
96
280
  rescue TimeoutError
97
281
  break
98
282
  end
@@ -106,62 +290,77 @@ module CDC
106
290
  "#{processor.class} must declare ractor_safe!"
107
291
  end
108
292
 
109
- def build_worker(processor) # rubocop:disable Metrics/MethodLength
110
- ::Ractor.new(processor) do |safe_processor|
111
- loop do
112
- message = ::Ractor.receive
113
- break if message.nil?
293
+ def self.start_worker(processor, boot_port)
294
+ ::Ractor.new(processor, boot_port) do |safe_processor, ready_port|
295
+ inbox = ::Ractor::Port.new
296
+ ready_port << inbox
297
+
298
+ CDC::Parallel::ProcessorPool.send(:run_worker_loop, safe_processor, inbox)
299
+ end
300
+ end
301
+ private_class_method :start_worker
114
302
 
115
- index, item, reply_port = message
303
+ def self.run_worker_loop(safe_processor, inbox)
304
+ loop do
305
+ message = inbox.receive
306
+ break if message.nil?
116
307
 
117
- response = begin
118
- CDC::Parallel::ResultCollector.worker_success(
119
- safe_processor.process(item)
120
- )
121
- rescue StandardError => e
122
- CDC::Parallel::ResultCollector.worker_failure(e)
123
- end
308
+ index, item, reply_port = message
309
+ response = worker_response(safe_processor, item)
124
310
 
125
- begin
126
- reply_port << [index, response]
127
- rescue Ractor::ClosedError
128
- # The caller may have timed out and closed the reply port.
129
- end
311
+ begin
312
+ reply_port << [index, response]
313
+ rescue Ractor::ClosedError
314
+ # The caller may have timed out and closed the reply port.
130
315
  end
131
316
  end
132
317
  end
318
+ private_class_method :run_worker_loop
133
319
 
134
- def next_worker
135
- worker = @workers[@next_worker]
320
+ def self.worker_response(safe_processor, item)
321
+ CDC::Parallel::ResultCollector.worker_success(
322
+ safe_processor.process(item)
323
+ )
324
+ rescue StandardError => e
325
+ CDC::Parallel::ResultCollector.worker_failure(e)
326
+ end
327
+ private_class_method :worker_response
328
+
329
+ def next_worker_slot
330
+ slot = @slots[@next_worker]
136
331
 
137
332
  @next_worker += 1
138
- @next_worker = 0 if @next_worker >= @workers.length
333
+ @next_worker = 0 if @next_worker >= @slots.length
139
334
 
140
- worker
335
+ slot
141
336
  end
142
337
 
143
- def collect_results(reply_port, count)
338
+ def collect_results(reply_port, count, assignments = [])
144
339
  results = Array.new(count)
145
340
  return results.freeze if count.zero?
146
341
 
147
342
  if @configuration.timeout
148
- collect_results_with_timeout(reply_port, results)
343
+ collect_results_with_timeout(reply_port, results, assignments)
149
344
  else
150
- collect_results_without_timeout(reply_port, results)
345
+ collect_results_without_timeout(reply_port, results, assignments)
151
346
  end
152
347
  end
153
348
 
154
- def collect_results_without_timeout(reply_port, results)
349
+ def collect_results_without_timeout(reply_port, results, assignments = [])
155
350
  results.length.times do
156
351
  index, response = reply_port.receive
157
352
  results[index] = ResultCollector.normalize(response)
353
+ assignments[index]&.complete(index)
158
354
  end
159
355
 
160
356
  results.freeze
161
357
  end
162
358
 
163
- def collect_results_with_timeout(reply_port, results)
164
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @configuration.timeout
359
+ def collect_results_with_timeout(reply_port, results, assignments = [])
360
+ timeout = @configuration.timeout
361
+ return results.freeze unless timeout
362
+
363
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
165
364
 
166
365
  results.length.times do
167
366
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -169,6 +368,7 @@ module CDC
169
368
 
170
369
  index, response = ::Timeout.timeout(remaining, TimeoutError) { reply_port.receive }
171
370
  results[index] = ResultCollector.normalize(response)
371
+ assignments[index]&.complete(index)
172
372
  rescue TimeoutError
173
373
  return timeout_results(results)
174
374
  end
@@ -186,6 +386,271 @@ module CDC
186
386
  result || CDC::Core::ProcessorResult.failure(timeout_error)
187
387
  end.freeze
188
388
  end
389
+
390
+ # One supervised worker position in the pool.
391
+ #
392
+ # A slot keeps its identity while its underlying Ractor can be replaced
393
+ # after unexpected death. The slot also owns in-flight reply ports so a
394
+ # fatal worker exit can be reported back to callers instead of leaving
395
+ # them blocked forever.
396
+ #
397
+ # @api private
398
+ # rubocop:disable Metrics/ClassLength
399
+ class WorkerSlot
400
+ # Number of times this slot has booted a replacement worker.
401
+ #
402
+ # @return [Integer]
403
+ # @api private
404
+ attr_reader :respawns
405
+
406
+ # Create a supervised worker slot.
407
+ #
408
+ # @param index [Integer]
409
+ # Stable slot index inside the owning pool.
410
+ # @param processor [CDC::Core::Processor]
411
+ # Shareable processor instance used by the worker.
412
+ # @param supervision [Boolean]
413
+ # Whether unexpected worker death should trigger respawn.
414
+ # @param max_respawns [Integer]
415
+ # Maximum crash count inside the respawn window before cooldown.
416
+ # @param respawn_window [Numeric]
417
+ # Sliding crash-loop window in seconds.
418
+ # @param respawn_cooldown [Numeric]
419
+ # Cooldown duration in seconds after repeated crashes.
420
+ # @return [void]
421
+ # @api private
422
+ def initialize(index:, processor:, supervision:, max_respawns:, respawn_window:, respawn_cooldown:)
423
+ @index = index
424
+ @processor = processor
425
+ @supervision = supervision
426
+ @max_respawns = Integer(max_respawns)
427
+ @respawn_window = Float(respawn_window)
428
+ @respawn_cooldown = Float(respawn_cooldown)
429
+ @lock = Mutex.new
430
+ @inflight = {}
431
+ @crashes = []
432
+ @respawns = 0
433
+ @shutdown = false
434
+ @degraded_until = nil
435
+ boot!
436
+ @supervisor_thread = Thread.new { supervise }
437
+ end
438
+
439
+ # Return the current worker Ractor.
440
+ #
441
+ # @return [Ractor]
442
+ # @api private
443
+ def worker
444
+ @lock.synchronize { @worker }
445
+ end
446
+
447
+ # Return the current worker inbox port.
448
+ #
449
+ # @return [Ractor::Port]
450
+ # @api private
451
+ def inbox
452
+ @lock.synchronize { @inbox }
453
+ end
454
+
455
+ # Send one indexed work item to the current worker.
456
+ #
457
+ # If the slot is cooling down or the worker inbox is already closed, a
458
+ # serialized failure response is sent to the caller reply port.
459
+ #
460
+ # @param index [Integer]
461
+ # @param item [Object]
462
+ # @param reply_port [Ractor::Port]
463
+ # @return [void]
464
+ # @api private
465
+ def send_work(index, item, reply_port)
466
+ target = nil
467
+ immediate_failure = nil
468
+
469
+ @lock.synchronize do
470
+ if degraded_locked?
471
+ immediate_failure = RuntimeError.new("worker slot #{@index} is cooling down after repeated crashes")
472
+ else
473
+ @inflight[index] = reply_port
474
+ target = @inbox
475
+ end
476
+ end
477
+
478
+ return send_failure(reply_port, index, require_failure(immediate_failure)) if immediate_failure
479
+
480
+ send_to_inbox(target, [index, item, reply_port])
481
+ rescue Ractor::ClosedError => e
482
+ complete(index)
483
+ send_failure(reply_port, index, e)
484
+ end
485
+
486
+ # Mark an in-flight work item as completed.
487
+ #
488
+ # @param index [Integer]
489
+ # @return [void]
490
+ # @api private
491
+ def complete(index)
492
+ @lock.synchronize { @inflight.delete(index) }
493
+ end
494
+
495
+ # Return whether this slot is currently in crash-loop cooldown.
496
+ #
497
+ # @return [Boolean]
498
+ # @api private
499
+ def degraded?
500
+ @lock.synchronize { degraded_locked? }
501
+ end
502
+
503
+ # Return whether this slot is degraded while the caller holds the lock.
504
+ #
505
+ # @return [Boolean]
506
+ # @api private
507
+ def degraded_locked?
508
+ degraded_until = @degraded_until
509
+ return false unless degraded_until
510
+
511
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= degraded_until
512
+ @degraded_until = nil
513
+ false
514
+ else
515
+ true
516
+ end
517
+ end
518
+
519
+ # Request worker shutdown and prevent future respawns.
520
+ #
521
+ # @return [void]
522
+ # @api private
523
+ def shutdown
524
+ target = nil
525
+ @lock.synchronize do
526
+ @shutdown = true
527
+ target = @inbox
528
+ end
529
+
530
+ send_to_inbox(target, nil)
531
+ rescue Ractor::ClosedError
532
+ nil
533
+ end
534
+
535
+ # Return a non-nil failure object for Steep and runtime dispatch.
536
+ #
537
+ # @param failure [Exception, nil]
538
+ # @return [Exception]
539
+ # @raise [RuntimeError] if no failure was supplied
540
+ # @api private
541
+ def require_failure(failure)
542
+ raise "worker failure unavailable" if failure.nil?
543
+
544
+ failure
545
+ end
546
+
547
+ # Send a message to a worker inbox.
548
+ #
549
+ # Ractor::Port supports both #send and #<<. Some tests replace the inbox
550
+ # with a small sentinel object that only implements #send so the closed
551
+ # inbox path can be exercised without relying on a live port. Keep this
552
+ # helper deliberately typed as an inbox boundary rather than forcing every
553
+ # caller to narrow the test double shape.
554
+ #
555
+ # @param inbox [#send, nil]
556
+ # @param message [Object]
557
+ # @return [void]
558
+ # @raise [Ractor::ClosedError] when the worker inbox has already closed
559
+ # @api private
560
+ def send_to_inbox(inbox, message)
561
+ raise Ractor::ClosedError, "worker inbox closed" if inbox.nil?
562
+
563
+ inbox.send(message)
564
+ nil
565
+ end
566
+
567
+ # Wait for the supervisor thread to finish.
568
+ #
569
+ # @return [Thread]
570
+ # @api private
571
+ def join
572
+ @supervisor_thread.join
573
+ end
574
+
575
+ private
576
+
577
+ def boot!
578
+ boot_port = ::Ractor::Port.new
579
+ worker = CDC::Parallel::ProcessorPool.send(:start_worker, @processor, boot_port)
580
+ inbox = boot_port.receive
581
+
582
+ @lock.synchronize do
583
+ @worker = worker
584
+ @inbox = inbox
585
+ end
586
+ ensure
587
+ boot_port&.close
588
+ end
589
+
590
+ def supervise
591
+ loop do
592
+ current_worker = worker
593
+ cause = wait_for_worker_death(current_worker)
594
+ break if shutting_down?
595
+
596
+ fail_inflight(cause)
597
+ break unless @supervision
598
+
599
+ cool_down_if_needed
600
+ break if shutting_down?
601
+
602
+ boot!
603
+ @lock.synchronize { @respawns += 1 }
604
+ end
605
+ end
606
+
607
+ def wait_for_worker_death(current_worker)
608
+ current_worker.value
609
+ RuntimeError.new("worker slot #{@index} exited unexpectedly")
610
+ rescue Ractor::Error => e
611
+ cause = e.respond_to?(:cause) ? e.cause : nil
612
+ cause.is_a?(Exception) ? cause : e
613
+ end
614
+
615
+ def fail_inflight(cause)
616
+ pending = {} # : Hash[Integer, Ractor::Port]
617
+ @lock.synchronize do
618
+ pending = @inflight.dup
619
+ @inflight.clear
620
+ end
621
+
622
+ pending.each { |index, reply_port| send_failure(reply_port, index, cause) }
623
+ end
624
+
625
+ def send_failure(reply_port, index, error)
626
+ reply_port << [index, ResultCollector.worker_failure(error)]
627
+ rescue Ractor::ClosedError
628
+ nil
629
+ end
630
+
631
+ def cool_down_if_needed
632
+ return unless crash_loop?
633
+
634
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @respawn_cooldown
635
+ @lock.synchronize { @degraded_until = deadline }
636
+ sleep @respawn_cooldown
637
+ @lock.synchronize { @degraded_until = nil }
638
+ end
639
+
640
+ def crash_loop?
641
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
642
+ @lock.synchronize do
643
+ @crashes = @crashes.select { |timestamp| now - timestamp <= @respawn_window }
644
+ @crashes << now
645
+ @crashes.length > @max_respawns
646
+ end
647
+ end
648
+
649
+ def shutting_down?
650
+ @lock.synchronize { @shutdown }
651
+ end
652
+ end
653
+ # rubocop:enable Metrics/ClassLength
189
654
  end
190
655
  end
191
656
  end