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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -21
- data/README.md +54 -48
- data/lib/cdc/parallel/configuration.rb +33 -6
- data/lib/cdc/parallel/errors.rb +59 -5
- data/lib/cdc/parallel/processor_pool.rb +522 -57
- data/lib/cdc/parallel/result_collector.rb +49 -2
- data/lib/cdc/parallel/router.rb +26 -1
- data/lib/cdc/parallel/runtime.rb +135 -7
- data/lib/cdc/parallel/transaction_pool.rb +87 -5
- data/lib/cdc/parallel/version.rb +6 -1
- data/lib/cdc/parallel.rb +39 -1
- data/sig/cdc/parallel/configuration.rbs +14 -2
- data/sig/cdc/parallel/errors.rbs +7 -7
- data/sig/cdc/parallel/processor_pool.rbs +112 -24
- data/sig/cdc/parallel/result_collector.rbs +6 -4
- data/sig/cdc/parallel/router.rbs +7 -4
- data/sig/cdc/parallel/runtime.rbs +43 -12
- data/sig/cdc/parallel/transaction_pool.rbs +15 -5
- data/sig/cdc/parallel/version.rbs +1 -1
- metadata +5 -23
- data/sig/shims/cdc_core.rbs +0 -14
- data/sig/shims/data_define.rbs +0 -0
- data/sig/shims/etc.rbs +0 -3
- data/sig/shims/timeout.rbs +0 -3
|
@@ -2,97 +2,281 @@
|
|
|
2
2
|
|
|
3
3
|
module CDC
|
|
4
4
|
module Parallel
|
|
5
|
-
# Executes one Ractor-safe processor
|
|
5
|
+
# Executes one Ractor-safe `cdc-core` processor across a fixed set of
|
|
6
|
+
# pre-warmed Ractor workers.
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
22
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
227
|
+
@dispatch_mutex.synchronize do
|
|
228
|
+
return if @shutdown
|
|
63
229
|
|
|
64
|
-
|
|
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
|
|
73
|
-
@
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
@
|
|
265
|
+
@slots.each(&:join)
|
|
85
266
|
end
|
|
86
267
|
end
|
|
87
268
|
|
|
88
269
|
def wait_for_workers_with_timeout
|
|
89
|
-
|
|
270
|
+
timeout = @configuration.timeout
|
|
271
|
+
return unless timeout
|
|
272
|
+
|
|
273
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
90
274
|
|
|
91
|
-
@
|
|
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) {
|
|
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
|
|
110
|
-
::Ractor.new(processor) do |safe_processor|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
303
|
+
def self.run_worker_loop(safe_processor, inbox)
|
|
304
|
+
loop do
|
|
305
|
+
message = inbox.receive
|
|
306
|
+
break if message.nil?
|
|
116
307
|
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
135
|
-
|
|
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 >= @
|
|
333
|
+
@next_worker = 0 if @next_worker >= @slots.length
|
|
139
334
|
|
|
140
|
-
|
|
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
|
-
|
|
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
|