ractor-wrapper 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/.yardopts +1 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +79 -0
- data/DESIGN.md +1024 -0
- data/README.md +344 -57
- data/lib/ractor/wrapper/version.rb +1 -1
- data/lib/ractor/wrapper.rb +1041 -282
- metadata +6 -4
data/lib/ractor/wrapper.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
##
|
|
4
|
-
# See ruby-
|
|
4
|
+
# See https://docs.ruby-lang.org/en/4.0/language/ractor_md.html for info on
|
|
5
|
+
# Ractors.
|
|
5
6
|
#
|
|
6
7
|
class Ractor
|
|
7
8
|
##
|
|
@@ -98,7 +99,7 @@ class Ractor
|
|
|
98
99
|
#
|
|
99
100
|
# # Create a wrapper around the database. A SQLite3::Database object
|
|
100
101
|
# # cannot be moved between Ractors, so we configure the wrapper to run
|
|
101
|
-
# # in the current Ractor.
|
|
102
|
+
# # in the current Ractor. We can also configure it to run multiple
|
|
102
103
|
# # worker threads because the database object itself is thread-safe.
|
|
103
104
|
# wrapper = Ractor::Wrapper.new(db, use_current_ractor: true, threads: 2)
|
|
104
105
|
#
|
|
@@ -111,17 +112,16 @@ class Ractor
|
|
|
111
112
|
# rows = wrapper.stub.execute("select * from numbers")
|
|
112
113
|
#
|
|
113
114
|
# # Here, we start two Ractors, and pass the stub to each one. The
|
|
114
|
-
# # wrapper's
|
|
115
|
-
#
|
|
116
|
-
# r1 = Ractor.new(wrapper.stub) do |db_stub|
|
|
115
|
+
# # wrapper's worker threads will handle the requests concurrently.
|
|
116
|
+
# r1 = Ractor.new(wrapper.stub) do |stub|
|
|
117
117
|
# 5.times do
|
|
118
|
-
#
|
|
118
|
+
# stub.execute("select * from numbers")
|
|
119
119
|
# end
|
|
120
120
|
# :ok
|
|
121
121
|
# end
|
|
122
|
-
# r2 = Ractor.new(wrapper.stub) do |
|
|
122
|
+
# r2 = Ractor.new(wrapper.stub) do |stub|
|
|
123
123
|
# 5.times do
|
|
124
|
-
#
|
|
124
|
+
# stub.execute("select * from numbers")
|
|
125
125
|
# end
|
|
126
126
|
# :ok
|
|
127
127
|
# end
|
|
@@ -138,7 +138,7 @@ class Ractor
|
|
|
138
138
|
# # When running a wrapper with :use_current_ractor, you do not need to
|
|
139
139
|
# # recover the object, because it was never moved. The recover_object
|
|
140
140
|
# # method is not available.
|
|
141
|
-
# # db2 = wrapper.recover_object # <= raises Ractor::Error
|
|
141
|
+
# # db2 = wrapper.recover_object # <= raises Ractor::Wrapper::Error
|
|
142
142
|
#
|
|
143
143
|
# ## Features
|
|
144
144
|
#
|
|
@@ -149,6 +149,8 @@ class Ractor
|
|
|
149
149
|
# * Can be configured per method whether to copy or move arguments and
|
|
150
150
|
# return values.
|
|
151
151
|
# * Blocks can be run in the calling Ractor or in the object Ractor.
|
|
152
|
+
# * Blocks running in the calling Ractor can re-enter the wrapper, calling
|
|
153
|
+
# other methods on it without deadlocking.
|
|
152
154
|
# * Raises exceptions thrown by the method.
|
|
153
155
|
# * Can serialize method calls for non-thread-safe objects, or run methods
|
|
154
156
|
# concurrently in multiple worker threads for thread-safe objects.
|
|
@@ -170,8 +172,30 @@ class Ractor
|
|
|
170
172
|
# later unless they are configured to run "in place". In particular,
|
|
171
173
|
# using blocks as a syntax to define callbacks can generally not be done
|
|
172
174
|
# through a wrapper.
|
|
175
|
+
# * Re-entrant calls from a block are not safe if the wrapped method
|
|
176
|
+
# invoked the block from a nested Fiber (such as inside an Enumerator)
|
|
177
|
+
# or from a spawned Thread. Such re-entrant calls may deadlock.
|
|
173
178
|
#
|
|
174
179
|
class Wrapper
|
|
180
|
+
##
|
|
181
|
+
# Base class for errors raised by {Ractor::Wrapper}.
|
|
182
|
+
#
|
|
183
|
+
class Error < ::Ractor::Error; end
|
|
184
|
+
|
|
185
|
+
##
|
|
186
|
+
# Raised when a {Ractor::Wrapper} server has crashed unexpectedly. May
|
|
187
|
+
# also be raised in the calling Ractor when an in-flight method call is
|
|
188
|
+
# suspended at a block-yield point and the server (or its worker thread)
|
|
189
|
+
# crashes before the block result can be delivered.
|
|
190
|
+
#
|
|
191
|
+
class CrashedError < Error; end
|
|
192
|
+
|
|
193
|
+
##
|
|
194
|
+
# Raised when calling a method on a {Ractor::Wrapper} whose server has
|
|
195
|
+
# stopped and is no longer accepting calls.
|
|
196
|
+
#
|
|
197
|
+
class StoppedError < Error; end
|
|
198
|
+
|
|
175
199
|
##
|
|
176
200
|
# A stub that forwards calls to a wrapper.
|
|
177
201
|
#
|
|
@@ -206,234 +230,355 @@ class Ractor
|
|
|
206
230
|
end
|
|
207
231
|
|
|
208
232
|
##
|
|
209
|
-
#
|
|
210
|
-
#
|
|
233
|
+
# Configuration for a {Ractor::Wrapper}. An instance of this class is
|
|
234
|
+
# yielded by {Ractor::Wrapper#initialize} if a block is provided. Any
|
|
235
|
+
# settings made to the Configuration before the block returns take
|
|
236
|
+
# effect when the Wrapper is constructed.
|
|
211
237
|
#
|
|
212
|
-
class
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
@
|
|
221
|
-
@move_results = interpret_setting(move_results, move_data)
|
|
222
|
-
@move_block_arguments = interpret_setting(move_block_arguments, move_data)
|
|
223
|
-
@move_block_results = interpret_setting(move_block_results, move_data)
|
|
224
|
-
@execute_blocks_in_place = interpret_setting(execute_blocks_in_place, false)
|
|
225
|
-
freeze
|
|
238
|
+
class Configuration
|
|
239
|
+
##
|
|
240
|
+
# Set the name of the wrapper. This is shown in logging and is also
|
|
241
|
+
# used as the name of the wrapping Ractor.
|
|
242
|
+
#
|
|
243
|
+
# @param value [String, nil]
|
|
244
|
+
#
|
|
245
|
+
def name=(value)
|
|
246
|
+
@name = value ? value.to_s.freeze : nil
|
|
226
247
|
end
|
|
227
248
|
|
|
228
249
|
##
|
|
229
|
-
#
|
|
250
|
+
# Enable or disable internal debug logging.
|
|
230
251
|
#
|
|
231
|
-
|
|
232
|
-
|
|
252
|
+
# @param value [Boolean]
|
|
253
|
+
#
|
|
254
|
+
def enable_logging=(value)
|
|
255
|
+
@enable_logging = value ? true : false
|
|
233
256
|
end
|
|
234
257
|
|
|
235
258
|
##
|
|
236
|
-
#
|
|
259
|
+
# Set the number of worker threads. If the underlying object is
|
|
260
|
+
# thread-safe, a value of 2 or more allows concurrent calls. Leave at
|
|
261
|
+
# the default of 0 to handle calls sequentially without worker threads.
|
|
262
|
+
#
|
|
263
|
+
# The number of worker threads only needs to reflect the desired
|
|
264
|
+
# concurrency of independent calls. It does not need to be sized to the
|
|
265
|
+
# depth of re-entrant block calls, because suspended methods do not
|
|
266
|
+
# occupy a worker thread while waiting for a block to complete.
|
|
237
267
|
#
|
|
238
|
-
|
|
239
|
-
|
|
268
|
+
# @param value [Integer]
|
|
269
|
+
#
|
|
270
|
+
def threads=(value)
|
|
271
|
+
value = value.to_i
|
|
272
|
+
value = 0 if value.negative?
|
|
273
|
+
@threads = value
|
|
240
274
|
end
|
|
241
275
|
|
|
242
276
|
##
|
|
243
|
-
#
|
|
277
|
+
# If set to true, the wrapper server runs as Thread(s) inside the
|
|
278
|
+
# current Ractor rather than spawning a new isolated Ractor. Use this
|
|
279
|
+
# for objects that cannot be moved between Ractors.
|
|
280
|
+
#
|
|
281
|
+
# @param value [Boolean]
|
|
244
282
|
#
|
|
245
|
-
def
|
|
246
|
-
@
|
|
283
|
+
def use_current_ractor=(value)
|
|
284
|
+
@use_current_ractor = value ? true : false
|
|
247
285
|
end
|
|
248
286
|
|
|
249
287
|
##
|
|
250
|
-
#
|
|
288
|
+
# Configure how argument and return values are communicated for the given
|
|
289
|
+
# method.
|
|
290
|
+
#
|
|
291
|
+
# In general, the following values are recognized for the data-moving
|
|
292
|
+
# settings:
|
|
293
|
+
#
|
|
294
|
+
# * `:copy` - Method arguments or return values that are not shareable,
|
|
295
|
+
# are *deep copied* when communicated between the caller and the object.
|
|
296
|
+
# * `:move` - Method arguments or return values that are not shareable,
|
|
297
|
+
# are *moved* when communicated between the caller and the object. This
|
|
298
|
+
# means they are no longer available to the source; that is, the caller
|
|
299
|
+
# can no longer access objects that were moved to method arguments, and
|
|
300
|
+
# the wrapped object can no longer access objects that were used as
|
|
301
|
+
# return values.
|
|
302
|
+
# * `:void` - This option is available for return values and block
|
|
303
|
+
# results. It disables return values for the given method, and is
|
|
304
|
+
# intended to avoid copying or moving objects that are not intended to
|
|
305
|
+
# be return values. The recipient will receive `nil`.
|
|
306
|
+
#
|
|
307
|
+
# The following settings are recognized for the `block_environment`
|
|
308
|
+
# setting:
|
|
309
|
+
#
|
|
310
|
+
# * `:caller` - Blocks are executed in the caller's context. This means
|
|
311
|
+
# the wrapper sends a message back to the caller to execute the block
|
|
312
|
+
# in its original context. This means the block will have access to its
|
|
313
|
+
# lexical scope and any other data available to the calling Ractor.
|
|
314
|
+
# Such blocks may safely re-enter the wrapper to invoke other methods
|
|
315
|
+
# on it, *unless* the wrapped method invoked the block from a nested
|
|
316
|
+
# Fiber (such as inside an Enumerator) or a spawned Thread, in which
|
|
317
|
+
# case re-entrant calls from the block may deadlock. If you need to
|
|
318
|
+
# invoke the block from a nested Fiber or a spawned Thread and the
|
|
319
|
+
# block does not need re-entrancy, prefer the `:wrapped` setting.
|
|
320
|
+
# * `:wrapped` - Blocks are executed directly in the wrapped object's
|
|
321
|
+
# context. This does not require any communication, but it means the
|
|
322
|
+
# block is removed from the caller's environment and does not have
|
|
323
|
+
# access to the caller's lexical scope or Ractor-accessible data.
|
|
251
324
|
#
|
|
252
|
-
|
|
253
|
-
|
|
325
|
+
# All settings are optional. If not provided, they will fall back to a
|
|
326
|
+
# default. If you are configuring a particular method, by specifying the
|
|
327
|
+
# `method_name` argument, any unspecified setting will fall back to the
|
|
328
|
+
# method default settings (which you can set by omitting the method name.)
|
|
329
|
+
# If you are configuring the method default settings, by omitting the
|
|
330
|
+
# `method_name` argument, unspecified settings will fall back to `:copy`
|
|
331
|
+
# for the data movement settings, and `:caller` for the
|
|
332
|
+
# `block_environment` setting.
|
|
333
|
+
#
|
|
334
|
+
# @param method_name [Symbol,nil] The name of the method being configured,
|
|
335
|
+
# or `nil` to set defaults for all methods not configured explicitly.
|
|
336
|
+
# @param arguments [:move,:copy] How to communicate method arguments.
|
|
337
|
+
# @param results [:move,:copy,:void] How to communicate method return
|
|
338
|
+
# values.
|
|
339
|
+
# @param block_arguments [:move,:copy] How to communicate block arguments.
|
|
340
|
+
# @param block_results [:move,:copy,:void] How to communicate block
|
|
341
|
+
# result values.
|
|
342
|
+
# @param block_environment [:caller,:wrapped] How to execute blocks, and
|
|
343
|
+
# what scope blocks have access to.
|
|
344
|
+
#
|
|
345
|
+
def configure_method(method_name = nil,
|
|
346
|
+
arguments: nil,
|
|
347
|
+
results: nil,
|
|
348
|
+
block_arguments: nil,
|
|
349
|
+
block_results: nil,
|
|
350
|
+
block_environment: nil)
|
|
351
|
+
method_name = method_name.to_sym unless method_name.nil?
|
|
352
|
+
@method_settings[method_name] =
|
|
353
|
+
MethodSettings.new(arguments: arguments,
|
|
354
|
+
results: results,
|
|
355
|
+
block_arguments: block_arguments,
|
|
356
|
+
block_results: block_results,
|
|
357
|
+
block_environment: block_environment)
|
|
358
|
+
self
|
|
254
359
|
end
|
|
255
360
|
|
|
256
361
|
##
|
|
257
|
-
# @
|
|
362
|
+
# @private
|
|
363
|
+
# Return the name of the wrapper.
|
|
258
364
|
#
|
|
259
|
-
|
|
260
|
-
|
|
365
|
+
# @return [String, nil]
|
|
366
|
+
#
|
|
367
|
+
attr_reader :name
|
|
368
|
+
|
|
369
|
+
##
|
|
370
|
+
# @private
|
|
371
|
+
# Return whether logging is enabled.
|
|
372
|
+
#
|
|
373
|
+
# @return [Boolean]
|
|
374
|
+
#
|
|
375
|
+
attr_reader :enable_logging
|
|
376
|
+
|
|
377
|
+
##
|
|
378
|
+
# @private
|
|
379
|
+
# Return the number of worker threads.
|
|
380
|
+
#
|
|
381
|
+
# @return [Integer]
|
|
382
|
+
#
|
|
383
|
+
attr_reader :threads
|
|
384
|
+
|
|
385
|
+
##
|
|
386
|
+
# @private
|
|
387
|
+
# Return whether the wrapper runs in the current Ractor.
|
|
388
|
+
#
|
|
389
|
+
# @return [Boolean]
|
|
390
|
+
#
|
|
391
|
+
attr_reader :use_current_ractor
|
|
392
|
+
|
|
393
|
+
##
|
|
394
|
+
# @private
|
|
395
|
+
# Resolve the method settings by filling in the defaults for all fields
|
|
396
|
+
# not explicitly set, and return the final settings keyed by method name.
|
|
397
|
+
# The `nil` key will contain defaults for method names not explicitly
|
|
398
|
+
# configured. This hash will be frozen and shareable.
|
|
399
|
+
#
|
|
400
|
+
# @return [Hash{(Symbol,nil)=>MethodSettings}]
|
|
401
|
+
#
|
|
402
|
+
def final_method_settings
|
|
403
|
+
fallback = MethodSettings.new(arguments: :copy, results: :copy,
|
|
404
|
+
block_arguments: :copy, block_results: :copy,
|
|
405
|
+
block_environment: :caller)
|
|
406
|
+
defaults = MethodSettings.with_fallback(@method_settings[nil], fallback)
|
|
407
|
+
results = {nil => defaults}
|
|
408
|
+
@method_settings.each do |name, settings|
|
|
409
|
+
next if name.nil?
|
|
410
|
+
results[name] = MethodSettings.with_fallback(settings, defaults)
|
|
411
|
+
end
|
|
412
|
+
results.freeze
|
|
261
413
|
end
|
|
262
414
|
|
|
263
|
-
|
|
415
|
+
##
|
|
416
|
+
# @private
|
|
417
|
+
# Create an empty configuration.
|
|
418
|
+
#
|
|
419
|
+
def initialize
|
|
420
|
+
@method_settings = {}
|
|
421
|
+
configure_method(arguments: nil,
|
|
422
|
+
results: nil,
|
|
423
|
+
block_arguments: nil,
|
|
424
|
+
block_results: nil,
|
|
425
|
+
block_environment: nil)
|
|
426
|
+
end
|
|
427
|
+
end
|
|
264
428
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
429
|
+
##
|
|
430
|
+
# Settings for a method call. Specifies how a method's arguments and
|
|
431
|
+
# return value are communicated (i.e. copy or move semantics.)
|
|
432
|
+
#
|
|
433
|
+
class MethodSettings
|
|
434
|
+
# @private
|
|
435
|
+
def initialize(arguments: nil,
|
|
436
|
+
results: nil,
|
|
437
|
+
block_arguments: nil,
|
|
438
|
+
block_results: nil,
|
|
439
|
+
block_environment: nil)
|
|
440
|
+
unless [nil, :copy, :move].include?(arguments)
|
|
441
|
+
raise ::ArgumentError, "Unknown `arguments`: #{arguments.inspect} (must be :copy or :move)"
|
|
442
|
+
end
|
|
443
|
+
unless [nil, :copy, :move, :void].include?(results)
|
|
444
|
+
raise ::ArgumentError, "Unknown `results`: #{results.inspect} (must be :copy, :move, or :void)"
|
|
445
|
+
end
|
|
446
|
+
unless [nil, :copy, :move].include?(block_arguments)
|
|
447
|
+
raise ::ArgumentError, "Unknown `block_arguments`: #{block_arguments.inspect} (must be :copy or :move)"
|
|
448
|
+
end
|
|
449
|
+
unless [nil, :copy, :move, :void].include?(block_results)
|
|
450
|
+
raise ::ArgumentError, "Unknown `block_results`: #{block_results.inspect} (must be :copy, :move, or :void)"
|
|
270
451
|
end
|
|
452
|
+
unless [nil, :caller, :wrapped].include?(block_environment)
|
|
453
|
+
raise ::ArgumentError,
|
|
454
|
+
"Unknown `block_environment`: #{block_environment.inspect} (must be :caller or :wrapped)"
|
|
455
|
+
end
|
|
456
|
+
@arguments = arguments
|
|
457
|
+
@results = results
|
|
458
|
+
@block_arguments = block_arguments
|
|
459
|
+
@block_results = block_results
|
|
460
|
+
@block_environment = block_environment
|
|
461
|
+
freeze
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
##
|
|
465
|
+
# @return [:copy,:move] How to communicate method arguments
|
|
466
|
+
# @return [nil] if not set (will not happen in final settings)
|
|
467
|
+
#
|
|
468
|
+
attr_reader :arguments
|
|
469
|
+
|
|
470
|
+
##
|
|
471
|
+
# @return [:copy,:move,:void] How to communicate method return values
|
|
472
|
+
# @return [nil] if not set (will not happen in final settings)
|
|
473
|
+
#
|
|
474
|
+
attr_reader :results
|
|
475
|
+
|
|
476
|
+
##
|
|
477
|
+
# @return [:copy,:move] How to communicate arguments to a block
|
|
478
|
+
# @return [nil] if not set (will not happen in final settings)
|
|
479
|
+
#
|
|
480
|
+
attr_reader :block_arguments
|
|
481
|
+
|
|
482
|
+
##
|
|
483
|
+
# @return [:copy,:move,:void] How to communicate block results
|
|
484
|
+
# @return [nil] if not set (will not happen in final settings)
|
|
485
|
+
#
|
|
486
|
+
attr_reader :block_results
|
|
487
|
+
|
|
488
|
+
##
|
|
489
|
+
# @return [:caller,:wrapped] What environment blocks execute in
|
|
490
|
+
# @return [nil] if not set (will not happen in final settings)
|
|
491
|
+
#
|
|
492
|
+
attr_reader :block_environment
|
|
493
|
+
|
|
494
|
+
# @private
|
|
495
|
+
def self.with_fallback(settings, fallback)
|
|
496
|
+
new(
|
|
497
|
+
arguments: settings.arguments || fallback.arguments,
|
|
498
|
+
results: settings.results || fallback.results,
|
|
499
|
+
block_arguments: settings.block_arguments || fallback.block_arguments,
|
|
500
|
+
block_results: settings.block_results || fallback.block_results,
|
|
501
|
+
block_environment: settings.block_environment || fallback.block_environment
|
|
502
|
+
)
|
|
271
503
|
end
|
|
272
504
|
end
|
|
273
505
|
|
|
274
506
|
##
|
|
275
507
|
# Create a wrapper around the given object.
|
|
276
508
|
#
|
|
277
|
-
# If you pass an optional block,
|
|
278
|
-
#
|
|
279
|
-
# particular, method
|
|
280
|
-
#
|
|
509
|
+
# If you pass an optional block, a {Ractor::Wrapper::Configuration} object
|
|
510
|
+
# will be yielded to it, allowing additional configuration before the wrapper
|
|
511
|
+
# starts. In particular, per-method configuration must be set in this block.
|
|
512
|
+
# Block-provided settings override keyword arguments.
|
|
513
|
+
#
|
|
514
|
+
# See {Configuration} for more information about the method communication
|
|
515
|
+
# and block settings.
|
|
281
516
|
#
|
|
282
517
|
# @param object [Object] The non-shareable object to wrap.
|
|
283
518
|
# @param use_current_ractor [boolean] If true, the wrapper is run in a
|
|
284
519
|
# thread in the current Ractor instead of spawning a new Ractor (the
|
|
285
520
|
# default behavior). This option can be used if the wrapped object
|
|
286
|
-
# cannot be moved or must run in the main Ractor.
|
|
287
|
-
#
|
|
521
|
+
# cannot be moved or must run in the main Ractor. Can also be set via
|
|
522
|
+
# the configuration block.
|
|
523
|
+
# @param name [String] A name for this wrapper. Used during logging. Can
|
|
524
|
+
# also be set via the configuration block. Defaults to the object_id.
|
|
288
525
|
# @param threads [Integer] The number of worker threads to run.
|
|
289
526
|
# Defaults to 0, which causes the wrapper to run sequentially without
|
|
290
|
-
# spawning workers.
|
|
291
|
-
#
|
|
292
|
-
#
|
|
293
|
-
#
|
|
294
|
-
# @param
|
|
295
|
-
#
|
|
296
|
-
#
|
|
297
|
-
#
|
|
298
|
-
#
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
# @param
|
|
303
|
-
#
|
|
304
|
-
# `:
|
|
305
|
-
# @param execute_blocks_in_place [boolean] If true, blocks passed to
|
|
306
|
-
# methods are made shareable and passed into the wrapper to be executed
|
|
307
|
-
# in the wrapped environment. If false (the default), blocks are
|
|
308
|
-
# replaced by a proc that passes messages back out to the caller and
|
|
309
|
-
# executes the block in the caller's environment.
|
|
527
|
+
# spawning workers. Sized to the desired concurrency of independent
|
|
528
|
+
# calls; does not need to account for re-entrant block calls, since
|
|
529
|
+
# suspended methods do not occupy a worker thread while waiting for a
|
|
530
|
+
# block to complete. Can also be set via the configuration block.
|
|
531
|
+
# @param arguments [:move,:copy] How to communicate method arguments by
|
|
532
|
+
# default. If not specified, defaults to `:copy`.
|
|
533
|
+
# @param results [:move,:copy,:void] How to communicate method return
|
|
534
|
+
# values by default. If not specified, defaults to `:copy`.
|
|
535
|
+
# @param block_arguments [:move,:copy] How to communicate block arguments
|
|
536
|
+
# by default. If not specified, defaults to `:copy`.
|
|
537
|
+
# @param block_results [:move,:copy,:void] How to communicate block result
|
|
538
|
+
# values by default. If not specified, defaults to `:copy`.
|
|
539
|
+
# @param block_environment [:caller,:wrapped] How to execute blocks, and
|
|
540
|
+
# what scope blocks have access to. If not specified, defaults to
|
|
541
|
+
# `:caller`.
|
|
310
542
|
# @param enable_logging [boolean] Set to true to enable logging. Default
|
|
311
|
-
# is false.
|
|
543
|
+
# is false. Can also be set via the configuration block.
|
|
544
|
+
# @yield [config] An optional configuration block.
|
|
545
|
+
# @yieldparam config [Ractor::Wrapper::Configuration]
|
|
312
546
|
#
|
|
313
547
|
def initialize(object,
|
|
314
548
|
use_current_ractor: false,
|
|
315
549
|
name: nil,
|
|
316
550
|
threads: 0,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
execute_blocks_in_place: nil,
|
|
551
|
+
arguments: nil,
|
|
552
|
+
results: nil,
|
|
553
|
+
block_arguments: nil,
|
|
554
|
+
block_results: nil,
|
|
555
|
+
block_environment: nil,
|
|
323
556
|
enable_logging: false)
|
|
324
557
|
raise ::Ractor::MovedError, "cannot wrap a moved object" if ::Ractor::MovedObject === object
|
|
325
558
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
yield
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
559
|
+
config = Configuration.new
|
|
560
|
+
config.name = name || object_id.to_s
|
|
561
|
+
config.enable_logging = enable_logging
|
|
562
|
+
config.threads = threads
|
|
563
|
+
config.use_current_ractor = use_current_ractor
|
|
564
|
+
config.configure_method(arguments: arguments,
|
|
565
|
+
results: results,
|
|
566
|
+
block_arguments: block_arguments,
|
|
567
|
+
block_results: block_results,
|
|
568
|
+
block_environment: block_environment)
|
|
569
|
+
yield config if block_given?
|
|
570
|
+
|
|
571
|
+
@name = config.name
|
|
572
|
+
@enable_logging = config.enable_logging
|
|
573
|
+
@threads = config.threads
|
|
574
|
+
@method_settings = config.final_method_settings
|
|
575
|
+
@stub = Stub.new(self)
|
|
576
|
+
|
|
577
|
+
if config.use_current_ractor
|
|
340
578
|
setup_local_server(object)
|
|
341
579
|
else
|
|
342
580
|
setup_isolated_server(object)
|
|
343
581
|
end
|
|
344
|
-
@stub = Stub.new(self)
|
|
345
|
-
|
|
346
|
-
freeze
|
|
347
|
-
end
|
|
348
|
-
|
|
349
|
-
##
|
|
350
|
-
# Set the number of threads to run in the wrapper. If the underlying object
|
|
351
|
-
# is thread-safe, setting a value of 2 or more allows concurrent calls to
|
|
352
|
-
# it. If the underlying object is not thread-safe, you should leave this
|
|
353
|
-
# set to its default of 0, which disables worker threads and handles all
|
|
354
|
-
# calls sequentially.
|
|
355
|
-
#
|
|
356
|
-
# This method can be called only during an initialization block.
|
|
357
|
-
# All settings are frozen once the wrapper is active.
|
|
358
|
-
#
|
|
359
|
-
# @param value [Integer]
|
|
360
|
-
#
|
|
361
|
-
def threads=(value)
|
|
362
|
-
value = value.to_i
|
|
363
|
-
value = 0 if value.negative?
|
|
364
|
-
@threads = value
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
##
|
|
368
|
-
# Enable or disable internal debug logging.
|
|
369
|
-
#
|
|
370
|
-
# This method can be called only during an initialization block.
|
|
371
|
-
# All settings are frozen once the wrapper is active.
|
|
372
|
-
#
|
|
373
|
-
# @param value [Boolean]
|
|
374
|
-
#
|
|
375
|
-
def enable_logging=(value)
|
|
376
|
-
@enable_logging = value ? true : false
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
##
|
|
380
|
-
# Set the name of this wrapper. This is shown in logging, and is also used
|
|
381
|
-
# as the name of the wrapping Ractor.
|
|
382
|
-
#
|
|
383
|
-
# This method can be called only during an initialization block.
|
|
384
|
-
# All settings are frozen once the wrapper is active.
|
|
385
|
-
#
|
|
386
|
-
# @param value [String, nil]
|
|
387
|
-
#
|
|
388
|
-
def name=(value)
|
|
389
|
-
@name = value ? value.to_s.freeze : nil
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
##
|
|
393
|
-
# Configure the move semantics for the given method (or the default
|
|
394
|
-
# settings if no method name is given.) That is, determine whether
|
|
395
|
-
# arguments, return values, and/or exceptions are copied or moved when
|
|
396
|
-
# communicated with the wrapper. By default, all objects are copied.
|
|
397
|
-
#
|
|
398
|
-
# This method can be called only during an initialization block.
|
|
399
|
-
# All settings are frozen once the wrapper is active.
|
|
400
|
-
#
|
|
401
|
-
# @param method_name [Symbol, nil] The name of the method being configured,
|
|
402
|
-
# or `nil` to set defaults for all methods not configured explicitly.
|
|
403
|
-
# @param move_data [boolean] If true, communication for this method will
|
|
404
|
-
# move instead of copy arguments and return values. Default is false.
|
|
405
|
-
# This setting can be overridden by other `:move_*` settings.
|
|
406
|
-
# @param move_arguments [boolean] If true, arguments for this method are
|
|
407
|
-
# moved instead of copied. If not set, uses the `:move_data` setting.
|
|
408
|
-
# @param move_results [boolean] If true, return values for this method are
|
|
409
|
-
# moved instead of copied. If not set, uses the `:move_data` setting.
|
|
410
|
-
# @param move_block_arguments [boolean] If true, arguments to blocks passed
|
|
411
|
-
# to this method are moved instead of copied. If not set, uses the
|
|
412
|
-
# `:move_data` setting.
|
|
413
|
-
# @param move_block_results [boolean] If true, result values from blocks
|
|
414
|
-
# passed to this method are moved instead of copied. If not set, uses
|
|
415
|
-
# the `:move_data` setting.
|
|
416
|
-
# @param execute_blocks_in_place [boolean] If true, blocks passed to this
|
|
417
|
-
# method are made shareable and passed into the wrapper to be executed
|
|
418
|
-
# in the wrapped environment. If false (the default), blocks are
|
|
419
|
-
# replaced by a proc that passes messages back out to the caller and
|
|
420
|
-
# executes the block in the caller's environment.
|
|
421
|
-
#
|
|
422
|
-
def configure_method(method_name = nil,
|
|
423
|
-
move_data: false,
|
|
424
|
-
move_arguments: nil,
|
|
425
|
-
move_results: nil,
|
|
426
|
-
move_block_arguments: nil,
|
|
427
|
-
move_block_results: nil,
|
|
428
|
-
execute_blocks_in_place: nil)
|
|
429
|
-
method_name = method_name.to_sym unless method_name.nil?
|
|
430
|
-
@method_settings[method_name] =
|
|
431
|
-
MethodSettings.new(move_data: move_data,
|
|
432
|
-
move_arguments: move_arguments,
|
|
433
|
-
move_results: move_results,
|
|
434
|
-
move_block_arguments: move_block_arguments,
|
|
435
|
-
move_block_results: move_block_results,
|
|
436
|
-
execute_blocks_in_place: execute_blocks_in_place)
|
|
437
582
|
end
|
|
438
583
|
|
|
439
584
|
##
|
|
@@ -478,8 +623,7 @@ class Ractor
|
|
|
478
623
|
# @return [MethodSettings]
|
|
479
624
|
#
|
|
480
625
|
def method_settings(method_name)
|
|
481
|
-
method_name
|
|
482
|
-
@method_settings[method_name] || @method_settings[nil]
|
|
626
|
+
(method_name && @method_settings[method_name.to_sym]) || @method_settings[nil]
|
|
483
627
|
end
|
|
484
628
|
|
|
485
629
|
##
|
|
@@ -500,7 +644,7 @@ class Ractor
|
|
|
500
644
|
#
|
|
501
645
|
def call(method_name, *args, **kwargs, &)
|
|
502
646
|
reply_port = ::Ractor::Port.new
|
|
503
|
-
transaction =
|
|
647
|
+
transaction = make_transaction
|
|
504
648
|
settings = method_settings(method_name)
|
|
505
649
|
block_arg = make_block_arg(settings, &)
|
|
506
650
|
message = CallMessage.new(method_name: method_name,
|
|
@@ -511,27 +655,33 @@ class Ractor
|
|
|
511
655
|
settings: settings,
|
|
512
656
|
reply_port: reply_port)
|
|
513
657
|
maybe_log("Sending method", method_name: method_name, transaction: transaction)
|
|
514
|
-
|
|
658
|
+
begin
|
|
659
|
+
@port.send(message, move: settings.arguments == :move)
|
|
660
|
+
rescue ::Ractor::ClosedError
|
|
661
|
+
raise StoppedError, "Wrapper has stopped"
|
|
662
|
+
end
|
|
515
663
|
loop do
|
|
516
664
|
reply_message = reply_port.receive
|
|
517
665
|
case reply_message
|
|
518
|
-
when
|
|
666
|
+
when FiberYieldMessage, BlockingYieldMessage
|
|
519
667
|
handle_yield(reply_message, transaction, settings, method_name, &)
|
|
520
668
|
when ReturnMessage
|
|
521
669
|
maybe_log("Received result", method_name: method_name, transaction: transaction)
|
|
522
|
-
reply_port.close
|
|
523
670
|
return reply_message.value
|
|
524
671
|
when ExceptionMessage
|
|
525
672
|
maybe_log("Received exception", method_name: method_name, transaction: transaction)
|
|
526
|
-
reply_port.close
|
|
527
673
|
raise reply_message.exception
|
|
528
674
|
end
|
|
529
675
|
end
|
|
676
|
+
ensure
|
|
677
|
+
reply_port.close
|
|
530
678
|
end
|
|
531
679
|
|
|
532
680
|
##
|
|
533
681
|
# Request that the wrapper stop. All currently running calls will complete
|
|
534
|
-
# before the wrapper actually terminates
|
|
682
|
+
# before the wrapper actually terminates, including calls that are
|
|
683
|
+
# suspended waiting for a re-entrant block to return. New calls submitted
|
|
684
|
+
# after the stop request will fail with {StoppedError}.
|
|
535
685
|
#
|
|
536
686
|
# This method is idempotent and can be called multiple times (even from
|
|
537
687
|
# different ractors).
|
|
@@ -550,6 +700,10 @@ class Ractor
|
|
|
550
700
|
##
|
|
551
701
|
# Blocks until the wrapper has fully stopped.
|
|
552
702
|
#
|
|
703
|
+
# Unlike `Thread#join` and `Ractor#join`, if a Wrapper crashes, the
|
|
704
|
+
# exception generally does *not* get raised out of `Wrapper#join`. Instead,
|
|
705
|
+
# it just returns self in the same way as normal termination.
|
|
706
|
+
#
|
|
553
707
|
# @return [self]
|
|
554
708
|
#
|
|
555
709
|
def join
|
|
@@ -557,13 +711,16 @@ class Ractor
|
|
|
557
711
|
@ractor.join
|
|
558
712
|
else
|
|
559
713
|
reply_port = ::Ractor::Port.new
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
714
|
+
begin
|
|
715
|
+
@port.send(JoinMessage.new(reply_port))
|
|
716
|
+
reply_port.receive
|
|
717
|
+
rescue ::Ractor::ClosedError
|
|
718
|
+
# Assume the wrapper has stopped if the port is not sendable
|
|
719
|
+
ensure
|
|
720
|
+
reply_port.close
|
|
721
|
+
end
|
|
563
722
|
end
|
|
564
723
|
self
|
|
565
|
-
rescue ::Ractor::ClosedError
|
|
566
|
-
self
|
|
567
724
|
end
|
|
568
725
|
|
|
569
726
|
##
|
|
@@ -577,13 +734,17 @@ class Ractor
|
|
|
577
734
|
# case, any calls to this method will raise Ractor::Error.
|
|
578
735
|
#
|
|
579
736
|
# Only one ractor may call this method; any additional calls will fail with
|
|
580
|
-
# a Ractor::Error.
|
|
737
|
+
# a Ractor::Wrapper::Error.
|
|
581
738
|
#
|
|
582
739
|
# @return [Object] The original wrapped object
|
|
583
740
|
#
|
|
584
741
|
def recover_object
|
|
585
|
-
raise
|
|
586
|
-
|
|
742
|
+
raise Error, "cannot recover an object from a local wrapper" unless @ractor
|
|
743
|
+
begin
|
|
744
|
+
@ractor.value
|
|
745
|
+
rescue ::Ractor::Error => e
|
|
746
|
+
raise ::Ractor::Wrapper::Error, e.message, cause: e
|
|
747
|
+
end
|
|
587
748
|
end
|
|
588
749
|
|
|
589
750
|
#### private items below ####
|
|
@@ -592,7 +753,7 @@ class Ractor
|
|
|
592
753
|
# @private
|
|
593
754
|
# Message sent to initialize a server.
|
|
594
755
|
#
|
|
595
|
-
InitMessage = ::Data.define(:object, :
|
|
756
|
+
InitMessage = ::Data.define(:object, :stub)
|
|
596
757
|
|
|
597
758
|
##
|
|
598
759
|
# @private
|
|
@@ -619,6 +780,12 @@ class Ractor
|
|
|
619
780
|
#
|
|
620
781
|
JoinMessage = ::Data.define(:reply_port)
|
|
621
782
|
|
|
783
|
+
##
|
|
784
|
+
# @private
|
|
785
|
+
# Message sent from a server in response to a join request.
|
|
786
|
+
#
|
|
787
|
+
JoinReplyMessage = ::Data.define
|
|
788
|
+
|
|
622
789
|
##
|
|
623
790
|
# @private
|
|
624
791
|
# Message sent to report a return value
|
|
@@ -633,9 +800,39 @@ class Ractor
|
|
|
633
800
|
|
|
634
801
|
##
|
|
635
802
|
# @private
|
|
636
|
-
# Message sent from a server to request a yield block run
|
|
803
|
+
# Message sent from a server to request a yield block run, in the
|
|
804
|
+
# blocking-fallback path. The server allocates a temporary reply_port and
|
|
805
|
+
# blocks waiting for a response on it. Used when the wrapped object yields
|
|
806
|
+
# from a context where Fiber.yield is not safe (e.g., inside a nested
|
|
807
|
+
# fiber such as an Enumerator's generator, or in a spawned thread).
|
|
808
|
+
#
|
|
809
|
+
BlockingYieldMessage = ::Data.define(:args, :kwargs, :reply_port)
|
|
810
|
+
|
|
811
|
+
##
|
|
812
|
+
# @private
|
|
813
|
+
# Message sent from a server to request a yield block run, in the
|
|
814
|
+
# fiber-suspend path. The server suspends its method-handling fiber and
|
|
815
|
+
# is resumed when a FiberReturnMessage or FiberExceptionMessage tagged
|
|
816
|
+
# with the same fiber_id arrives back on the server's main port.
|
|
637
817
|
#
|
|
638
|
-
|
|
818
|
+
FiberYieldMessage = ::Data.define(:args, :kwargs, :fiber_id)
|
|
819
|
+
|
|
820
|
+
##
|
|
821
|
+
# @private
|
|
822
|
+
# Message sent from a caller back to a server, carrying the result of a
|
|
823
|
+
# block invoked via the fiber-suspend path. The fiber_id identifies which
|
|
824
|
+
# suspended fiber on the server should be resumed with this value.
|
|
825
|
+
#
|
|
826
|
+
FiberReturnMessage = ::Data.define(:value, :fiber_id)
|
|
827
|
+
|
|
828
|
+
##
|
|
829
|
+
# @private
|
|
830
|
+
# Message sent from a caller back to a server, carrying an exception
|
|
831
|
+
# raised by a block invoked via the fiber-suspend path. The fiber_id
|
|
832
|
+
# identifies which suspended fiber on the server should be resumed and
|
|
833
|
+
# have this exception raised inside it.
|
|
834
|
+
#
|
|
835
|
+
FiberExceptionMessage = ::Data.define(:exception, :fiber_id)
|
|
639
836
|
|
|
640
837
|
private
|
|
641
838
|
|
|
@@ -647,8 +844,12 @@ class Ractor
|
|
|
647
844
|
maybe_log("Starting local server")
|
|
648
845
|
@ractor = nil
|
|
649
846
|
@port = ::Ractor::Port.new
|
|
847
|
+
freeze
|
|
848
|
+
wrapper_id = object_id
|
|
650
849
|
::Thread.new do
|
|
850
|
+
::Thread.current.name = "ractor-wrapper:server:#{wrapper_id}"
|
|
651
851
|
Server.run_local(object: object,
|
|
852
|
+
stub: @stub,
|
|
652
853
|
port: @port,
|
|
653
854
|
name: name,
|
|
654
855
|
enable_logging: enable_logging?,
|
|
@@ -669,14 +870,16 @@ class Ractor
|
|
|
669
870
|
threads: threads)
|
|
670
871
|
end
|
|
671
872
|
@port = @ractor.default_port
|
|
672
|
-
|
|
873
|
+
freeze
|
|
874
|
+
init_message = InitMessage.new(object: object, stub: @stub)
|
|
875
|
+
@port.send(init_message, move: true)
|
|
673
876
|
end
|
|
674
877
|
|
|
675
878
|
##
|
|
676
879
|
# Create a transaction ID, used for logging
|
|
677
880
|
#
|
|
678
881
|
def make_transaction
|
|
679
|
-
::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
|
|
882
|
+
::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).rjust(16, "0").freeze
|
|
680
883
|
end
|
|
681
884
|
|
|
682
885
|
##
|
|
@@ -685,7 +888,7 @@ class Ractor
|
|
|
685
888
|
def make_block_arg(settings, &)
|
|
686
889
|
if !block_given?
|
|
687
890
|
nil
|
|
688
|
-
elsif settings.
|
|
891
|
+
elsif settings.block_environment == :wrapped
|
|
689
892
|
::Ractor.shareable_proc(&)
|
|
690
893
|
else
|
|
691
894
|
:send_block_message
|
|
@@ -694,20 +897,26 @@ class Ractor
|
|
|
694
897
|
|
|
695
898
|
##
|
|
696
899
|
# Handle a call to a block directed to run in the caller environment.
|
|
900
|
+
# Dispatches the block result or exception based on which yield-message
|
|
901
|
+
# variant arrived: a FiberYieldMessage routes the response back to the
|
|
902
|
+
# server's main port (so the suspended fiber can be resumed), while a
|
|
903
|
+
# BlockingYieldMessage routes it to the temporary reply_port the server
|
|
904
|
+
# is blocked on.
|
|
697
905
|
#
|
|
698
906
|
def handle_yield(message, transaction, settings, method_name)
|
|
699
907
|
maybe_log("Yielding to block", method_name: method_name, transaction: transaction)
|
|
700
908
|
begin
|
|
701
909
|
block_result = yield(*message.args, **message.kwargs)
|
|
910
|
+
block_result = nil if settings.block_results == :void
|
|
702
911
|
maybe_log("Sending block result", method_name: method_name, transaction: transaction)
|
|
703
|
-
message
|
|
912
|
+
send_block_result(message, block_result, settings)
|
|
704
913
|
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
705
914
|
maybe_log("Sending block exception", method_name: method_name, transaction: transaction)
|
|
706
915
|
begin
|
|
707
|
-
message
|
|
916
|
+
send_block_exception(message, e)
|
|
708
917
|
rescue ::StandardError
|
|
709
918
|
begin
|
|
710
|
-
message
|
|
919
|
+
send_block_exception(message, ::StandardError.new(e.inspect))
|
|
711
920
|
rescue ::StandardError
|
|
712
921
|
maybe_log("Failure to send block reply", method_name: method_name, transaction: transaction)
|
|
713
922
|
end
|
|
@@ -715,6 +924,33 @@ class Ractor
|
|
|
715
924
|
end
|
|
716
925
|
end
|
|
717
926
|
|
|
927
|
+
##
|
|
928
|
+
# Send a block return value to the appropriate destination based on the
|
|
929
|
+
# yield-message variant.
|
|
930
|
+
#
|
|
931
|
+
def send_block_result(message, value, settings)
|
|
932
|
+
case message
|
|
933
|
+
when FiberYieldMessage
|
|
934
|
+
@port.send(FiberReturnMessage.new(value: value, fiber_id: message.fiber_id),
|
|
935
|
+
move: settings.block_results == :move)
|
|
936
|
+
when BlockingYieldMessage
|
|
937
|
+
message.reply_port.send(ReturnMessage.new(value), move: settings.block_results == :move)
|
|
938
|
+
end
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
##
|
|
942
|
+
# Send a block exception to the appropriate destination based on the
|
|
943
|
+
# yield-message variant.
|
|
944
|
+
#
|
|
945
|
+
def send_block_exception(message, exception)
|
|
946
|
+
case message
|
|
947
|
+
when FiberYieldMessage
|
|
948
|
+
@port.send(FiberExceptionMessage.new(exception: exception, fiber_id: message.fiber_id))
|
|
949
|
+
when BlockingYieldMessage
|
|
950
|
+
message.reply_port.send(ExceptionMessage.new(exception))
|
|
951
|
+
end
|
|
952
|
+
end
|
|
953
|
+
|
|
718
954
|
##
|
|
719
955
|
# Prints out a log message
|
|
720
956
|
#
|
|
@@ -741,12 +977,174 @@ class Ractor
|
|
|
741
977
|
# * Either sequentially or concurrently using worker threads.
|
|
742
978
|
#
|
|
743
979
|
class Server
|
|
980
|
+
##
|
|
981
|
+
# @private
|
|
982
|
+
#
|
|
983
|
+
# Multi-queue work dispatcher for the threaded server. Routes new
|
|
984
|
+
# `CallMessage`s through a shared queue (any worker may pick them up)
|
|
985
|
+
# and routes fiber resumes (`FiberReturnMessage` / `FiberExceptionMessage`)
|
|
986
|
+
# through per-worker queues so a suspended fiber is always resumed by
|
|
987
|
+
# the same worker thread that started it (Ruby fibers cannot be resumed
|
|
988
|
+
# from a different thread than their last resumer).
|
|
989
|
+
#
|
|
990
|
+
# All public methods are thread-safe. The internal mutex guards the
|
|
991
|
+
# shared queue, all per-worker queues, the fiber→worker map, and the
|
|
992
|
+
# closed/notified state. `dequeue` blocks on a single shared
|
|
993
|
+
# `ConditionVariable`; producers `broadcast` rather than `signal` so
|
|
994
|
+
# workers waiting on per-worker queues are not starved by
|
|
995
|
+
# shared-queue activity.
|
|
996
|
+
#
|
|
997
|
+
class Dispatcher
|
|
998
|
+
CLOSED = [:closed, nil].freeze
|
|
999
|
+
TERMINATE = [:terminate, nil].freeze
|
|
1000
|
+
|
|
1001
|
+
##
|
|
1002
|
+
# @param num_workers [Integer] number of per-worker queues to allocate.
|
|
1003
|
+
# Workers are addressed by integers in the range `[0, num_workers)`.
|
|
1004
|
+
#
|
|
1005
|
+
def initialize(num_workers)
|
|
1006
|
+
@mutex = ::Mutex.new
|
|
1007
|
+
@cond = ::ConditionVariable.new
|
|
1008
|
+
@shared_queue = []
|
|
1009
|
+
@worker_queues = ::Array.new(num_workers) { [] }
|
|
1010
|
+
@fiber_to_worker = {}
|
|
1011
|
+
@closed = false
|
|
1012
|
+
@crashed = false
|
|
1013
|
+
@closed_notified = ::Array.new(num_workers, false)
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
##
|
|
1017
|
+
# Push a new `CallMessage` onto the shared queue.
|
|
1018
|
+
# @return [Boolean] `true` normally, `false` if `close` has been called.
|
|
1019
|
+
#
|
|
1020
|
+
def enqueue_call(message)
|
|
1021
|
+
@mutex.synchronize do
|
|
1022
|
+
return false if @closed
|
|
1023
|
+
@shared_queue.push(message)
|
|
1024
|
+
@cond.broadcast
|
|
1025
|
+
true
|
|
1026
|
+
end
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
##
|
|
1030
|
+
# Push a fiber-resume message (`FiberReturnMessage` /
|
|
1031
|
+
# `FiberExceptionMessage`) onto the queue of the worker that owns the
|
|
1032
|
+
# fiber identified by `message.fiber_id`.
|
|
1033
|
+
# @return [Boolean] `true` if dispatched, `false` if the fiber_id is
|
|
1034
|
+
# not registered (e.g. fiber already finished or was aborted).
|
|
1035
|
+
#
|
|
1036
|
+
def enqueue_fiber_resume(message)
|
|
1037
|
+
@mutex.synchronize do
|
|
1038
|
+
worker_num = @fiber_to_worker[message.fiber_id]
|
|
1039
|
+
return false unless worker_num
|
|
1040
|
+
@worker_queues[worker_num].push(message)
|
|
1041
|
+
@cond.broadcast
|
|
1042
|
+
true
|
|
1043
|
+
end
|
|
1044
|
+
end
|
|
1045
|
+
|
|
1046
|
+
##
|
|
1047
|
+
# Block until the worker has work. Priority order:
|
|
1048
|
+
#
|
|
1049
|
+
# 1. Per-worker queue (always — these are resumes for fibers this
|
|
1050
|
+
# worker owns; they must be drained even after close).
|
|
1051
|
+
# 2. Shared queue, but only if `accept_calls` is true and the
|
|
1052
|
+
# dispatcher is not yet closed.
|
|
1053
|
+
# 3. The `CLOSED` sentinel (`[:closed, nil]`), returned exactly once
|
|
1054
|
+
# per worker, the first time the worker would otherwise have
|
|
1055
|
+
# blocked after `close` was called. Used to wake the worker so it
|
|
1056
|
+
# can transition to a draining state. Subsequent calls behave
|
|
1057
|
+
# normally and may block again.
|
|
1058
|
+
#
|
|
1059
|
+
# If `crash_close` has been called, returns `TERMINATE`
|
|
1060
|
+
# (`[:terminate, nil]`) immediately whenever the per-worker queue is
|
|
1061
|
+
# empty — signaling the worker to exit even if it has pending fibers.
|
|
1062
|
+
# The per-worker queue is still drained first so any in-flight resumes
|
|
1063
|
+
# complete normally.
|
|
1064
|
+
#
|
|
1065
|
+
# @return [Array(Symbol, Object)] one of `[:resume, msg]`,
|
|
1066
|
+
# `[:call, msg]`, `CLOSED`, or `TERMINATE`.
|
|
1067
|
+
#
|
|
1068
|
+
def dequeue(worker_num, accept_calls:)
|
|
1069
|
+
@mutex.synchronize do
|
|
1070
|
+
loop do
|
|
1071
|
+
if (msg = @worker_queues[worker_num].shift)
|
|
1072
|
+
return [:resume, msg]
|
|
1073
|
+
end
|
|
1074
|
+
return TERMINATE if @crashed
|
|
1075
|
+
if accept_calls && !@closed && (msg = @shared_queue.shift)
|
|
1076
|
+
return [:call, msg]
|
|
1077
|
+
end
|
|
1078
|
+
if @closed && !@closed_notified[worker_num]
|
|
1079
|
+
@closed_notified[worker_num] = true
|
|
1080
|
+
return CLOSED
|
|
1081
|
+
end
|
|
1082
|
+
@cond.wait(@mutex)
|
|
1083
|
+
end
|
|
1084
|
+
end
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
##
|
|
1088
|
+
# Atomically associate `fiber_id` with `worker_num` so subsequent
|
|
1089
|
+
# `enqueue_fiber_resume` calls land on the right worker queue.
|
|
1090
|
+
#
|
|
1091
|
+
def register_fiber(fiber_id, worker_num)
|
|
1092
|
+
@mutex.synchronize { @fiber_to_worker[fiber_id] = worker_num }
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
##
|
|
1096
|
+
# Remove the fiber→worker mapping. Idempotent.
|
|
1097
|
+
#
|
|
1098
|
+
def unregister_fiber(fiber_id)
|
|
1099
|
+
@mutex.synchronize { @fiber_to_worker.delete(fiber_id) }
|
|
1100
|
+
end
|
|
1101
|
+
|
|
1102
|
+
##
|
|
1103
|
+
# Mark closed and wake all blocked workers. Drains the shared queue
|
|
1104
|
+
# and returns its previous contents so the caller can refuse them
|
|
1105
|
+
# (with `StoppedError`) on behalf of their pending callers. Idempotent
|
|
1106
|
+
# — repeated calls return `[]`.
|
|
1107
|
+
# @return [Array] the messages that were in the shared queue.
|
|
1108
|
+
#
|
|
1109
|
+
def close
|
|
1110
|
+
@mutex.synchronize do
|
|
1111
|
+
return [] if @closed
|
|
1112
|
+
@closed = true
|
|
1113
|
+
drained = @shared_queue.dup
|
|
1114
|
+
@shared_queue.clear
|
|
1115
|
+
@cond.broadcast
|
|
1116
|
+
drained
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
|
|
1120
|
+
##
|
|
1121
|
+
# Mark closed AND crashed: future `dequeue` calls return `TERMINATE`
|
|
1122
|
+
# whenever the per-worker queue is empty (rather than blocking),
|
|
1123
|
+
# signaling workers to exit immediately so their `ensure` blocks can
|
|
1124
|
+
# abort any pending fibers. Used on server crash, where no further
|
|
1125
|
+
# fiber-resume messages will arrive. Idempotent (sets crashed even if
|
|
1126
|
+
# already closed). Returns the drained shared queue.
|
|
1127
|
+
# @return [Array] the messages that were in the shared queue.
|
|
1128
|
+
#
|
|
1129
|
+
def crash_close
|
|
1130
|
+
@mutex.synchronize do
|
|
1131
|
+
already_closed = @closed
|
|
1132
|
+
@closed = true
|
|
1133
|
+
@crashed = true
|
|
1134
|
+
drained = already_closed ? [] : @shared_queue.dup
|
|
1135
|
+
@shared_queue.clear
|
|
1136
|
+
@cond.broadcast
|
|
1137
|
+
drained
|
|
1138
|
+
end
|
|
1139
|
+
end
|
|
1140
|
+
end
|
|
1141
|
+
|
|
744
1142
|
##
|
|
745
1143
|
# @private
|
|
746
1144
|
# Create and run a server hosted in the current Ractor
|
|
747
1145
|
#
|
|
748
|
-
def self.run_local(object:, port:, name:, enable_logging: false, threads: 0)
|
|
749
|
-
server = new(isolated: false, object:, port:, name:, enable_logging:, threads:)
|
|
1146
|
+
def self.run_local(object:, stub:, port:, name:, enable_logging: false, threads: 0)
|
|
1147
|
+
server = new(isolated: false, object:, stub:, port:, name:, enable_logging:, threads:)
|
|
750
1148
|
server.run
|
|
751
1149
|
end
|
|
752
1150
|
|
|
@@ -756,19 +1154,27 @@ class Ractor
|
|
|
756
1154
|
#
|
|
757
1155
|
def self.run_isolated(name:, enable_logging: false, threads: 0)
|
|
758
1156
|
port = ::Ractor.current.default_port
|
|
759
|
-
server = new(isolated: true, object: nil, port:, name:, enable_logging:, threads:)
|
|
1157
|
+
server = new(isolated: true, object: nil, stub: nil, port:, name:, enable_logging:, threads:)
|
|
760
1158
|
server.run
|
|
761
1159
|
end
|
|
762
1160
|
|
|
763
1161
|
# @private
|
|
764
|
-
def initialize(isolated:, object:, port:, name:, enable_logging:, threads:)
|
|
1162
|
+
def initialize(isolated:, object:, stub:, port:, name:, enable_logging:, threads:)
|
|
765
1163
|
@isolated = isolated
|
|
766
1164
|
@object = object
|
|
1165
|
+
@stub = stub
|
|
767
1166
|
@port = port
|
|
768
1167
|
@name = name
|
|
769
1168
|
@enable_logging = enable_logging
|
|
770
|
-
@
|
|
1169
|
+
@threads_requested = threads.positive? ? threads : false
|
|
771
1170
|
@join_requests = []
|
|
1171
|
+
# Sequential mode only: maps fiber_id (Integer) => Fiber for
|
|
1172
|
+
# method-handling fibers that have suspended (via Fiber.yield) waiting
|
|
1173
|
+
# for a block result. Used to route incoming
|
|
1174
|
+
# FiberReturnMessage/FiberExceptionMessage back to the right fiber.
|
|
1175
|
+
# Threaded mode tracks pending fibers per-worker (in `worker_loop`'s
|
|
1176
|
+
# local `pending` hash) and routes via `Dispatcher`.
|
|
1177
|
+
@pending_fibers = {}
|
|
772
1178
|
end
|
|
773
1179
|
|
|
774
1180
|
##
|
|
@@ -779,14 +1185,16 @@ class Ractor
|
|
|
779
1185
|
#
|
|
780
1186
|
def run
|
|
781
1187
|
receive_remote_object if @isolated
|
|
782
|
-
start_workers if @
|
|
1188
|
+
start_workers if @threads_requested
|
|
783
1189
|
main_loop
|
|
784
|
-
stop_workers if @
|
|
1190
|
+
stop_workers if @threads_requested
|
|
785
1191
|
cleanup
|
|
786
1192
|
@object
|
|
787
|
-
rescue ::
|
|
788
|
-
|
|
1193
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1194
|
+
@crash_exception = e
|
|
789
1195
|
@object
|
|
1196
|
+
ensure
|
|
1197
|
+
crash_cleanup if @crash_exception
|
|
790
1198
|
end
|
|
791
1199
|
|
|
792
1200
|
private
|
|
@@ -796,19 +1204,27 @@ class Ractor
|
|
|
796
1204
|
# separate Ractor.
|
|
797
1205
|
#
|
|
798
1206
|
def receive_remote_object
|
|
799
|
-
maybe_log("Waiting for
|
|
800
|
-
|
|
1207
|
+
maybe_log("Waiting for initialization")
|
|
1208
|
+
init_message = @port.receive
|
|
1209
|
+
@object = init_message.object
|
|
1210
|
+
@stub = init_message.stub
|
|
801
1211
|
end
|
|
802
1212
|
|
|
803
1213
|
##
|
|
804
|
-
# Start the worker threads. Each thread picks up
|
|
805
|
-
#
|
|
1214
|
+
# Start the worker threads. Each thread picks up work via the
|
|
1215
|
+
# `Dispatcher`, which routes new `CallMessage`s through a shared queue
|
|
1216
|
+
# and routes fiber-resume messages to the specific worker that owns the
|
|
1217
|
+
# suspended fiber. Called only if worker threading is enabled.
|
|
806
1218
|
#
|
|
807
1219
|
def start_workers
|
|
808
|
-
@
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
1220
|
+
maybe_log("Spawning #{@threads_requested} worker threads")
|
|
1221
|
+
@dispatcher = Dispatcher.new(@threads_requested)
|
|
1222
|
+
@active_workers = {}
|
|
1223
|
+
(0...@threads_requested).each do |worker_num|
|
|
1224
|
+
@active_workers[worker_num] = ::Thread.new do
|
|
1225
|
+
::Thread.current.name = "ractor-wrapper:#{@name}:worker:#{worker_num}"
|
|
1226
|
+
worker_loop(worker_num)
|
|
1227
|
+
end
|
|
812
1228
|
end
|
|
813
1229
|
end
|
|
814
1230
|
|
|
@@ -816,8 +1232,17 @@ class Ractor
|
|
|
816
1232
|
# This is the main loop, listening on the inbox and handling messages for
|
|
817
1233
|
# normal operation:
|
|
818
1234
|
#
|
|
819
|
-
# * If it receives a CallMessage, it either runs the method
|
|
820
|
-
# sequential mode) or
|
|
1235
|
+
# * If it receives a CallMessage, it either runs the method in a
|
|
1236
|
+
# fiber (sequential mode) or hands it to the `Dispatcher`'s shared
|
|
1237
|
+
# queue (threaded mode). In both modes the method body executes
|
|
1238
|
+
# inside a `Fiber` so it can suspend (via `Fiber.yield`) when its
|
|
1239
|
+
# caller-side block needs to make a re-entrant call back into this
|
|
1240
|
+
# wrapper.
|
|
1241
|
+
# * If it receives a FiberReturnMessage or FiberExceptionMessage, it
|
|
1242
|
+
# resumes the suspended fiber. In sequential mode the fiber lives
|
|
1243
|
+
# in `@pending_fibers` and is resumed inline. In threaded mode the
|
|
1244
|
+
# `Dispatcher` routes the message to the per-worker queue of the
|
|
1245
|
+
# fiber's owning worker.
|
|
821
1246
|
# * If it receives a StopMessage, it exits the main loop and proceeds
|
|
822
1247
|
# to the termination logic.
|
|
823
1248
|
# * If it receives a JoinMessage, it adds it to the list of join ports
|
|
@@ -832,36 +1257,128 @@ class Ractor
|
|
|
832
1257
|
maybe_log("Waiting for message in running phase")
|
|
833
1258
|
message = @port.receive
|
|
834
1259
|
case message
|
|
835
|
-
when CallMessage
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
@queue.enq(message)
|
|
839
|
-
else
|
|
840
|
-
handle_method(message)
|
|
841
|
-
end
|
|
1260
|
+
when CallMessage then dispatch_call(message)
|
|
1261
|
+
when FiberReturnMessage, FiberExceptionMessage then dispatch_fiber_resume(message)
|
|
1262
|
+
when JoinMessage then @join_requests << message.reply_port
|
|
842
1263
|
when WorkerStoppedMessage
|
|
843
1264
|
maybe_log("Received unexpected WorkerStoppedMessage")
|
|
844
|
-
@
|
|
1265
|
+
@active_workers.delete(message.worker_num) if @threads_requested
|
|
845
1266
|
break
|
|
846
1267
|
when StopMessage
|
|
847
1268
|
maybe_log("Received stop")
|
|
1269
|
+
drain_pending_fibers unless @threads_requested
|
|
848
1270
|
break
|
|
1271
|
+
end
|
|
1272
|
+
end
|
|
1273
|
+
end
|
|
1274
|
+
|
|
1275
|
+
##
|
|
1276
|
+
# Dispatch a `CallMessage` received by the main loop. In sequential
|
|
1277
|
+
# mode the method is started inline as a new fiber; in threaded mode
|
|
1278
|
+
# it is handed to the dispatcher's shared queue for any worker to pick
|
|
1279
|
+
# up.
|
|
1280
|
+
#
|
|
1281
|
+
def dispatch_call(message)
|
|
1282
|
+
maybe_log("Received CallMessage", call_message: message)
|
|
1283
|
+
if @threads_requested
|
|
1284
|
+
@dispatcher.enqueue_call(message)
|
|
1285
|
+
else
|
|
1286
|
+
start_method_fiber(message)
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
##
|
|
1291
|
+
# Route a fiber-resume message (`FiberReturnMessage` /
|
|
1292
|
+
# `FiberExceptionMessage`) to its owning fiber. In sequential mode this
|
|
1293
|
+
# resumes the fiber inline; in threaded mode the dispatcher routes it to
|
|
1294
|
+
# the per-worker queue of the worker that started the fiber. Logs and
|
|
1295
|
+
# discards if the fiber is no longer registered (likely already finished
|
|
1296
|
+
# or aborted by a crashed worker).
|
|
1297
|
+
#
|
|
1298
|
+
def dispatch_fiber_resume(message)
|
|
1299
|
+
maybe_log("Routing fiber resume", fiber_id: message.fiber_id)
|
|
1300
|
+
if @threads_requested
|
|
1301
|
+
return if @dispatcher.enqueue_fiber_resume(message)
|
|
1302
|
+
maybe_log("Discarding orphan fiber resume", fiber_id: message.fiber_id)
|
|
1303
|
+
else
|
|
1304
|
+
resume_method_fiber(message)
|
|
1305
|
+
end
|
|
1306
|
+
end
|
|
1307
|
+
|
|
1308
|
+
##
|
|
1309
|
+
# Sequential-mode stopping phase. After receiving a StopMessage, continue
|
|
1310
|
+
# accepting fiber-result messages (and join requests) so that any
|
|
1311
|
+
# currently-suspended method-handling fiber can complete. New
|
|
1312
|
+
# `CallMessage`s are refused with `StoppedError`. Returns once
|
|
1313
|
+
# `@pending_fibers` is empty.
|
|
1314
|
+
#
|
|
1315
|
+
def drain_pending_fibers
|
|
1316
|
+
until @pending_fibers.empty?
|
|
1317
|
+
maybe_log("Waiting for pending fibers to complete")
|
|
1318
|
+
message = @port.receive
|
|
1319
|
+
case message
|
|
1320
|
+
when CallMessage
|
|
1321
|
+
refuse_method(message)
|
|
1322
|
+
when FiberReturnMessage, FiberExceptionMessage
|
|
1323
|
+
resume_method_fiber(message)
|
|
1324
|
+
when StopMessage
|
|
1325
|
+
maybe_log("Stop received when already stopping")
|
|
849
1326
|
when JoinMessage
|
|
850
1327
|
maybe_log("Received and queueing join request")
|
|
851
1328
|
@join_requests << message.reply_port
|
|
1329
|
+
else
|
|
1330
|
+
maybe_log("Unexpected message when draining pending fibers: #{message.class.name}")
|
|
852
1331
|
end
|
|
853
1332
|
end
|
|
854
1333
|
end
|
|
855
1334
|
|
|
856
1335
|
##
|
|
857
|
-
#
|
|
858
|
-
#
|
|
859
|
-
#
|
|
1336
|
+
# Spawn a fiber to handle a CallMessage in sequential mode. If the
|
|
1337
|
+
# method-handling fiber suspends via Fiber.yield (because its caller-side
|
|
1338
|
+
# block re-entered this wrapper), the fiber is registered in
|
|
1339
|
+
# `@pending_fibers` so that the matching block-return message can later
|
|
1340
|
+
# resume it.
|
|
1341
|
+
#
|
|
1342
|
+
def start_method_fiber(message)
|
|
1343
|
+
fiber = ::Fiber.new { handle_method(message) }
|
|
1344
|
+
fiber_id = fiber.object_id
|
|
1345
|
+
@pending_fibers[fiber_id] = fiber
|
|
1346
|
+
maybe_log("Starting method fiber", call_message: message, fiber_id: fiber_id)
|
|
1347
|
+
fiber.resume
|
|
1348
|
+
@pending_fibers.delete(fiber_id) unless fiber.alive?
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
##
|
|
1352
|
+
# Resume a previously-suspended method-handling fiber, delivering the
|
|
1353
|
+
# block-result message as the return value of its Fiber.yield call.
|
|
1354
|
+
# Silently ignores unknown fiber_ids (the fiber may have been aborted).
|
|
1355
|
+
#
|
|
1356
|
+
def resume_method_fiber(message)
|
|
1357
|
+
fiber = @pending_fibers[message.fiber_id]
|
|
1358
|
+
return unless fiber
|
|
1359
|
+
maybe_log("Resuming method fiber", fiber_id: message.fiber_id)
|
|
1360
|
+
fiber.resume(message)
|
|
1361
|
+
@pending_fibers.delete(message.fiber_id) unless fiber.alive?
|
|
1362
|
+
end
|
|
1363
|
+
|
|
1364
|
+
##
|
|
1365
|
+
# This signals workers to stop by closing the dispatcher, and then
|
|
1366
|
+
# waits for all workers to report in that they have stopped. It is
|
|
1367
|
+
# called only if worker threading is enabled.
|
|
1368
|
+
#
|
|
1369
|
+
# Closing the dispatcher drains any never-dispatched `CallMessage`s
|
|
1370
|
+
# from its shared queue; those are refused immediately so the
|
|
1371
|
+
# corresponding callers do not block forever. The dispatcher also
|
|
1372
|
+
# delivers a one-shot closed signal to each worker so they can
|
|
1373
|
+
# transition to a draining state.
|
|
860
1374
|
#
|
|
861
1375
|
# Responds to messages to indicate the wrapper is stopping and no longer
|
|
862
1376
|
# accepting new method requests:
|
|
863
1377
|
#
|
|
864
1378
|
# * If it receives a CallMessage, it sends back a refusal exception.
|
|
1379
|
+
# * If it receives a FiberReturnMessage or FiberExceptionMessage, it
|
|
1380
|
+
# forwards it to the dispatcher so the owning worker can resume its
|
|
1381
|
+
# suspended fiber and complete the in-flight call.
|
|
865
1382
|
# * If it receives a StopMessage, it does nothing (i.e. the stop
|
|
866
1383
|
# operation is idempotent).
|
|
867
1384
|
# * If it receives a JoinMessage, it adds it to the list of join ports
|
|
@@ -875,16 +1392,19 @@ class Ractor
|
|
|
875
1392
|
# stopped.
|
|
876
1393
|
#
|
|
877
1394
|
def stop_workers
|
|
878
|
-
@
|
|
879
|
-
|
|
1395
|
+
drained = @dispatcher.close
|
|
1396
|
+
drained.each { |message| refuse_method(message) }
|
|
1397
|
+
until @active_workers.empty?
|
|
880
1398
|
maybe_log("Waiting for message in stopping phase")
|
|
881
1399
|
message = @port.receive
|
|
882
1400
|
case message
|
|
883
1401
|
when CallMessage
|
|
884
1402
|
refuse_method(message)
|
|
1403
|
+
when FiberReturnMessage, FiberExceptionMessage
|
|
1404
|
+
dispatch_fiber_resume(message)
|
|
885
1405
|
when WorkerStoppedMessage
|
|
886
1406
|
maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}")
|
|
887
|
-
@
|
|
1407
|
+
@active_workers.delete(message.worker_num)
|
|
888
1408
|
when StopMessage
|
|
889
1409
|
maybe_log("Stop received when already stopping")
|
|
890
1410
|
when JoinMessage
|
|
@@ -901,8 +1421,6 @@ class Ractor
|
|
|
901
1421
|
def cleanup
|
|
902
1422
|
maybe_log("Closing inbox")
|
|
903
1423
|
@port.close
|
|
904
|
-
maybe_log("Responding to join requests")
|
|
905
|
-
@join_requests.each { |port| send_join_reply(port) }
|
|
906
1424
|
maybe_log("Draining inbox")
|
|
907
1425
|
loop do
|
|
908
1426
|
message = begin
|
|
@@ -924,6 +1442,111 @@ class Ractor
|
|
|
924
1442
|
send_join_reply(message.reply_port)
|
|
925
1443
|
end
|
|
926
1444
|
end
|
|
1445
|
+
maybe_log("Responding to join requests")
|
|
1446
|
+
@join_requests.each { |port| send_join_reply(port) }
|
|
1447
|
+
end
|
|
1448
|
+
|
|
1449
|
+
##
|
|
1450
|
+
# Called from the ensure block in run when an unexpected exception
|
|
1451
|
+
# terminated the server. Drains pending requests that are not otherwise
|
|
1452
|
+
# being handled, responding to all pending callers and join requesters,
|
|
1453
|
+
# and also joins any worker threads.
|
|
1454
|
+
#
|
|
1455
|
+
def crash_cleanup
|
|
1456
|
+
maybe_log("Running crash cleanup after: #{@crash_exception.message} (#{@crash_exception.class})")
|
|
1457
|
+
error = CrashedError.new("Server crashed: #{@crash_exception.message} (#{@crash_exception.class})")
|
|
1458
|
+
# `@dispatcher` should not be nil in threaded mode, but we're
|
|
1459
|
+
# checking anyway just in case a crash happened during setup
|
|
1460
|
+
drain_dispatcher_after_crash(@dispatcher, error) if @threads_requested && @dispatcher
|
|
1461
|
+
abort_pending_fibers(@pending_fibers, error) unless @threads_requested
|
|
1462
|
+
drain_inbox_after_crash(@port, error)
|
|
1463
|
+
# `@active_workers` should not be nil in threaded mode, but we're
|
|
1464
|
+
# checking anyway just in case a crash happened during setup
|
|
1465
|
+
join_workers_after_crash(@active_workers) if @threads_requested && @active_workers
|
|
1466
|
+
@join_requests.each { |port| send_join_reply(port) }
|
|
1467
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1468
|
+
maybe_log("Suppressed exception during crash_cleanup: #{e.message} (#{e.class})")
|
|
1469
|
+
end
|
|
1470
|
+
|
|
1471
|
+
##
|
|
1472
|
+
# After a crash, raises +error+ inside each suspended method-handling
|
|
1473
|
+
# fiber. The exception emerges from the fiber's `Fiber.yield` call and is
|
|
1474
|
+
# caught by `handle_method`'s rescue chain, which sends an
|
|
1475
|
+
# `ExceptionMessage` to the fiber's reply_port so the caller observes a
|
|
1476
|
+
# `CrashedError`.
|
|
1477
|
+
#
|
|
1478
|
+
def abort_pending_fibers(pending_fibers, error)
|
|
1479
|
+
pending_fibers.each_pair do |fiber_id, fiber|
|
|
1480
|
+
maybe_log("Aborting suspended fiber", fiber_id: fiber_id)
|
|
1481
|
+
fiber.raise(error)
|
|
1482
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1483
|
+
maybe_log("Suppressed exception during abort_pending_fibers: #{e.message} (#{e.class})",
|
|
1484
|
+
fiber_id: fiber_id)
|
|
1485
|
+
end
|
|
1486
|
+
pending_fibers.clear
|
|
1487
|
+
end
|
|
1488
|
+
|
|
1489
|
+
##
|
|
1490
|
+
# Closes the dispatcher after a crash, then sends an error response
|
|
1491
|
+
# to the callers of any `CallMessage`s that were still queued in the
|
|
1492
|
+
# shared queue (and therefore had not yet been dispatched to a worker).
|
|
1493
|
+
# Workers themselves clean up their own in-flight fibers via their
|
|
1494
|
+
# ensure blocks.
|
|
1495
|
+
#
|
|
1496
|
+
def drain_dispatcher_after_crash(dispatcher, error)
|
|
1497
|
+
dispatcher.crash_close.each do |message|
|
|
1498
|
+
message.reply_port.send(ExceptionMessage.new(error))
|
|
1499
|
+
rescue ::Ractor::Error
|
|
1500
|
+
maybe_log("Failed to send crash error to queued caller", call_message: message)
|
|
1501
|
+
end
|
|
1502
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1503
|
+
maybe_log("Suppressed exception during drain_dispatcher_after_crash: " \
|
|
1504
|
+
"#{e.message} (#{e.class})")
|
|
1505
|
+
end
|
|
1506
|
+
|
|
1507
|
+
##
|
|
1508
|
+
# Drains any remaining inbox messages after a crash, sending errors to
|
|
1509
|
+
# pending callers and responding to any join requests.
|
|
1510
|
+
#
|
|
1511
|
+
def drain_inbox_after_crash(port, error)
|
|
1512
|
+
begin
|
|
1513
|
+
port.close
|
|
1514
|
+
rescue ::Ractor::Error
|
|
1515
|
+
# Port was already closed (maybe because it was the cause of the crash)
|
|
1516
|
+
end
|
|
1517
|
+
loop do
|
|
1518
|
+
message = begin
|
|
1519
|
+
port.receive
|
|
1520
|
+
rescue ::Ractor::Error
|
|
1521
|
+
nil
|
|
1522
|
+
end
|
|
1523
|
+
break if message.nil?
|
|
1524
|
+
case message
|
|
1525
|
+
when CallMessage
|
|
1526
|
+
begin
|
|
1527
|
+
message.reply_port.send(ExceptionMessage.new(error))
|
|
1528
|
+
rescue ::Ractor::Error
|
|
1529
|
+
maybe_log("Failed to send crash error to caller", call_message: message)
|
|
1530
|
+
end
|
|
1531
|
+
when JoinMessage
|
|
1532
|
+
send_join_reply(message.reply_port)
|
|
1533
|
+
when WorkerStoppedMessage, StopMessage, FiberReturnMessage, FiberExceptionMessage
|
|
1534
|
+
# Ignore
|
|
1535
|
+
end
|
|
1536
|
+
end
|
|
1537
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1538
|
+
maybe_log("Suppressed exception during drain_inbox_after_crash: #{e.message} (#{e.class})")
|
|
1539
|
+
end
|
|
1540
|
+
|
|
1541
|
+
##
|
|
1542
|
+
# Wait until all workers have stopped after a crash
|
|
1543
|
+
#
|
|
1544
|
+
def join_workers_after_crash(workers)
|
|
1545
|
+
workers.each_value do |thread|
|
|
1546
|
+
thread.join
|
|
1547
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1548
|
+
maybe_log("Suppressed exception during join_workers_after_crash: #{e.message} (#{e.class})")
|
|
1549
|
+
end
|
|
927
1550
|
end
|
|
928
1551
|
|
|
929
1552
|
##
|
|
@@ -934,23 +1557,101 @@ class Ractor
|
|
|
934
1557
|
# is closed, the worker will send an acknowledgement message and then
|
|
935
1558
|
# terminate.
|
|
936
1559
|
#
|
|
937
|
-
def
|
|
1560
|
+
def worker_loop(worker_num)
|
|
938
1561
|
maybe_log("Worker starting", worker_num: worker_num)
|
|
1562
|
+
pending = {}
|
|
1563
|
+
crash_exception = nil
|
|
1564
|
+
begin
|
|
1565
|
+
run_worker_dispatch_loop(worker_num, pending)
|
|
1566
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1567
|
+
crash_exception = e
|
|
1568
|
+
raise
|
|
1569
|
+
ensure
|
|
1570
|
+
cleanup_worker(worker_num, pending, crash_exception)
|
|
1571
|
+
end
|
|
1572
|
+
end
|
|
1573
|
+
|
|
1574
|
+
##
|
|
1575
|
+
# The dispatch loop body for a worker thread. Loops on
|
|
1576
|
+
# `@dispatcher.dequeue` and routes each result to the appropriate
|
|
1577
|
+
# handler. Exits when the dispatcher signals termination, or when a
|
|
1578
|
+
# graceful close has been observed and the local pending hash is empty.
|
|
1579
|
+
#
|
|
1580
|
+
def run_worker_dispatch_loop(worker_num, pending)
|
|
1581
|
+
stopping = false
|
|
939
1582
|
loop do
|
|
940
|
-
maybe_log("Waiting for
|
|
941
|
-
message = @
|
|
942
|
-
|
|
943
|
-
|
|
1583
|
+
maybe_log("Waiting for work", worker_num: worker_num)
|
|
1584
|
+
kind, message = @dispatcher.dequeue(worker_num, accept_calls: !stopping)
|
|
1585
|
+
case kind
|
|
1586
|
+
when :call then start_worker_fiber(message, pending, worker_num)
|
|
1587
|
+
when :resume then resume_worker_fiber(message, pending)
|
|
1588
|
+
when :closed then stopping = true
|
|
1589
|
+
when :terminate then return
|
|
1590
|
+
end
|
|
1591
|
+
return if stopping && pending.empty?
|
|
944
1592
|
end
|
|
945
|
-
|
|
1593
|
+
end
|
|
1594
|
+
|
|
1595
|
+
##
|
|
1596
|
+
# Worker-thread cleanup: aborts any pending fibers (so their callers
|
|
1597
|
+
# observe `CrashedError`) and reports the worker's stop to the main
|
|
1598
|
+
# loop. Always runs in the worker's ensure block — both for normal exit
|
|
1599
|
+
# and for crash exit.
|
|
1600
|
+
#
|
|
1601
|
+
def cleanup_worker(worker_num, pending, crash_exception = nil)
|
|
946
1602
|
maybe_log("Worker stopping", worker_num: worker_num)
|
|
1603
|
+
if pending && !pending.empty?
|
|
1604
|
+
message =
|
|
1605
|
+
if crash_exception
|
|
1606
|
+
"Worker #{worker_num} crashed: #{crash_exception.message} (#{crash_exception.class})"
|
|
1607
|
+
else
|
|
1608
|
+
"Worker #{worker_num} terminated"
|
|
1609
|
+
end
|
|
1610
|
+
error = CrashedError.new(message)
|
|
1611
|
+
pending.each_key { |fiber_id| @dispatcher.unregister_fiber(fiber_id) }
|
|
1612
|
+
abort_pending_fibers(pending, error)
|
|
1613
|
+
end
|
|
947
1614
|
begin
|
|
948
1615
|
@port.send(WorkerStoppedMessage.new(worker_num))
|
|
949
1616
|
rescue ::Ractor::ClosedError
|
|
950
|
-
maybe_log("
|
|
1617
|
+
maybe_log("Worker unable to report stop, possibly due to server crash", worker_num: worker_num)
|
|
951
1618
|
end
|
|
952
1619
|
end
|
|
953
1620
|
|
|
1621
|
+
##
|
|
1622
|
+
# Start a fiber for a new `CallMessage` on this worker. Registers the
|
|
1623
|
+
# fiber with the dispatcher so future fiber-resume messages route here.
|
|
1624
|
+
# If the fiber completes synchronously (no `Fiber.yield`), it is
|
|
1625
|
+
# immediately removed from the local `pending` hash and unregistered.
|
|
1626
|
+
#
|
|
1627
|
+
def start_worker_fiber(message, pending, worker_num)
|
|
1628
|
+
fiber = ::Fiber.new { handle_method(message, worker_num: worker_num) }
|
|
1629
|
+
fiber_id = fiber.object_id
|
|
1630
|
+
pending[fiber_id] = fiber
|
|
1631
|
+
@dispatcher.register_fiber(fiber_id, worker_num)
|
|
1632
|
+
maybe_log("Starting worker fiber", call_message: message, worker_num: worker_num, fiber_id: fiber_id)
|
|
1633
|
+
fiber.resume
|
|
1634
|
+
return if fiber.alive?
|
|
1635
|
+
pending.delete(fiber_id)
|
|
1636
|
+
@dispatcher.unregister_fiber(fiber_id)
|
|
1637
|
+
end
|
|
1638
|
+
|
|
1639
|
+
##
|
|
1640
|
+
# Resume a previously-suspended fiber with a fiber-result message. If
|
|
1641
|
+
# the fiber is no longer alive (e.g. aborted), the message is silently
|
|
1642
|
+
# discarded. On completion the fiber is removed from `pending` and
|
|
1643
|
+
# unregistered from the dispatcher.
|
|
1644
|
+
#
|
|
1645
|
+
def resume_worker_fiber(message, pending)
|
|
1646
|
+
fiber = pending[message.fiber_id]
|
|
1647
|
+
return unless fiber
|
|
1648
|
+
maybe_log("Resuming worker fiber", fiber_id: message.fiber_id)
|
|
1649
|
+
fiber.resume(message)
|
|
1650
|
+
return if fiber.alive?
|
|
1651
|
+
pending.delete(message.fiber_id)
|
|
1652
|
+
@dispatcher.unregister_fiber(message.fiber_id)
|
|
1653
|
+
end
|
|
1654
|
+
|
|
954
1655
|
##
|
|
955
1656
|
# This is called to handle a method call request.
|
|
956
1657
|
# It calls the method on the wrapped object, and then sends back a
|
|
@@ -963,20 +1664,20 @@ class Ractor
|
|
|
963
1664
|
def handle_method(message, worker_num: nil)
|
|
964
1665
|
block = make_block(message)
|
|
965
1666
|
maybe_log("Running method", worker_num: worker_num, call_message: message)
|
|
1667
|
+
result = @object.__send__(message.method_name, *message.args, **message.kwargs, &block)
|
|
1668
|
+
result = @stub if result.equal?(@object)
|
|
1669
|
+
result = nil if message.settings.results == :void
|
|
1670
|
+
maybe_log("Sending return value", worker_num: worker_num, call_message: message)
|
|
1671
|
+
message.reply_port.send(ReturnMessage.new(result), move: message.settings.results == :move)
|
|
1672
|
+
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
1673
|
+
maybe_log("Sending exception", worker_num: worker_num, call_message: message)
|
|
966
1674
|
begin
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
message.reply_port.send(ReturnMessage.new(result), move: message.settings.move_results?)
|
|
970
|
-
rescue ::Exception => e # rubocop:disable Lint/RescueException
|
|
971
|
-
maybe_log("Sending exception", worker_num: worker_num, call_message: message)
|
|
1675
|
+
message.reply_port.send(ExceptionMessage.new(e))
|
|
1676
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
972
1677
|
begin
|
|
973
|
-
message.reply_port.send(ExceptionMessage.new(e))
|
|
974
|
-
rescue ::
|
|
975
|
-
|
|
976
|
-
message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
|
|
977
|
-
rescue ::StandardError
|
|
978
|
-
maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
|
|
979
|
-
end
|
|
1678
|
+
message.reply_port.send(ExceptionMessage.new(::RuntimeError.new(e.inspect)))
|
|
1679
|
+
rescue ::Exception # rubocop:disable Lint/RescueException
|
|
1680
|
+
maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
|
|
980
1681
|
end
|
|
981
1682
|
end
|
|
982
1683
|
end
|
|
@@ -991,23 +1692,77 @@ class Ractor
|
|
|
991
1692
|
# with the block arguments, to run the block in the caller's
|
|
992
1693
|
# environment
|
|
993
1694
|
#
|
|
1695
|
+
# The returned proc uses the fiber-suspend path (Fiber.yield) so the
|
|
1696
|
+
# server can continue processing other messages, including re-entrant
|
|
1697
|
+
# calls from inside the block. In threaded mode, fiber resumes are
|
|
1698
|
+
# routed back to the same worker that started the fiber via the
|
|
1699
|
+
# `Dispatcher`'s per-worker queues (Ruby fibers cannot migrate between
|
|
1700
|
+
# threads).
|
|
1701
|
+
#
|
|
1702
|
+
# Hybrid fallback: if the proc is invoked from a different fiber than
|
|
1703
|
+
# the method-handling fiber (e.g. from inside an Enumerator generator
|
|
1704
|
+
# or a spawned fiber), the fiber path would call Fiber.yield on the
|
|
1705
|
+
# wrong fiber. In that case the proc falls back to the blocking path.
|
|
1706
|
+
#
|
|
994
1707
|
def make_block(message)
|
|
995
1708
|
return message.block_arg unless message.block_arg == :send_block_message
|
|
1709
|
+
expected_fiber = ::Fiber.current
|
|
996
1710
|
proc do |*args, **kwargs|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
when ExceptionMessage
|
|
1004
|
-
raise reply_message.exception
|
|
1005
|
-
when ReturnMessage
|
|
1006
|
-
reply_message.value
|
|
1711
|
+
args.map! { |arg| arg.equal?(@object) ? @stub : arg }
|
|
1712
|
+
kwargs.transform_values! { |arg| arg.equal?(@object) ? @stub : arg }
|
|
1713
|
+
if ::Fiber.current.equal?(expected_fiber)
|
|
1714
|
+
fiber_yield_block(message, args, kwargs)
|
|
1715
|
+
else
|
|
1716
|
+
blocking_yield_block(message, args, kwargs)
|
|
1007
1717
|
end
|
|
1008
1718
|
end
|
|
1009
1719
|
end
|
|
1010
1720
|
|
|
1721
|
+
##
|
|
1722
|
+
# Yield to a caller-side block via the fiber-suspend path. The current
|
|
1723
|
+
# fiber's id is sent in the FiberYieldMessage so the caller knows which
|
|
1724
|
+
# FiberReturnMessage/FiberExceptionMessage to send back. The fiber then
|
|
1725
|
+
# suspends; main_loop will resume it with the reply message when one
|
|
1726
|
+
# arrives on @port.
|
|
1727
|
+
#
|
|
1728
|
+
def fiber_yield_block(message, args, kwargs)
|
|
1729
|
+
fiber_id = ::Fiber.current.object_id
|
|
1730
|
+
yield_message = FiberYieldMessage.new(args: args, kwargs: kwargs, fiber_id: fiber_id)
|
|
1731
|
+
maybe_log("Yielding to caller-side block", call_message: message, fiber_id: fiber_id)
|
|
1732
|
+
message.reply_port.send(yield_message, move: message.settings.block_arguments == :move)
|
|
1733
|
+
reply = ::Fiber.yield
|
|
1734
|
+
maybe_log("Resumed after block reply", call_message: message, fiber_id: fiber_id)
|
|
1735
|
+
case reply
|
|
1736
|
+
when FiberExceptionMessage
|
|
1737
|
+
raise reply.exception
|
|
1738
|
+
when FiberReturnMessage
|
|
1739
|
+
reply.value
|
|
1740
|
+
end
|
|
1741
|
+
end
|
|
1742
|
+
|
|
1743
|
+
##
|
|
1744
|
+
# Yield to a caller-side block via the blocking-fallback path: allocate
|
|
1745
|
+
# a temporary reply_port and block waiting for a response on it. Used
|
|
1746
|
+
# when the fiber-suspend path is not available (nested-fiber and
|
|
1747
|
+
# spawned-thread invocations).
|
|
1748
|
+
#
|
|
1749
|
+
def blocking_yield_block(message, args, kwargs)
|
|
1750
|
+
reply_port = ::Ractor::Port.new
|
|
1751
|
+
reply_message = begin
|
|
1752
|
+
yield_message = BlockingYieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port)
|
|
1753
|
+
message.reply_port.send(yield_message, move: message.settings.block_arguments == :move)
|
|
1754
|
+
reply_port.receive
|
|
1755
|
+
ensure
|
|
1756
|
+
reply_port.close
|
|
1757
|
+
end
|
|
1758
|
+
case reply_message
|
|
1759
|
+
when ExceptionMessage
|
|
1760
|
+
raise reply_message.exception
|
|
1761
|
+
when ReturnMessage
|
|
1762
|
+
reply_message.value
|
|
1763
|
+
end
|
|
1764
|
+
end
|
|
1765
|
+
|
|
1011
1766
|
##
|
|
1012
1767
|
# This is called from the main Ractor thread to report to a caller that
|
|
1013
1768
|
# the wrapper cannot handle a requested method call, likely because the
|
|
@@ -1016,7 +1771,7 @@ class Ractor
|
|
|
1016
1771
|
def refuse_method(message)
|
|
1017
1772
|
maybe_log("Refusing method call", call_message: message)
|
|
1018
1773
|
begin
|
|
1019
|
-
error =
|
|
1774
|
+
error = StoppedError.new("Wrapper is shutting down")
|
|
1020
1775
|
message.reply_port.send(ExceptionMessage.new(error))
|
|
1021
1776
|
rescue ::Ractor::Error
|
|
1022
1777
|
maybe_log("Failed to send refusal message", call_message: message)
|
|
@@ -1027,7 +1782,7 @@ class Ractor
|
|
|
1027
1782
|
# This attempts to send a signal that a wrapper join has completed.
|
|
1028
1783
|
#
|
|
1029
1784
|
def send_join_reply(port)
|
|
1030
|
-
port.send(
|
|
1785
|
+
port.send(JoinReplyMessage.new.freeze)
|
|
1031
1786
|
rescue ::Ractor::ClosedError
|
|
1032
1787
|
maybe_log("Join reply port is closed")
|
|
1033
1788
|
end
|
|
@@ -1035,17 +1790,21 @@ class Ractor
|
|
|
1035
1790
|
##
|
|
1036
1791
|
# Print out a log message
|
|
1037
1792
|
#
|
|
1038
|
-
def maybe_log(str, call_message: nil, worker_num: nil,
|
|
1793
|
+
def maybe_log(str, call_message: nil, worker_num: nil, fiber_id: nil,
|
|
1794
|
+
transaction: nil, method_name: nil)
|
|
1039
1795
|
return unless @enable_logging
|
|
1040
1796
|
transaction ||= call_message&.transaction
|
|
1041
1797
|
method_name ||= call_message&.method_name
|
|
1042
|
-
metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper
|
|
1043
|
-
metadata << "Worker
|
|
1044
|
-
metadata << "
|
|
1045
|
-
metadata << "
|
|
1798
|
+
metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper:#{@name}"]
|
|
1799
|
+
metadata << "Worker:#{worker_num}" if worker_num
|
|
1800
|
+
metadata << "Fiber:#{fiber_id}" if fiber_id
|
|
1801
|
+
metadata << "Transaction:#{transaction}" if transaction
|
|
1802
|
+
metadata << "Method:#{method_name}" if method_name
|
|
1046
1803
|
metadata = metadata.join(" ")
|
|
1047
1804
|
$stderr.puts("[#{metadata}] #{str}")
|
|
1048
1805
|
$stderr.flush
|
|
1806
|
+
rescue ::StandardError
|
|
1807
|
+
# Swallow any errors during logging
|
|
1049
1808
|
end
|
|
1050
1809
|
end
|
|
1051
1810
|
end
|