cdc-parallel 0.2.3 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 727373877c3c90e65e65ec4bbb5f5312b7e0d15d879af54402fa6752ac730aa0
4
- data.tar.gz: cd2701bf5e93f5ef825f4b2e95a015dfbbb214bb14cb3f095a65d499aeaf9f28
3
+ metadata.gz: e6e397f5b9c12042cf5521d0cc689604560c0a4180a9a53911915ebc38d31e27
4
+ data.tar.gz: a7736d3c1958ca24183939c4221fa9812a558fab0daab814988d2ba225afc4aa
5
5
  SHA512:
6
- metadata.gz: 33e6152dfaf7fdeda19853229519f2154b6ec39cb87b3c074dea0658e94eb65156d11d8e7ce11d0c3560dae678fe196dc1cc99a4f6fb4c6d712f8b84be7ad274
7
- data.tar.gz: 9999fdf8a4e05694f506b1a4f6e6db9679bc0da09d47b8c67513959d8467ead0fce0b26245b158c3101c8467110ed29af2aefeb4bc13c27d95ac76e38920e449
6
+ metadata.gz: 75fbe128470e921d71ccb21f98969751379bf63a2b2fc8e1fae9fb45d5557ce281a025e6c67e4a02a3438300380671d76812535c8fe9b271225b7356605e8b6f
7
+ data.tar.gz: 46960241a8cdc582a26c63c6725e2bae4cce316c3206019e03c3123ed178b891206e30a883b2932e5984f8a03822b640dea6f7672ff54747d77fbc570438be3d
data/CHANGELOG.md CHANGED
@@ -6,7 +6,18 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
6
6
 
7
7
  ## Unreleased
8
8
 
9
- No unreleased changes.
9
+ ## [0.2.4] - 2026-06-28
10
+
11
+ ### Fixed
12
+
13
+ - Started `Runtime` processors before internal pools shareablize the processor object graph.
14
+ - Forwarded `Runtime` supervisor tuning options to both event and transaction pools.
15
+
16
+ ### Added
17
+
18
+ - Added fatal-worker supervisor regression tests for respawn, degraded cooldown, and shutdown behavior.
19
+ - Added runtime regression tests for lifecycle ordering and supervisor option forwarding.
20
+ - Updated RBS signatures and supervisor plan documentation for the new runtime contract.
10
21
 
11
22
  ## [0.2.3] - 2026-06-03
12
23
 
@@ -2,6 +2,10 @@
2
2
 
3
3
  module CDC
4
4
  module Parallel
5
+ # :nodoc:
6
+ ConfigurationData = Data.define(:size, :timeout)
7
+ private_constant :ConfigurationData
8
+
5
9
  # Immutable configuration shared by cdc-parallel runtime objects.
6
10
  #
7
11
  # `Configuration` validates worker sizing and timeout values at construction
@@ -22,7 +26,7 @@ module CDC
22
26
  # @!attribute [r] timeout
23
27
  # @return [Numeric, nil] Optional wait timeout in seconds.
24
28
  # @api public
25
- class Configuration < Data.define(:size, :timeout)
29
+ class Configuration < ConfigurationData
26
30
  # Create a validated runtime configuration.
27
31
  #
28
32
  # @param size [Integer]
@@ -109,22 +109,70 @@ module CDC
109
109
  # @raise [ArgumentError]
110
110
  # Raised by {Configuration} when `size` or `timeout` is invalid.
111
111
  # @return [void]
112
- 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
+ )
113
138
  validate_processor!(processor)
114
139
 
140
+ processor.start if manage_lifecycle
115
141
  @processor = ::Ractor.make_shareable(processor)
142
+ @manage_lifecycle = manage_lifecycle
116
143
  @configuration = Configuration.new(size:, timeout:)
117
- booted_workers = Array.new(@configuration.size) do
118
- build_worker(@processor)
119
- end
120
-
121
- @workers = booted_workers.map(&:first).freeze
122
- @worker_inboxes = booted_workers.map(&:last).freeze
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
+ )
153
+ end.freeze
154
+ @workers = @slots.map(&:worker).freeze
155
+ @worker_inboxes = @slots.map(&:inbox).freeze
123
156
 
124
157
  @next_worker = 0
125
158
  @dispatch_mutex = Mutex.new
126
159
  @shutdown = false
127
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
128
176
 
129
177
  # Process one work item synchronously.
130
178
  #
@@ -161,9 +209,9 @@ module CDC
161
209
  work_items = items.map { |item| ::Ractor.make_shareable(item) }
162
210
  reply_port = ::Ractor::Port.new
163
211
 
164
- dispatch(work_items, reply_port)
212
+ assignments = dispatch(work_items, reply_port)
165
213
 
166
- collect_results(reply_port, work_items.length)
214
+ collect_results(reply_port, work_items.length, assignments).compact.freeze
167
215
  ensure
168
216
  reply_port&.close
169
217
  end
@@ -184,6 +232,10 @@ module CDC
184
232
  end
185
233
 
186
234
  wait_for_workers
235
+ return unless @manage_lifecycle
236
+
237
+ @processor.flush
238
+ @processor.stop
187
239
  end
188
240
 
189
241
  private
@@ -192,25 +244,25 @@ module CDC
192
244
  @dispatch_mutex.synchronize do
193
245
  raise ShutdownError, "processor pool has been shut down" if @shutdown
194
246
 
247
+ assignments = Array.new(work_items.length)
195
248
  work_items.each_with_index do |item, index|
196
- next_worker_inbox.send([index, item, reply_port])
249
+ slot = next_worker_slot
250
+ assignments[index] = slot
251
+ slot.send_work(index, item, reply_port)
197
252
  end
253
+ assignments
198
254
  end
199
255
  end
200
256
 
201
257
  def signal_workers
202
- @worker_inboxes.each do |inbox|
203
- inbox.send(nil)
204
- rescue Ractor::ClosedError
205
- # Already stopped.
206
- end
258
+ @slots.each(&:shutdown)
207
259
  end
208
260
 
209
261
  def wait_for_workers
210
262
  if @configuration.timeout
211
263
  wait_for_workers_with_timeout
212
264
  else
213
- @workers.each(&:join)
265
+ @slots.each(&:join)
214
266
  end
215
267
  end
216
268
 
@@ -220,11 +272,11 @@ module CDC
220
272
 
221
273
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
222
274
 
223
- @workers.each do |worker|
275
+ @slots.each do |slot|
224
276
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
225
277
  break unless remaining.positive?
226
278
 
227
- ::Timeout.timeout(remaining, TimeoutError) { worker.join }
279
+ ::Timeout.timeout(remaining, TimeoutError) { slot.join }
228
280
  rescue TimeoutError
229
281
  break
230
282
  end
@@ -238,16 +290,7 @@ module CDC
238
290
  "#{processor.class} must declare ractor_safe!"
239
291
  end
240
292
 
241
- def build_worker(processor)
242
- boot_port = ::Ractor::Port.new
243
- worker = start_worker(processor, boot_port)
244
-
245
- [worker, boot_port.receive]
246
- ensure
247
- boot_port.close
248
- end
249
-
250
- def start_worker(processor, boot_port)
293
+ def self.start_worker(processor, boot_port)
251
294
  ::Ractor.new(processor, boot_port) do |safe_processor, ready_port|
252
295
  inbox = ::Ractor::Port.new
253
296
  ready_port << inbox
@@ -255,6 +298,7 @@ module CDC
255
298
  CDC::Parallel::ProcessorPool.send(:run_worker_loop, safe_processor, inbox)
256
299
  end
257
300
  end
301
+ private_class_method :start_worker
258
302
 
259
303
  def self.run_worker_loop(safe_processor, inbox)
260
304
  loop do
@@ -282,36 +326,37 @@ module CDC
282
326
  end
283
327
  private_class_method :worker_response
284
328
 
285
- def next_worker_inbox
286
- inbox = @worker_inboxes[@next_worker]
329
+ def next_worker_slot
330
+ slot = @slots[@next_worker]
287
331
 
288
332
  @next_worker += 1
289
- @next_worker = 0 if @next_worker >= @worker_inboxes.length
333
+ @next_worker = 0 if @next_worker >= @slots.length
290
334
 
291
- inbox
335
+ slot
292
336
  end
293
337
 
294
- def collect_results(reply_port, count)
338
+ def collect_results(reply_port, count, assignments = [])
295
339
  results = Array.new(count)
296
340
  return results.freeze if count.zero?
297
341
 
298
342
  if @configuration.timeout
299
- collect_results_with_timeout(reply_port, results)
343
+ collect_results_with_timeout(reply_port, results, assignments)
300
344
  else
301
- collect_results_without_timeout(reply_port, results)
345
+ collect_results_without_timeout(reply_port, results, assignments)
302
346
  end
303
347
  end
304
348
 
305
- def collect_results_without_timeout(reply_port, results)
349
+ def collect_results_without_timeout(reply_port, results, assignments = [])
306
350
  results.length.times do
307
351
  index, response = reply_port.receive
308
352
  results[index] = ResultCollector.normalize(response)
353
+ assignments[index]&.complete(index)
309
354
  end
310
355
 
311
356
  results.freeze
312
357
  end
313
358
 
314
- def collect_results_with_timeout(reply_port, results)
359
+ def collect_results_with_timeout(reply_port, results, assignments = [])
315
360
  timeout = @configuration.timeout
316
361
  return results.freeze unless timeout
317
362
 
@@ -323,6 +368,7 @@ module CDC
323
368
 
324
369
  index, response = ::Timeout.timeout(remaining, TimeoutError) { reply_port.receive }
325
370
  results[index] = ResultCollector.normalize(response)
371
+ assignments[index]&.complete(index)
326
372
  rescue TimeoutError
327
373
  return timeout_results(results)
328
374
  end
@@ -340,6 +386,271 @@ module CDC
340
386
  result || CDC::Core::ProcessorResult.failure(timeout_error)
341
387
  end.freeze
342
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
343
654
  end
344
655
  end
345
656
  end
@@ -90,6 +90,12 @@ module CDC
90
90
  end
91
91
  end
92
92
 
93
+ # Return whether a value is a serialized worker failure payload.
94
+ #
95
+ # @param value [Object]
96
+ # Raw worker response.
97
+ # @return [Boolean]
98
+ # @api private
93
99
  def self.worker_failure?(value)
94
100
  value.is_a?(Hash) && value[:type] == FAILURE_MARKER
95
101
  end
@@ -48,14 +48,39 @@ module CDC
48
48
  # Number of worker Ractors per internal pool.
49
49
  # @param timeout [Numeric, nil]
50
50
  # Optional timeout in seconds for result collection and shutdown waits.
51
+ # @param supervision [Boolean]
52
+ # Whether worker Ractors should be respawned after unexpected death.
53
+ # @param max_respawns [Integer]
54
+ # Maximum crash count inside `respawn_window` before a worker slot enters
55
+ # cooldown.
56
+ # @param respawn_window [Numeric]
57
+ # Time window, in seconds, used by the crash-loop circuit breaker.
58
+ # @param respawn_cooldown [Numeric]
59
+ # Cooldown, in seconds, before a crash-looping slot is revived again.
51
60
  # @raise [UnsafeProcessorError]
52
61
  # Raised when the processor class has not declared `ractor_safe!`.
53
62
  # @raise [ArgumentError]
54
63
  # Raised when size or timeout is invalid.
55
64
  # @return [void]
56
- def initialize(processor:, size: Etc.nprocessors, timeout: nil)
57
- @processor_pool = ProcessorPool.new(processor:, size:, timeout:)
58
- @transaction_pool = TransactionPool.new(processor:, size:, timeout:)
65
+ def initialize(
66
+ processor:,
67
+ size: Etc.nprocessors,
68
+ timeout: nil,
69
+ supervision: true,
70
+ max_respawns: 3,
71
+ respawn_window: 60,
72
+ respawn_cooldown: 5
73
+ )
74
+ @processor = processor
75
+ @processor.start
76
+
77
+ pool_options = build_pool_options(
78
+ processor:, size:, timeout:, supervision:,
79
+ max_respawns:, respawn_window:, respawn_cooldown:
80
+ )
81
+
82
+ @processor_pool = ProcessorPool.new(**pool_options)
83
+ @transaction_pool = TransactionPool.new(**pool_options)
59
84
  @router = Router.new(processor_pool: @processor_pool, transaction_pool: @transaction_pool)
60
85
  @shutdown = false
61
86
  end
@@ -94,7 +119,9 @@ module CDC
94
119
  # Shut down all runtime resources.
95
120
  #
96
121
  # Shutdown is idempotent and cascades to the internal event and transaction
97
- # pools. After shutdown, {#process} raises {ShutdownError}.
122
+ # pools. The processor's `flush` and `stop` lifecycle hooks are called once
123
+ # after all pools have been drained. After shutdown, {#process} raises
124
+ # {ShutdownError}.
98
125
  #
99
126
  # @return [void]
100
127
  def shutdown
@@ -103,6 +130,46 @@ module CDC
103
130
  @shutdown = true
104
131
  @processor_pool.shutdown
105
132
  @transaction_pool.shutdown
133
+ @processor.flush
134
+ @processor.stop
135
+ end
136
+
137
+ private
138
+
139
+ # Build keyword arguments shared by the event and transaction pools.
140
+ #
141
+ # The runtime owns the processor lifecycle, so internal pools receive
142
+ # `manage_lifecycle: false` to avoid duplicate `start`, `flush`, or `stop`
143
+ # calls for the same processor instance.
144
+ #
145
+ # @param processor [CDC::Core::Processor]
146
+ # @param size [Integer]
147
+ # @param timeout [Numeric, nil]
148
+ # @param supervision [Boolean]
149
+ # @param max_respawns [Integer]
150
+ # @param respawn_window [Numeric]
151
+ # @param respawn_cooldown [Numeric]
152
+ # @return [Hash]
153
+ # @api private
154
+ def build_pool_options(
155
+ processor:,
156
+ size:,
157
+ timeout:,
158
+ supervision:,
159
+ max_respawns:,
160
+ respawn_window:,
161
+ respawn_cooldown:
162
+ )
163
+ {
164
+ processor:,
165
+ size:,
166
+ timeout:,
167
+ supervision:,
168
+ max_respawns:,
169
+ respawn_window:,
170
+ respawn_cooldown:,
171
+ manage_lifecycle: false
172
+ }
106
173
  end
107
174
  end
108
175
  end
@@ -38,11 +38,42 @@ module CDC
38
38
  # Number of worker Ractors in the underlying processor pool.
39
39
  # @param timeout [Numeric, nil]
40
40
  # Optional timeout in seconds for result collection and shutdown waits.
41
+ # @param supervision [Boolean]
42
+ # Whether worker Ractors should be respawned after unexpected death.
43
+ # @param max_respawns [Integer]
44
+ # Maximum crash count inside `respawn_window` before a worker slot enters
45
+ # cooldown.
46
+ # @param respawn_window [Numeric]
47
+ # Time window, in seconds, used by the crash-loop circuit breaker.
48
+ # @param respawn_cooldown [Numeric]
49
+ # Cooldown, in seconds, before a crash-looping slot is revived again.
50
+ # @param manage_lifecycle [Boolean]
51
+ # When `true` (default), the pool calls `processor.start` during
52
+ # initialization and `processor.flush` + `processor.stop` during shutdown.
53
+ # Set to `false` when a higher-level runtime owns the processor lifecycle.
41
54
  # @raise [UnsafeProcessorError]
42
55
  # Raised when the processor class has not declared `ractor_safe!`.
43
56
  # @return [void]
44
- def initialize(processor:, size: Etc.nprocessors, timeout: nil)
45
- @processor_pool = ProcessorPool.new(processor:, size:, timeout:)
57
+ def initialize(
58
+ processor:,
59
+ size: Etc.nprocessors,
60
+ timeout: nil,
61
+ supervision: true,
62
+ max_respawns: 3,
63
+ respawn_window: 60,
64
+ respawn_cooldown: 5,
65
+ manage_lifecycle: true
66
+ )
67
+ @processor_pool = ProcessorPool.new(
68
+ processor:,
69
+ size:,
70
+ timeout:,
71
+ supervision:,
72
+ max_respawns:,
73
+ respawn_window:,
74
+ respawn_cooldown:,
75
+ manage_lifecycle:
76
+ )
46
77
  end
47
78
 
48
79
  # Process all events inside a transaction envelope.
@@ -8,6 +8,6 @@ module CDC
8
8
  # the loaded runtime version at boot time.
9
9
  #
10
10
  # @return [String]
11
- VERSION = "0.2.3"
11
+ VERSION = "0.2.4"
12
12
  end
13
13
  end
data/lib/cdc/parallel.rb CHANGED
@@ -12,6 +12,12 @@ require_relative "parallel/transaction_pool"
12
12
  require_relative "parallel/router"
13
13
  require_relative "parallel/runtime"
14
14
 
15
+ # Namespace for Change Data Capture ecosystem components.
16
+ #
17
+ # The `cdc-parallel` gem adds the {CDC::Parallel} runtime namespace under
18
+ # this shared ecosystem root.
19
+ #
20
+ # @api public
15
21
  module CDC
16
22
  # Optional parallel Change Data Capture runtime for `cdc-core` processors.
17
23
  #
@@ -1,7 +1,13 @@
1
1
  module CDC
2
2
  module Parallel
3
+ # Private Data superclass used to keep YARD documentation stable for Configuration.
4
+ class ConfigurationData < Data
5
+ attr_reader size: Integer
6
+ attr_reader timeout: Numeric?
7
+ end
8
+
3
9
  # Immutable configuration for Ractor runtimes.
4
- class Configuration < Data
10
+ class Configuration < ConfigurationData
5
11
  attr_reader size: Integer
6
12
 
7
13
  attr_reader timeout: Numeric?
@@ -7,25 +7,46 @@ module CDC
7
7
  type worker_response = result | Hash[Symbol, untyped] | untyped
8
8
  type reply_port = Ractor::Port
9
9
  type worker_inbox = Ractor::Port
10
- @processor: CDC::Core::Processor
11
10
 
11
+ @processor: CDC::Core::Processor
12
12
  @configuration: Configuration
13
-
13
+ @slots: Array[WorkerSlot]
14
14
  @workers: Array[Ractor]
15
-
16
15
  @worker_inboxes: Array[worker_inbox]
17
-
18
16
  @next_worker: Integer
19
-
20
17
  @dispatch_mutex: Mutex
21
-
22
18
  @shutdown: bool
19
+ @manage_lifecycle: bool
23
20
 
24
21
  # @param processor [CDC::Core::Processor]
25
22
  # @param size [Integer]
26
- # @param timeout [Float, nil]
23
+ # @param timeout [Numeric, nil]
24
+ # @param supervision [Boolean]
25
+ # @param max_respawns [Integer]
26
+ # @param respawn_window [Numeric]
27
+ # @param respawn_cooldown [Numeric]
28
+ # @param manage_lifecycle [Boolean]
27
29
  # @return [void]
28
- def initialize: (processor: CDC::Core::Processor, ?size: Integer, ?timeout: Numeric?) -> void
30
+ def initialize: (
31
+ processor: CDC::Core::Processor,
32
+ ?size: Integer,
33
+ ?timeout: Numeric?,
34
+ ?supervision: bool,
35
+ ?max_respawns: Integer,
36
+ ?respawn_window: Numeric,
37
+ ?respawn_cooldown: Numeric,
38
+ ?manage_lifecycle: bool
39
+ ) -> void
40
+
41
+ # Total worker-slot respawns since pool boot.
42
+ #
43
+ # @return [Integer]
44
+ def respawns: () -> Integer
45
+
46
+ # Whether any worker slot is in crash-loop cooldown.
47
+ #
48
+ # @return [Boolean]
49
+ def degraded?: () -> bool
29
50
 
30
51
  # Process one work item synchronously.
31
52
  #
@@ -39,40 +60,90 @@ module CDC
39
60
  # @return [Array<CDC::Core::ProcessorResult>]
40
61
  def process_many: (Array[work_item] items) -> Array[result]
41
62
 
42
- # Shut down the pool.
63
+ # Shut down the pool and call processor lifecycle hooks if manage_lifecycle is true.
43
64
  #
44
65
  # @return [void]
45
- def shutdown: () -> nil?
66
+ def shutdown: () -> void
46
67
 
47
68
  private
48
69
 
70
+ def self.start_worker: (CDC::Core::Processor processor, Ractor::Port boot_port) -> Ractor
71
+
49
72
  def self.run_worker_loop: (untyped safe_processor, untyped inbox) -> nil
50
73
 
51
74
  def self.worker_response: (untyped safe_processor, work_item item) -> worker_response
52
75
 
53
- def dispatch: (Array[work_item] work_items, reply_port reply_port) -> Array[work_item]
76
+ def dispatch: (Array[work_item] work_items, reply_port reply_port) -> Array[WorkerSlot?]
54
77
 
55
- def signal_workers: () -> Array[worker_inbox]
78
+ def signal_workers: () -> void
56
79
 
57
- def wait_for_workers: () -> untyped
80
+ def wait_for_workers: () -> void
58
81
 
59
- def wait_for_workers_with_timeout: () -> untyped
82
+ def wait_for_workers_with_timeout: () -> void
60
83
 
61
- def validate_processor!: (CDC::Core::Processor processor) -> nil
84
+ def validate_processor!: (CDC::Core::Processor processor) -> void
62
85
 
63
- def build_worker: (CDC::Core::Processor processor) -> ([Ractor, worker_inbox] | Array[untyped])
86
+ def next_worker_slot: () -> WorkerSlot
64
87
 
65
- def start_worker: (CDC::Core::Processor processor, Ractor::Port boot_port) -> Ractor
88
+ def collect_results: (reply_port reply_port, Integer count, ?Array[WorkerSlot?] assignments) -> Array[result?]
66
89
 
67
- def next_worker_inbox: () -> worker_inbox
90
+ def collect_results_without_timeout: (reply_port reply_port, Array[result?] results, Array[WorkerSlot?] assignments) -> Array[result?]
68
91
 
69
- def collect_results: (reply_port reply_port, Integer count) -> Array[result]
70
-
71
- def collect_results_without_timeout: (reply_port reply_port, Array[result?] results) -> untyped
72
-
73
- def collect_results_with_timeout: (reply_port reply_port, Array[result?] results) -> untyped
92
+ def collect_results_with_timeout: (reply_port reply_port, Array[result?] results, Array[WorkerSlot?] assignments) -> Array[result?]
74
93
 
75
94
  def timeout_results: (Array[result?] results) -> Array[result]
95
+
96
+ # One supervised worker position in the pool.
97
+ class WorkerSlot
98
+ @index: Integer
99
+ @processor: CDC::Core::Processor
100
+ @supervision: bool
101
+ @max_respawns: Integer
102
+ @respawn_window: Float
103
+ @respawn_cooldown: Float
104
+ @lock: Mutex
105
+ @worker: Ractor
106
+ @inbox: Ractor::Port
107
+ @inflight: Hash[Integer, Ractor::Port]
108
+ @crashes: Array[Float]
109
+ @respawns: Integer
110
+ @shutdown: bool
111
+ @degraded_until: Float?
112
+ @supervisor_thread: Thread
113
+
114
+ attr_reader respawns: Integer
115
+
116
+ def initialize: (
117
+ index: Integer,
118
+ processor: CDC::Core::Processor,
119
+ supervision: bool,
120
+ max_respawns: Integer,
121
+ respawn_window: Numeric,
122
+ respawn_cooldown: Numeric
123
+ ) -> void
124
+
125
+ def worker: () -> Ractor
126
+ def inbox: () -> Ractor::Port
127
+ def send_work: (Integer index, untyped item, Ractor::Port reply_port) -> void
128
+ def complete: (Integer index) -> void
129
+ def degraded?: () -> bool
130
+ def shutdown: () -> void
131
+ def join: () -> void
132
+
133
+ private
134
+
135
+ def boot!: () -> void
136
+ def supervise: () -> void
137
+ def wait_for_worker_death: (Ractor current_worker) -> Exception
138
+ def fail_inflight: (Exception cause) -> void
139
+ def send_failure: (Ractor::Port reply_port, Integer index, Exception error) -> void
140
+ def require_failure: (Exception? failure) -> Exception
141
+ def send_to_inbox: (untyped inbox, untyped message) -> void
142
+ def cool_down_if_needed: () -> void
143
+ def crash_loop?: () -> bool
144
+ def shutting_down?: () -> bool
145
+ def degraded_locked?: () -> bool
146
+ end
76
147
  end
77
148
  end
78
149
  end
@@ -4,19 +4,25 @@ module CDC
4
4
  class Runtime
5
5
  type work_item = CDC::Core::ChangeEvent | CDC::Core::TransactionEnvelope
6
6
 
7
+ @processor: CDC::Core::Processor
7
8
  @processor_pool: ProcessorPool
8
-
9
9
  @transaction_pool: TransactionPool
10
-
11
10
  @router: Router
12
-
13
11
  @shutdown: bool
14
12
 
15
13
  # @param processor [CDC::Core::Processor]
16
14
  # @param size [Integer]
17
- # @param timeout [Float, nil]
15
+ # @param timeout [Numeric, nil]
18
16
  # @return [void]
19
- def initialize: (processor: CDC::Core::Processor, ?size: Integer, ?timeout: Numeric?) -> void
17
+ def initialize: (
18
+ processor: CDC::Core::Processor,
19
+ ?size: Integer,
20
+ ?timeout: Numeric?,
21
+ ?supervision: bool,
22
+ ?max_respawns: Integer,
23
+ ?respawn_window: Numeric,
24
+ ?respawn_cooldown: Numeric
25
+ ) -> void
20
26
 
21
27
  # Process a ChangeEvent or TransactionEnvelope.
22
28
  #
@@ -31,10 +37,32 @@ module CDC
31
37
  # @return [CDC::Core::ProcessorResult]
32
38
  def process_transaction: (CDC::Core::TransactionEnvelope transaction) -> CDC::Core::ProcessorResult
33
39
 
34
- # Shut down all runtime resources.
40
+ # Shut down all runtime resources and call processor lifecycle hooks.
35
41
  #
36
42
  # @return [void]
37
- def shutdown: () -> nil
43
+ def shutdown: () -> void
44
+
45
+ private
46
+
47
+ # Build keyword arguments shared by internal pools.
48
+ def build_pool_options: (
49
+ processor: CDC::Core::Processor,
50
+ size: Integer,
51
+ timeout: Numeric?,
52
+ supervision: bool,
53
+ max_respawns: Integer,
54
+ respawn_window: Numeric,
55
+ respawn_cooldown: Numeric
56
+ ) -> {
57
+ processor: CDC::Core::Processor,
58
+ size: Integer,
59
+ timeout: Numeric?,
60
+ supervision: bool,
61
+ max_respawns: Integer,
62
+ respawn_window: Numeric,
63
+ respawn_cooldown: Numeric,
64
+ manage_lifecycle: false
65
+ }
38
66
  end
39
67
  end
40
68
  end
@@ -6,8 +6,18 @@ module CDC
6
6
 
7
7
  # @param processor [CDC::Core::Processor]
8
8
  # @param size [Integer]
9
- # @param timeout [Float, nil]
10
- def initialize: (processor: CDC::Core::Processor, ?size: Integer, ?timeout: Numeric?) -> void
9
+ # @param timeout [Numeric, nil]
10
+ # @param manage_lifecycle [Boolean]
11
+ def initialize: (
12
+ processor: CDC::Core::Processor,
13
+ ?size: Integer,
14
+ ?timeout: Numeric?,
15
+ ?supervision: bool,
16
+ ?max_respawns: Integer,
17
+ ?respawn_window: Numeric,
18
+ ?respawn_cooldown: Numeric,
19
+ ?manage_lifecycle: bool
20
+ ) -> void
11
21
 
12
22
  # Process all events inside a transaction envelope.
13
23
  #
@@ -18,7 +28,7 @@ module CDC
18
28
  # Shut down worker resources.
19
29
  #
20
30
  # @return [void]
21
- def shutdown: () -> nil
31
+ def shutdown: () -> void
22
32
  end
23
33
  end
24
34
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cdc-parallel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ken C. Demanawa