ractor-wrapper 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,247 +1,595 @@
1
+ # frozen_string_literal: true
2
+
1
3
  ##
2
- # See ruby-doc.org for info on Ractors.
4
+ # See https://docs.ruby-lang.org/en/4.0/language/ractor_md.html for info on
5
+ # Ractors.
3
6
  #
4
7
  class Ractor
5
8
  ##
6
- # An experimental class that wraps a non-shareable object, allowing multiple
7
- # Ractors to access it concurrently.
9
+ # An experimental class that wraps a non-shareable object in an actor,
10
+ # allowing multiple Ractors to access it concurrently.
8
11
  #
9
12
  # WARNING: This is a highly experimental library, and currently _not_
10
- # recommended for production use. (As of Ruby 3.0.0, the same can be said of
13
+ # recommended for production use. (As of Ruby 4.0.0, the same can be said of
11
14
  # Ractors in general.)
12
15
  #
13
16
  # ## What is Ractor::Wrapper?
14
17
  #
15
- # Ractors for the most part cannot access objects concurrently with other
16
- # Ractors unless the object is _shareable_ (that is, deeply immutable along
17
- # with a few other restrictions.) If multiple Ractors need to interact with a
18
- # shared resource that is stateful or otherwise not Ractor-shareable, that
19
- # resource must itself be implemented and accessed as a Ractor.
18
+ # For the most part, unless an object is _sharable_, which generally means
19
+ # deeply immutable along with a few other restrictions, it cannot be accessed
20
+ # directly from another Ractor. This makes it difficult for multiple Ractors
21
+ # to share a resource that is stateful. Such a resource must typically itself
22
+ # be implemented as a Ractor and accessed via message passing.
23
+ #
24
+ # Ractor::Wrapper makes it possible for an ordinary non-shareable object to
25
+ # be accessed from multiple Ractors. It does this by "wrapping" the object
26
+ # with an actor that listens for messages and invokes the object's methods in
27
+ # a controlled single-Ractor environment. It then provides a stub object that
28
+ # reproduces the interface of the original object, but responds to method
29
+ # calls by sending messages to the wrapper. Ractor::Wrapper can be used to
30
+ # implement simple actors by writing "plain" Ruby objects, or to adapt
31
+ # existing non-shareable objects to a multi-Ractor world.
32
+ #
33
+ # ## Net::HTTP example
34
+ #
35
+ # The following example shows how to share a single Net::HTTP session object
36
+ # among multiple Ractors.
37
+ #
38
+ # require "ractor/wrapper"
39
+ # require "net/http"
40
+ #
41
+ # # Create a Net::HTTP session. Net::HTTP sessions are not shareable,
42
+ # # so normally only one Ractor can access them at a time.
43
+ # http = Net::HTTP.new("example.com")
44
+ # http.start
45
+ #
46
+ # # Create a wrapper around the session. This moves the session into an
47
+ # # internal Ractor and listens for method call requests. By default, a
48
+ # # wrapper serializes calls, handling one at a time, for compatibility
49
+ # # with non-thread-safe objects.
50
+ # wrapper = Ractor::Wrapper.new(http)
51
+ #
52
+ # # At this point, the session object can no longer be accessed directly
53
+ # # because it is now owned by the wrapper's internal Ractor.
54
+ # # http.get("/whoops") # <= raises Ractor::MovedError
55
+ #
56
+ # # However, you can access the session via the stub object provided by
57
+ # # the wrapper. This stub proxies the call to the wrapper's internal
58
+ # # Ractor. And it's shareable, so any number of Ractors can use it.
59
+ # response = wrapper.stub.get("/")
60
+ #
61
+ # # Here, we start two Ractors, and pass the stub to each one. Each
62
+ # # Ractor can simply call methods on the stub as if it were the original
63
+ # # connection object. Internally, of course, the calls are proxied to
64
+ # # the original object via the wrapper, and execution is serialized.
65
+ # r1 = Ractor.new(wrapper.stub) do |stub|
66
+ # 5.times do
67
+ # stub.get("/hello")
68
+ # end
69
+ # :ok
70
+ # end
71
+ # r2 = Ractor.new(wrapper.stub) do |stub|
72
+ # 5.times do
73
+ # stub.get("/ruby")
74
+ # end
75
+ # :ok
76
+ # end
77
+ #
78
+ # # Wait for the two above Ractors to finish.
79
+ # r1.join
80
+ # r2.join
81
+ #
82
+ # # After you stop the wrapper, you can retrieve the underlying session
83
+ # # object and access it directly again.
84
+ # wrapper.async_stop
85
+ # http = wrapper.recover_object
86
+ # http.finish
20
87
  #
21
- # `Ractor::Wrapper` makes it possible for such a shared resource to be
22
- # implemented as an object and accessed using ordinary method calls. It does
23
- # this by "wrapping" the object in a Ractor, and mapping method calls to
24
- # message passing. This may make it easier to implement such a resource with
25
- # a simple class rather than a full-blown Ractor with message passing, and it
26
- # may also useful for adapting existing legacy object-based implementations.
88
+ # ## SQLite3 example
27
89
  #
28
- # Given a shared resource object, `Ractor::Wrapper` starts a new Ractor and
29
- # "runs" the object within that Ractor. It provides you with a stub object
30
- # on which you can invoke methods. The wrapper responds to these method calls
31
- # by sending messages to the internal Ractor, which invokes the shared object
32
- # and then sends back the result. If the underlying object is thread-safe,
33
- # you can configure the wrapper to run multiple threads that can run methods
34
- # concurrently. Or, if not, the wrapper can serialize requests to the object.
90
+ # The following example shows how to share a SQLite3 database among multiple
91
+ # Ractors.
35
92
  #
36
- # ## Example usage
93
+ # require "ractor/wrapper"
94
+ # require "sqlite3"
37
95
  #
38
- # The following example shows how to share a single `Faraday::Conection`
39
- # object among multiple Ractors. Because `Faraday::Connection` is not itself
40
- # thread-safe, this example serializes all calls to it.
96
+ # # Create a SQLite3 database. These objects are not shareable, so
97
+ # # normally only one Ractor can access them.
98
+ # db = SQLite3::Database.new($my_database_path)
41
99
  #
42
- # require "faraday"
100
+ # # Create a wrapper around the database. A SQLite3::Database object
101
+ # # cannot be moved between Ractors, so we configure the wrapper to run
102
+ # # in the current Ractor. We can also configure it to run multiple
103
+ # # worker threads because the database object itself is thread-safe.
104
+ # wrapper = Ractor::Wrapper.new(db, use_current_ractor: true, threads: 2)
43
105
  #
44
- # # Create a Faraday connection and a wrapper for it.
45
- # connection = Faraday.new "http://example.com"
46
- # wrapper = Ractor::Wrapper.new(connection)
106
+ # # At this point, the database object can still be accessed directly
107
+ # # because it hasn't been moved to a different Ractor.
108
+ # rows = db.execute("select * from numbers")
47
109
  #
48
- # # At this point, the connection object cannot be accessed directly
49
- # # because it has been "moved" to the wrapper's internal Ractor.
50
- # # connection.get("/whoops") # <= raises an error
110
+ # # You can also access the database via the stub object provided by the
111
+ # # wrapper.
112
+ # rows = wrapper.stub.execute("select * from numbers")
51
113
  #
52
- # # However, any number of Ractors can now access it through the wrapper.
53
- # # By default, access to the object is serialized; methods will not be
54
- # # invoked concurrently.
55
- # r1 = Ractor.new(wrapper) do |w|
56
- # 10.times do
57
- # w.stub.get("/hello")
114
+ # # Here, we start two Ractors, and pass the stub to each one. The
115
+ # # wrapper's worker threads will handle the requests concurrently.
116
+ # r1 = Ractor.new(wrapper.stub) do |stub|
117
+ # 5.times do
118
+ # stub.execute("select * from numbers")
58
119
  # end
59
120
  # :ok
60
121
  # end
61
- # r2 = Ractor.new(wrapper) do |w|
62
- # 10.times do
63
- # w.stub.get("/ruby")
122
+ # r2 = Ractor.new(wrapper.stub) do |stub|
123
+ # 5.times do
124
+ # stub.execute("select * from numbers")
64
125
  # end
65
126
  # :ok
66
127
  # end
67
128
  #
68
129
  # # Wait for the two above Ractors to finish.
69
- # r1.take
70
- # r2.take
130
+ # r1.join
131
+ # r2.join
71
132
  #
72
- # # After you stop the wrapper, you can retrieve the underlying
73
- # # connection object and access it directly again.
133
+ # # After stopping the wrapper, you can call the join method to wait for
134
+ # # it to completely finish.
74
135
  # wrapper.async_stop
75
- # connection = wrapper.recover_object
76
- # connection.get("/finally")
136
+ # wrapper.join
137
+ #
138
+ # # When running a wrapper with :use_current_ractor, you do not need to
139
+ # # recover the object, because it was never moved. The recover_object
140
+ # # method is not available.
141
+ # # db2 = wrapper.recover_object # <= raises Ractor::Wrapper::Error
77
142
  #
78
143
  # ## Features
79
144
  #
80
- # * Provides a method interface to an object running in a different Ractor.
145
+ # * Provides a Ractor-shareable method interface to a non-shareable object.
81
146
  # * Supports arbitrary method arguments and return values.
82
- # * Supports exceptions thrown by the method.
83
- # * Can be configured to copy or move arguments, return values, and
84
- # exceptions, per method.
85
- # * Can serialize method calls for non-concurrency-safe objects, or run
86
- # methods concurrently in multiple worker threads for thread-safe objects.
147
+ # * Can be configured to run in its own isolated Ractor or in a Thread in
148
+ # the current Ractor.
149
+ # * Can be configured per method whether to copy or move arguments and
150
+ # return values.
151
+ # * Blocks can be run in the calling Ractor or in the object Ractor.
152
+ # * Raises exceptions thrown by the method.
153
+ # * Can serialize method calls for non-thread-safe objects, or run methods
154
+ # concurrently in multiple worker threads for thread-safe objects.
87
155
  # * Can gracefully shut down the wrapper and retrieve the original object.
88
156
  #
89
157
  # ## Caveats
90
158
  #
91
- # Ractor::Wrapper is subject to some limitations (and bugs) of Ractors, as of
92
- # Ruby 3.0.0.
93
- #
94
- # * You cannot pass blocks to wrapped methods.
95
159
  # * Certain types cannot be used as method arguments or return values
96
- # because Ractor does not allow them to be moved between Ractors. These
97
- # include threads, procs, backtraces, and a few others.
98
- # * You can call wrapper methods from multiple Ractors concurrently, but
99
- # you cannot call them from multiple Threads within a single Ractor.
100
- # (This is due to https://bugs.ruby-lang.org/issues/17624)
101
- # * If you close the incoming port on a Ractor, it will no longer be able
102
- # to call out via a wrapper. If you close its incoming port while a call
103
- # is currently pending, that call may hang. (This is due to
104
- # https://bugs.ruby-lang.org/issues/17617)
160
+ # because they cannot be moved between Ractors. As of Ruby 4.0.0, these
161
+ # include threads, backtraces, procs, and a few others.
162
+ # * As of Ruby 4.0.0, any exceptions raised are always copied (rather than
163
+ # moved) back to the calling Ractor, and the backtrace is cleared out.
164
+ # This is due to https://bugs.ruby-lang.org/issues/21818
165
+ # * Blocks can be run "in place" (i.e. in the wrapped object context) only
166
+ # if the block does not access any data outside the block. Otherwise, the
167
+ # block must be run in caller's context.
168
+ # * Blocks configured to run in the caller's context can only be run while
169
+ # a method is executing. They cannot be "saved" as a proc to be run
170
+ # later unless they are configured to run "in place". In particular,
171
+ # using blocks as a syntax to define callbacks can generally not be done
172
+ # through a wrapper.
105
173
  #
106
174
  class Wrapper
107
175
  ##
108
- # Create a wrapper around the given object.
176
+ # Base class for errors raised by {Ractor::Wrapper}.
109
177
  #
110
- # If you pass an optional block, the wrapper itself will be yielded to it
111
- # at which time you can set additional configuration options. (The
112
- # configuration is frozen once the object is constructed.)
113
- #
114
- # @param object [Object] The non-shareable object to wrap.
115
- # @param threads [Integer] The number of worker threads to run.
116
- # Defaults to 1, which causes the worker to serialize calls.
178
+ class Error < ::Ractor::Error; end
179
+
180
+ ##
181
+ # Raised when a {Ractor::Wrapper} server has crashed unexpectedly.
117
182
  #
118
- def initialize(object,
119
- threads: 1,
120
- move: false,
121
- move_arguments: nil,
122
- move_return: nil,
123
- logging: false,
124
- name: nil)
125
- @method_settings = {}
126
- self.threads = threads
127
- self.logging = logging
128
- self.name = name
129
- configure_method(move: move, move_arguments: move_arguments, move_return: move_return)
130
- yield self if block_given?
131
- @method_settings.freeze
132
-
133
- maybe_log("Starting server")
134
- @ractor = ::Ractor.new(name: name) { Server.new.run }
135
- opts = {
136
- object: object,
137
- threads: @threads,
138
- method_settings: @method_settings,
139
- name: @name,
140
- logging: @logging,
141
- }
142
- @ractor.send(opts, move: true)
143
-
144
- maybe_log("Server ready")
145
- @stub = Stub.new(self)
146
- freeze
147
- end
183
+ class CrashedError < Error; end
148
184
 
149
185
  ##
150
- # Set the number of threads to run in the wrapper. If the underlying object
151
- # is thread-safe, this allows concurrent calls to it. If the underlying
152
- # object is not thread-safe, you should leave this set to its default of 1,
153
- # which effectively causes calls to be serialized.
186
+ # Raised when calling a method on a {Ractor::Wrapper} whose server has
187
+ # stopped and is no longer accepting calls.
154
188
  #
155
- # This method can be called only during an initialization block.
156
- # All settings are frozen once the wrapper is active.
189
+ class StoppedError < Error; end
190
+
191
+ ##
192
+ # A stub that forwards calls to a wrapper.
157
193
  #
158
- # @param value [Integer]
194
+ # This object is shareable and can be passed to any Ractor.
159
195
  #
160
- def threads=(value)
161
- value = value.to_i
162
- value = 1 if value < 1
163
- @threads = value
196
+ class Stub
197
+ ##
198
+ # Create a stub given a wrapper.
199
+ #
200
+ # @param wrapper [Ractor::Wrapper]
201
+ #
202
+ def initialize(wrapper)
203
+ @wrapper = wrapper
204
+ freeze
205
+ end
206
+
207
+ ##
208
+ # Forward calls to {Ractor::Wrapper#call}.
209
+ # @private
210
+ #
211
+ def method_missing(name, ...)
212
+ @wrapper.call(name, ...)
213
+ end
214
+
215
+ ##
216
+ # Forward respond_to queries.
217
+ # @private
218
+ #
219
+ def respond_to_missing?(name, include_all)
220
+ @wrapper.call(:respond_to?, name, include_all)
221
+ end
164
222
  end
165
223
 
166
224
  ##
167
- # Enable or disable internal debug logging.
168
- #
169
- # This method can be called only during an initialization block.
170
- # All settings are frozen once the wrapper is active.
225
+ # Configuration for a {Ractor::Wrapper}. An instance of this class is
226
+ # yielded by {Ractor::Wrapper#initialize} if a block is provided. Any
227
+ # settings made to the Configuration before the block returns take
228
+ # effect when the Wrapper is constructed.
171
229
  #
172
- # @param value [Boolean]
230
+ class Configuration
231
+ ##
232
+ # Set the name of the wrapper. This is shown in logging and is also
233
+ # used as the name of the wrapping Ractor.
234
+ #
235
+ # @param value [String, nil]
236
+ #
237
+ def name=(value)
238
+ @name = value ? value.to_s.freeze : nil
239
+ end
240
+
241
+ ##
242
+ # Enable or disable internal debug logging.
243
+ #
244
+ # @param value [Boolean]
245
+ #
246
+ def enable_logging=(value)
247
+ @enable_logging = value ? true : false
248
+ end
249
+
250
+ ##
251
+ # Set the number of worker threads. If the underlying object is
252
+ # thread-safe, a value of 2 or more allows concurrent calls. Leave at
253
+ # the default of 0 to handle calls sequentially without worker threads.
254
+ #
255
+ # @param value [Integer]
256
+ #
257
+ def threads=(value)
258
+ value = value.to_i
259
+ value = 0 if value.negative?
260
+ @threads = value
261
+ end
262
+
263
+ ##
264
+ # If set to true, the wrapper server runs as Thread(s) inside the
265
+ # current Ractor rather than spawning a new isolated Ractor. Use this
266
+ # for objects that cannot be moved between Ractors.
267
+ #
268
+ # @param value [Boolean]
269
+ #
270
+ def use_current_ractor=(value)
271
+ @use_current_ractor = value ? true : false
272
+ end
273
+
274
+ ##
275
+ # Configure how argument and return values are communicated for the given
276
+ # method.
277
+ #
278
+ # In general, the following values are recognized for the data-moving
279
+ # settings:
280
+ #
281
+ # * `:copy` - Method arguments or return values that are not shareable,
282
+ # are *deep copied* when communicated between the caller and the object.
283
+ # * `:move` - Method arguments or return values that are not shareable,
284
+ # are *moved* when communicated between the caller and the object. This
285
+ # means they are no longer available to the source; that is, the caller
286
+ # can no longer access objects that were moved to method arguments, and
287
+ # the wrapped object can no longer access objects that were used as
288
+ # return values.
289
+ # * `:void` - This option is available for return values and block
290
+ # results. It disables return values for the given method, and is
291
+ # intended to avoid copying or moving objects that are not intended to
292
+ # be return values. The recipient will receive `nil`.
293
+ #
294
+ # The following settings are recognized for the `block_environment`
295
+ # setting:
296
+ #
297
+ # * `:caller` - Blocks are executed in the caller's context. This means
298
+ # the wrapper sends a message back to the caller to execute the block
299
+ # in its original context. This means the block will have access to its
300
+ # lexical scope and any other data available to the calling Ractor.
301
+ # * `:wrapped` - Blocks are executed directly in the wrapped object's
302
+ # context. This does not require any communication, but it means the
303
+ # block is removed from the caller's environment and does not have
304
+ # access to the caller's lexical scope or Ractor-accessible data.
305
+ #
306
+ # All settings are optional. If not provided, they will fall back to a
307
+ # default. If you are configuring a particular method, by specifying the
308
+ # `method_name` argument, any unspecified setting will fall back to the
309
+ # method default settings (which you can set by omitting the method name.)
310
+ # If you are configuring the method default settings, by omitting the
311
+ # `method_name` argument, unspecified settings will fall back to `:copy`
312
+ # for the data movement settings, and `:caller` for the
313
+ # `block_environment` setting.
314
+ #
315
+ # @param method_name [Symbol,nil] The name of the method being configured,
316
+ # or `nil` to set defaults for all methods not configured explicitly.
317
+ # @param arguments [:move,:copy] How to communicate method arguments.
318
+ # @param results [:move,:copy,:void] How to communicate method return
319
+ # values.
320
+ # @param block_arguments [:move,:copy] How to communicate block arguments.
321
+ # @param block_results [:move,:copy,:void] How to communicate block
322
+ # result values.
323
+ # @param block_environment [:caller,:wrapped] How to execute blocks, and
324
+ # what scope blocks have access to.
325
+ #
326
+ def configure_method(method_name = nil,
327
+ arguments: nil,
328
+ results: nil,
329
+ block_arguments: nil,
330
+ block_results: nil,
331
+ block_environment: nil)
332
+ method_name = method_name.to_sym unless method_name.nil?
333
+ @method_settings[method_name] =
334
+ MethodSettings.new(arguments: arguments,
335
+ results: results,
336
+ block_arguments: block_arguments,
337
+ block_results: block_results,
338
+ block_environment: block_environment)
339
+ self
340
+ end
341
+
342
+ ##
343
+ # @private
344
+ # Return the name of the wrapper.
345
+ #
346
+ # @return [String, nil]
347
+ #
348
+ attr_reader :name
349
+
350
+ ##
351
+ # @private
352
+ # Return whether logging is enabled.
353
+ #
354
+ # @return [Boolean]
355
+ #
356
+ attr_reader :enable_logging
357
+
358
+ ##
359
+ # @private
360
+ # Return the number of worker threads.
361
+ #
362
+ # @return [Integer]
363
+ #
364
+ attr_reader :threads
365
+
366
+ ##
367
+ # @private
368
+ # Return whether the wrapper runs in the current Ractor.
369
+ #
370
+ # @return [Boolean]
371
+ #
372
+ attr_reader :use_current_ractor
373
+
374
+ ##
375
+ # @private
376
+ # Resolve the method settings by filling in the defaults for all fields
377
+ # not explicitly set, and return the final settings keyed by method name.
378
+ # The `nil` key will contain defaults for method names not explicitly
379
+ # configured. This hash will be frozen and shareable.
380
+ #
381
+ # @return [Hash{(Symbol,nil)=>MethodSettings}]
382
+ #
383
+ def final_method_settings
384
+ fallback = MethodSettings.new(arguments: :copy, results: :copy,
385
+ block_arguments: :copy, block_results: :copy,
386
+ block_environment: :caller)
387
+ defaults = MethodSettings.with_fallback(@method_settings[nil], fallback)
388
+ results = {nil => defaults}
389
+ @method_settings.each do |name, settings|
390
+ next if name.nil?
391
+ results[name] = MethodSettings.with_fallback(settings, defaults)
392
+ end
393
+ results.freeze
394
+ end
395
+
396
+ ##
397
+ # @private
398
+ # Create an empty configuration.
399
+ #
400
+ def initialize
401
+ @method_settings = {}
402
+ configure_method(arguments: nil,
403
+ results: nil,
404
+ block_arguments: nil,
405
+ block_results: nil,
406
+ block_environment: nil)
407
+ end
408
+ end
409
+
410
+ ##
411
+ # Settings for a method call. Specifies how a method's arguments and
412
+ # return value are communicated (i.e. copy or move semantics.)
173
413
  #
174
- def logging=(value)
175
- @logging = value ? true : false
414
+ class MethodSettings
415
+ # @private
416
+ def initialize(arguments: nil,
417
+ results: nil,
418
+ block_arguments: nil,
419
+ block_results: nil,
420
+ block_environment: nil)
421
+ unless [nil, :copy, :move].include?(arguments)
422
+ raise ::ArgumentError, "Unknown `arguments`: #{arguments.inspect} (must be :copy or :move)"
423
+ end
424
+ unless [nil, :copy, :move, :void].include?(results)
425
+ raise ::ArgumentError, "Unknown `results`: #{results.inspect} (must be :copy, :move, or :void)"
426
+ end
427
+ unless [nil, :copy, :move].include?(block_arguments)
428
+ raise ::ArgumentError, "Unknown `block_arguments`: #{block_arguments.inspect} (must be :copy or :move)"
429
+ end
430
+ unless [nil, :copy, :move, :void].include?(block_results)
431
+ raise ::ArgumentError, "Unknown `block_results`: #{block_results.inspect} (must be :copy, :move, or :void)"
432
+ end
433
+ unless [nil, :caller, :wrapped].include?(block_environment)
434
+ raise ::ArgumentError,
435
+ "Unknown `block_environment`: #{block_environment.inspect} (must be :caller or :wrapped)"
436
+ end
437
+ @arguments = arguments
438
+ @results = results
439
+ @block_arguments = block_arguments
440
+ @block_results = block_results
441
+ @block_environment = block_environment
442
+ freeze
443
+ end
444
+
445
+ ##
446
+ # @return [:copy,:move] How to communicate method arguments
447
+ # @return [nil] if not set (will not happen in final settings)
448
+ #
449
+ attr_reader :arguments
450
+
451
+ ##
452
+ # @return [:copy,:move,:void] How to communicate method return values
453
+ # @return [nil] if not set (will not happen in final settings)
454
+ #
455
+ attr_reader :results
456
+
457
+ ##
458
+ # @return [:copy,:move] How to communicate arguments to a block
459
+ # @return [nil] if not set (will not happen in final settings)
460
+ #
461
+ attr_reader :block_arguments
462
+
463
+ ##
464
+ # @return [:copy,:move,:void] How to communicate block results
465
+ # @return [nil] if not set (will not happen in final settings)
466
+ #
467
+ attr_reader :block_results
468
+
469
+ ##
470
+ # @return [:caller,:wrapped] What environment blocks execute in
471
+ # @return [nil] if not set (will not happen in final settings)
472
+ #
473
+ attr_reader :block_environment
474
+
475
+ # @private
476
+ def self.with_fallback(settings, fallback)
477
+ new(
478
+ arguments: settings.arguments || fallback.arguments,
479
+ results: settings.results || fallback.results,
480
+ block_arguments: settings.block_arguments || fallback.block_arguments,
481
+ block_results: settings.block_results || fallback.block_results,
482
+ block_environment: settings.block_environment || fallback.block_environment
483
+ )
484
+ end
176
485
  end
177
486
 
178
487
  ##
179
- # Set the name of this wrapper. This is shown in logging, and is also used
180
- # as the name of the wrapping Ractor.
488
+ # Create a wrapper around the given object.
181
489
  #
182
- # This method can be called only during an initialization block.
183
- # All settings are frozen once the wrapper is active.
490
+ # If you pass an optional block, a {Ractor::Wrapper::Configuration} object
491
+ # will be yielded to it, allowing additional configuration before the wrapper
492
+ # starts. In particular, per-method configuration must be set in this block.
493
+ # Block-provided settings override keyword arguments.
184
494
  #
185
- # @param value [String, nil]
495
+ # See {Configuration} for more information about the method communication
496
+ # and block settings.
186
497
  #
187
- def name=(value)
188
- @name = value ? value.to_s.freeze : nil
189
- end
498
+ # @param object [Object] The non-shareable object to wrap.
499
+ # @param use_current_ractor [boolean] If true, the wrapper is run in a
500
+ # thread in the current Ractor instead of spawning a new Ractor (the
501
+ # default behavior). This option can be used if the wrapped object
502
+ # cannot be moved or must run in the main Ractor. Can also be set via
503
+ # the configuration block.
504
+ # @param name [String] A name for this wrapper. Used during logging. Can
505
+ # also be set via the configuration block. Defaults to the object_id.
506
+ # @param threads [Integer] The number of worker threads to run.
507
+ # Defaults to 0, which causes the wrapper to run sequentially without
508
+ # spawning workers. Can also be set via the configuration block.
509
+ # @param arguments [:move,:copy] How to communicate method arguments by
510
+ # default. If not specified, defaults to `:copy`.
511
+ # @param results [:move,:copy,:void] How to communicate method return
512
+ # values by default. If not specified, defaults to `:copy`.
513
+ # @param block_arguments [:move,:copy] How to communicate block arguments
514
+ # by default. If not specified, defaults to `:copy`.
515
+ # @param block_results [:move,:copy,:void] How to communicate block result
516
+ # values by default. If not specified, defaults to `:copy`.
517
+ # @param block_environment [:caller,:wrapped] How to execute blocks, and
518
+ # what scope blocks have access to. If not specified, defaults to
519
+ # `:caller`.
520
+ # @param enable_logging [boolean] Set to true to enable logging. Default
521
+ # is false. Can also be set via the configuration block.
522
+ # @yield [config] An optional configuration block.
523
+ # @yieldparam config [Ractor::Wrapper::Configuration]
524
+ #
525
+ def initialize(object,
526
+ use_current_ractor: false,
527
+ name: nil,
528
+ threads: 0,
529
+ arguments: nil,
530
+ results: nil,
531
+ block_arguments: nil,
532
+ block_results: nil,
533
+ block_environment: nil,
534
+ enable_logging: false)
535
+ raise ::Ractor::MovedError, "cannot wrap a moved object" if ::Ractor::MovedObject === object
190
536
 
191
- ##
192
- # Configure the move semantics for the given method (or the default
193
- # settings if no method name is given.) That is, determine whether
194
- # arguments, return values, and/or exceptions are copied or moved when
195
- # communicated with the wrapper. By default, all objects are copied.
196
- #
197
- # This method can be called only during an initialization block.
198
- # All settings are frozen once the wrapper is active.
199
- #
200
- # @param method_name [Symbol, nil] The name of the method being configured,
201
- # or `nil` to set defaults for all methods not configured explicitly.
202
- # @param move [Boolean] Whether to move all communication. This value, if
203
- # given, is used if `move_arguments`, `move_return`, or
204
- # `move_exceptions` are not set.
205
- # @param move_arguments [Boolean] Whether to move arguments.
206
- # @param move_return [Boolean] Whether to move return values.
207
- #
208
- def configure_method(method_name = nil,
209
- move: false,
210
- move_arguments: nil,
211
- move_return: nil)
212
- method_name = method_name.to_sym unless method_name.nil?
213
- @method_settings[method_name] =
214
- MethodSettings.new(move: move, move_arguments: move_arguments, move_return: move_return)
537
+ config = Configuration.new
538
+ config.name = name || object_id.to_s
539
+ config.enable_logging = enable_logging
540
+ config.threads = threads
541
+ config.use_current_ractor = use_current_ractor
542
+ config.configure_method(arguments: arguments,
543
+ results: results,
544
+ block_arguments: block_arguments,
545
+ block_results: block_results,
546
+ block_environment: block_environment)
547
+ yield config if block_given?
548
+
549
+ @name = config.name
550
+ @enable_logging = config.enable_logging
551
+ @threads = config.threads
552
+ @method_settings = config.final_method_settings
553
+ @stub = Stub.new(self)
554
+
555
+ if config.use_current_ractor
556
+ setup_local_server(object)
557
+ else
558
+ setup_isolated_server(object)
559
+ end
215
560
  end
216
561
 
217
562
  ##
218
- # Return the wrapper stub. This is an object that responds to the same
219
- # methods as the wrapped object, providing an easy way to call a wrapper.
563
+ # Return the name of this wrapper.
220
564
  #
221
- # @return [Ractor::Wrapper::Stub]
565
+ # @return [String]
222
566
  #
223
- attr_reader :stub
567
+ attr_reader :name
224
568
 
225
569
  ##
226
- # Return the number of threads used by the wrapper.
570
+ # Determine whether this wrapper runs in the current Ractor
227
571
  #
228
- # @return [Integer]
572
+ # @return [boolean]
229
573
  #
230
- attr_reader :threads
574
+ def use_current_ractor?
575
+ @ractor.nil?
576
+ end
231
577
 
232
578
  ##
233
579
  # Return whether logging is enabled for this wrapper.
234
580
  #
235
581
  # @return [Boolean]
236
582
  #
237
- attr_reader :logging
583
+ def enable_logging?
584
+ @enable_logging
585
+ end
238
586
 
239
587
  ##
240
- # Return the name of this wrapper.
588
+ # Return the number of worker threads used by the wrapper.
241
589
  #
242
- # @return [String, nil]
590
+ # @return [Integer]
243
591
  #
244
- attr_reader :name
592
+ attr_reader :threads
245
593
 
246
594
  ##
247
595
  # Return the method settings for the given method name. This returns the
@@ -253,10 +601,17 @@ class Ractor
253
601
  # @return [MethodSettings]
254
602
  #
255
603
  def method_settings(method_name)
256
- method_name = method_name.to_sym
257
- @method_settings[method_name] || @method_settings[nil]
604
+ (method_name && @method_settings[method_name.to_sym]) || @method_settings[nil]
258
605
  end
259
606
 
607
+ ##
608
+ # Return the wrapper stub. This is an object that responds to the same
609
+ # methods as the wrapped object, providing an easy way to call a wrapper.
610
+ #
611
+ # @return [Ractor::Wrapper::Stub]
612
+ #
613
+ attr_reader :stub
614
+
260
615
  ##
261
616
  # A lower-level interface for calling methods through the wrapper.
262
617
  #
@@ -265,407 +620,681 @@ class Ractor
265
620
  # @param kwargs [keywords] The keyword arguments
266
621
  # @return [Object] The return value
267
622
  #
268
- def call(method_name, *args, **kwargs)
269
- request = Message.new(:call, data: [method_name, args, kwargs])
270
- transaction = request.transaction
271
- move = method_settings(method_name).move_arguments?
272
- maybe_log("Sending method #{method_name} (move=#{move}, transaction=#{transaction})")
273
- @ractor.send(request, move: move)
274
- reply = ::Ractor.receive_if { |msg| msg.is_a?(Message) && msg.transaction == transaction }
275
- case reply.type
276
- when :result
277
- maybe_log("Received result for method #{method_name} (transaction=#{transaction})")
278
- reply.data
279
- when :error
280
- maybe_log("Received exception for method #{method_name} (transaction=#{transaction})")
281
- raise reply.data
623
+ def call(method_name, *args, **kwargs, &)
624
+ reply_port = ::Ractor::Port.new
625
+ transaction = make_transaction
626
+ settings = method_settings(method_name)
627
+ block_arg = make_block_arg(settings, &)
628
+ message = CallMessage.new(method_name: method_name,
629
+ args: args,
630
+ kwargs: kwargs,
631
+ block_arg: block_arg,
632
+ transaction: transaction,
633
+ settings: settings,
634
+ reply_port: reply_port)
635
+ maybe_log("Sending method", method_name: method_name, transaction: transaction)
636
+ begin
637
+ @port.send(message, move: settings.arguments == :move)
638
+ rescue ::Ractor::ClosedError
639
+ raise StoppedError, "Wrapper has stopped"
640
+ end
641
+ loop do
642
+ reply_message = reply_port.receive
643
+ case reply_message
644
+ when YieldMessage
645
+ handle_yield(reply_message, transaction, settings, method_name, &)
646
+ when ReturnMessage
647
+ maybe_log("Received result", method_name: method_name, transaction: transaction)
648
+ return reply_message.value
649
+ when ExceptionMessage
650
+ maybe_log("Received exception", method_name: method_name, transaction: transaction)
651
+ raise reply_message.exception
652
+ end
282
653
  end
654
+ ensure
655
+ reply_port.close
283
656
  end
284
657
 
285
658
  ##
286
659
  # Request that the wrapper stop. All currently running calls will complete
287
660
  # before the wrapper actually terminates. However, any new calls will fail.
288
661
  #
289
- # This metnod is idempotent and can be called multiple times (even from
662
+ # This method is idempotent and can be called multiple times (even from
290
663
  # different ractors).
291
664
  #
292
665
  # @return [self]
293
666
  #
294
667
  def async_stop
295
- maybe_log("Stopping #{name}")
296
- @ractor.send(Message.new(:stop))
668
+ maybe_log("Stopping wrapper")
669
+ @port.send(StopMessage.new.freeze)
297
670
  self
298
671
  rescue ::Ractor::ClosedError
299
672
  # Ignore to allow stops to be idempotent.
300
673
  self
301
674
  end
302
675
 
676
+ ##
677
+ # Blocks until the wrapper has fully stopped.
678
+ #
679
+ # Unlike `Thread#join` and `Ractor#join`, if a Wrapper crashes, the
680
+ # exception generally does *not* get raised out of `Wrapper#join`. Instead,
681
+ # it just returns self in the same way as normal termination.
682
+ #
683
+ # @return [self]
684
+ #
685
+ def join
686
+ if @ractor
687
+ @ractor.join
688
+ else
689
+ reply_port = ::Ractor::Port.new
690
+ begin
691
+ @port.send(JoinMessage.new(reply_port))
692
+ reply_port.receive
693
+ rescue ::Ractor::ClosedError
694
+ # Assume the wrapper has stopped if the port is not sendable
695
+ ensure
696
+ reply_port.close
697
+ end
698
+ end
699
+ self
700
+ end
701
+
303
702
  ##
304
703
  # Retrieves the original object that was wrapped. This should be called
305
704
  # only after a stop request has been issued using {#async_stop}, and may
306
705
  # block until the wrapper has fully stopped.
307
706
  #
308
- # Only one ractor may call this method; any additional calls will fail.
707
+ # This can be called only if the wrapper was *not* configured with
708
+ # `use_current_ractor: true`. If the wrapper had that configuration, the
709
+ # object will not be moved, and does not need to be recovered. In such a
710
+ # case, any calls to this method will raise Ractor::Error.
711
+ #
712
+ # Only one ractor may call this method; any additional calls will fail with
713
+ # a Ractor::Wrapper::Error.
309
714
  #
310
715
  # @return [Object] The original wrapped object
311
716
  #
312
- def recovered_object
313
- @ractor.take
717
+ def recover_object
718
+ raise Error, "cannot recover an object from a local wrapper" unless @ractor
719
+ begin
720
+ @ractor.value
721
+ rescue ::Ractor::Error => e
722
+ raise ::Ractor::Wrapper::Error, e.message, cause: e
723
+ end
314
724
  end
315
725
 
316
- private
726
+ #### private items below ####
317
727
 
318
- def maybe_log(str)
319
- return unless logging
320
- time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
321
- $stderr.puts("[#{time} Ractor::Wrapper/#{name}]: #{str}")
322
- $stderr.flush
323
- end
728
+ ##
729
+ # @private
730
+ # Message sent to initialize a server.
731
+ #
732
+ InitMessage = ::Data.define(:object, :stub)
324
733
 
325
734
  ##
326
- # A stub that forwards calls to a wrapper.
735
+ # @private
736
+ # Message sent to a server to call a method
327
737
  #
328
- class Stub
329
- ##
330
- # Create a stub given a wrapper.
331
- #
332
- # @param wrapper [Ractor::Wrapper]
333
- #
334
- def initialize(wrapper)
335
- @wrapper = wrapper
336
- freeze
337
- end
738
+ CallMessage = ::Data.define(:method_name, :args, :kwargs, :block_arg,
739
+ :transaction, :settings, :reply_port)
338
740
 
339
- ##
340
- # Forward calls to {Ractor::Wrapper#call}.
341
- # @private
342
- #
343
- def method_missing(name, *args, **kwargs)
344
- @wrapper.call(name, *args, **kwargs)
345
- end
741
+ ##
742
+ # @private
743
+ # Message sent to a server when a worker thread terminates
744
+ #
745
+ WorkerStoppedMessage = ::Data.define(:worker_num)
346
746
 
347
- ##
348
- # Forward respond_to queries.
349
- # @private
350
- #
351
- def respond_to_missing?(name, include_all)
352
- @wrapper.call(:respond_to?, name, include_all)
353
- end
354
- end
747
+ ##
748
+ # @private
749
+ # Message sent to a server to request it to stop
750
+ #
751
+ StopMessage = ::Data.define
355
752
 
356
753
  ##
357
- # Settings for a method call. Specifies how a method's arguments and
358
- # return value are communicated (i.e. copy or move semantics.)
754
+ # @private
755
+ # Message sent to a server to request a join response
359
756
  #
360
- class MethodSettings
361
- # @private
362
- def initialize(move: false,
363
- move_arguments: nil,
364
- move_return: nil)
365
- @move_arguments = interpret_setting(move_arguments, move)
366
- @move_return = interpret_setting(move_return, move)
367
- freeze
368
- end
757
+ JoinMessage = ::Data.define(:reply_port)
369
758
 
370
- ##
371
- # @return [Boolean] Whether to move arguments
372
- #
373
- def move_arguments?
374
- @move_arguments
375
- end
759
+ ##
760
+ # @private
761
+ # Message sent from a server in response to a join request.
762
+ #
763
+ JoinReplyMessage = ::Data.define
376
764
 
377
- ##
378
- # @return [Boolean] Whether to move return values
379
- #
380
- def move_return?
381
- @move_return
382
- end
765
+ ##
766
+ # @private
767
+ # Message sent to report a return value
768
+ #
769
+ ReturnMessage = ::Data.define(:value)
383
770
 
384
- private
771
+ ##
772
+ # @private
773
+ # Message sent to report an exception result
774
+ #
775
+ ExceptionMessage = ::Data.define(:exception)
385
776
 
386
- def interpret_setting(setting, default)
387
- if setting.nil?
388
- default ? true : false
389
- else
390
- setting ? true : false
391
- end
777
+ ##
778
+ # @private
779
+ # Message sent from a server to request a yield block run
780
+ #
781
+ YieldMessage = ::Data.define(:args, :kwargs, :reply_port)
782
+
783
+ private
784
+
785
+ ##
786
+ # Start a server in the current Ractor.
787
+ # Passes the object directly to the server.
788
+ #
789
+ def setup_local_server(object)
790
+ maybe_log("Starting local server")
791
+ @ractor = nil
792
+ @port = ::Ractor::Port.new
793
+ freeze
794
+ wrapper_id = object_id
795
+ ::Thread.new do
796
+ ::Thread.current.name = "ractor-wrapper:server:#{wrapper_id}"
797
+ Server.run_local(object: object,
798
+ stub: @stub,
799
+ port: @port,
800
+ name: name,
801
+ enable_logging: enable_logging?,
802
+ threads: threads)
392
803
  end
393
804
  end
394
805
 
395
806
  ##
396
- # The class of all messages passed between a client Ractor and a wrapper.
397
- # This helps the wrapper distinguish these messages from any other messages
398
- # that might be received by a client Ractor.
807
+ # Start a server in an isolated Ractor.
808
+ # This must send the object separately since it must be moved into the
809
+ # server's Ractor.
399
810
  #
400
- # Any Ractor that calls a wrapper may receive messages of this type when
401
- # the call is in progress. If a Ractor interacts with its incoming message
402
- # queue concurrently while a wrapped call is in progress, it must ignore
403
- # these messages (i.e. by by using `receive_if`) in order not to interfere
404
- # with the wrapper. (Similarly, the wrapper will use `receive_if` to
405
- # receive only messages of this type, so it does not interfere with your
406
- # Ractor's functionality.)
407
- #
408
- class Message
409
- # @private
410
- def initialize(type, data: nil, transaction: nil)
411
- @sender = ::Ractor.current
412
- @type = type
413
- @data = data
414
- @transaction = transaction || new_transaction
415
- freeze
811
+ def setup_isolated_server(object)
812
+ maybe_log("Starting isolated server")
813
+ @ractor = ::Ractor.new(name, enable_logging?, threads, name: "wrapper:#{name}") do |name, enable_logging, threads|
814
+ Server.run_isolated(name: name,
815
+ enable_logging: enable_logging,
816
+ threads: threads)
416
817
  end
818
+ @port = @ractor.default_port
819
+ freeze
820
+ init_message = InitMessage.new(object: object, stub: @stub)
821
+ @port.send(init_message, move: true)
822
+ end
417
823
 
418
- # @private
419
- attr_reader :type
420
-
421
- # @private
422
- attr_reader :sender
423
-
424
- # @private
425
- attr_reader :transaction
426
-
427
- # @private
428
- attr_reader :data
429
-
430
- private
824
+ ##
825
+ # Create a transaction ID, used for logging
826
+ #
827
+ def make_transaction
828
+ ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).rjust(16, "0").freeze
829
+ end
431
830
 
432
- def new_transaction
433
- ::Random.rand(7958661109946400884391936).to_s(36).freeze
831
+ ##
832
+ # Create the shareable object representing a block in a method call
833
+ #
834
+ def make_block_arg(settings, &)
835
+ if !block_given?
836
+ nil
837
+ elsif settings.block_environment == :wrapped
838
+ ::Ractor.shareable_proc(&)
839
+ else
840
+ :send_block_message
434
841
  end
435
842
  end
436
843
 
437
844
  ##
438
- # This is the backend implementation of a wrapper. A Server runs within a
439
- # Ractor, and manages a shared object. It handles communication with
440
- # clients, translating those messages into method calls on the object. It
441
- # runs worker threads internally to handle actual method calls.
845
+ # Handle a call to a block directed to run in the caller environment.
442
846
  #
443
- # See the {#run} method for an overview of the Server implementation and
444
- # lifecycle.
847
+ def handle_yield(message, transaction, settings, method_name)
848
+ maybe_log("Yielding to block", method_name: method_name, transaction: transaction)
849
+ begin
850
+ block_result = yield(*message.args, **message.kwargs)
851
+ block_result = nil if settings.block_results == :void
852
+ maybe_log("Sending block result", method_name: method_name, transaction: transaction)
853
+ message.reply_port.send(ReturnMessage.new(block_result), move: settings.block_results == :move)
854
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
855
+ maybe_log("Sending block exception", method_name: method_name, transaction: transaction)
856
+ begin
857
+ message.reply_port.send(ExceptionMessage.new(e))
858
+ rescue ::StandardError
859
+ begin
860
+ message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
861
+ rescue ::StandardError
862
+ maybe_log("Failure to send block reply", method_name: method_name, transaction: transaction)
863
+ end
864
+ end
865
+ end
866
+ end
867
+
868
+ ##
869
+ # Prints out a log message
445
870
  #
871
+ def maybe_log(str, transaction: nil, method_name: nil)
872
+ return unless enable_logging?
873
+ metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper/#{name}"]
874
+ metadata << "Transaction/#{transaction}" if transaction
875
+ metadata << "Method/#{method_name}" if method_name
876
+ metadata = metadata.join(" ")
877
+ $stderr.puts("[#{metadata}] #{str}")
878
+ $stderr.flush
879
+ end
880
+
881
+ ##
446
882
  # @private
447
883
  #
884
+ # Server is the backend implementation of a wrapper. It listens for method
885
+ # call requests on a port, and calls the wrapped object in a controlled
886
+ # environment.
887
+ #
888
+ # It can run:
889
+ #
890
+ # * Either hosted by an external Ractor or isolated in a dedicated Ractor
891
+ # * Either sequentially or concurrently using worker threads.
892
+ #
448
893
  class Server
449
894
  ##
450
- # Handle the server lifecycle, running through the following phases:
895
+ # @private
896
+ # Create and run a server hosted in the current Ractor
451
897
  #
452
- # * **init**: Setup and spawning of worker threads.
453
- # * **running**: Normal operation, until a stop request is received.
454
- # * **stopping**: Waiting for worker threads to terminate.
455
- # * **cleanup**: Clearing out of any lingering meessages.
898
+ def self.run_local(object:, stub:, port:, name:, enable_logging: false, threads: 0)
899
+ server = new(isolated: false, object:, stub:, port:, name:, enable_logging:, threads:)
900
+ server.run
901
+ end
902
+
903
+ ##
904
+ # @private
905
+ # Create and run a server in an isolated Ractor
456
906
  #
457
- # The server returns the wrapped object, allowing one client Ractor to
458
- # take it.
907
+ def self.run_isolated(name:, enable_logging: false, threads: 0)
908
+ port = ::Ractor.current.default_port
909
+ server = new(isolated: true, object: nil, stub: nil, port:, name:, enable_logging:, threads:)
910
+ server.run
911
+ end
912
+
913
+ # @private
914
+ def initialize(isolated:, object:, stub:, port:, name:, enable_logging:, threads:)
915
+ @isolated = isolated
916
+ @object = object
917
+ @stub = stub
918
+ @port = port
919
+ @name = name
920
+ @enable_logging = enable_logging
921
+ @threads_requested = threads.positive? ? threads : false
922
+ @join_requests = []
923
+ end
924
+
925
+ ##
926
+ # @private
927
+ # Handle the server lifecycle.
928
+ # Returns the wrapped object, so it can be recovered if the server is run
929
+ # in a Ractor.
459
930
  #
460
931
  def run
461
- init_phase
462
- running_phase
463
- stopping_phase
464
- cleanup_phase
932
+ receive_remote_object if @isolated
933
+ start_workers if @threads_requested
934
+ main_loop
935
+ stop_workers if @threads_requested
936
+ cleanup
465
937
  @object
466
- rescue ::StandardError => e
467
- maybe_log("Unexpected error: #{e.inspect}")
938
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
939
+ @crash_exception = e
468
940
  @object
941
+ ensure
942
+ crash_cleanup if @crash_exception
469
943
  end
470
944
 
471
945
  private
472
946
 
473
947
  ##
474
- # In the **init phase**, the Server:
475
- #
476
- # * Receives an initial message providing the object to wrap, and
477
- # server configuration such as thread count and communications
478
- # settings.
479
- # * Initializes the job queue and the pending request list.
480
- # * Spawns worker threads.
948
+ # Receive the moved remote object. Called if the server is run in a
949
+ # separate Ractor.
481
950
  #
482
- def init_phase
483
- opts = ::Ractor.receive
484
- @object = opts[:object]
485
- @logging = opts[:logging]
486
- @name = opts[:name]
487
- @method_settings = opts[:method_settings]
488
- @thread_count = opts[:threads]
489
- @queue = ::Queue.new
490
- @mutex = ::Mutex.new
491
- @current_calls = {}
492
- maybe_log("Spawning #{@thread_count} threads")
493
- (1..@thread_count).map do |worker_num|
494
- ::Thread.new { worker_thread(worker_num) }
495
- end
496
- maybe_log("Server initialized")
951
+ def receive_remote_object
952
+ maybe_log("Waiting for initialization")
953
+ init_message = @port.receive
954
+ @object = init_message.object
955
+ @stub = init_message.stub
497
956
  end
498
957
 
499
958
  ##
500
- # A worker thread repeatedly pulls a method call requests off the job
501
- # queue, handles it, and sends back a response. It also removes the
502
- # request from the pending request list to signal that it has responded.
503
- # If no job is available, the thread blocks while waiting. If the queue
504
- # is closed, the worker will send an acknowledgement message and then
505
- # terminate.
959
+ # Start the worker threads. Each thread picks up methods to run from a
960
+ # shared queue. Called only if worker threading is enabled.
506
961
  #
507
- def worker_thread(worker_num)
508
- maybe_worker_log(worker_num, "Starting")
509
- loop do
510
- maybe_worker_log(worker_num, "Waiting for job")
511
- request = @queue.deq
512
- break if request.nil?
513
- handle_method(worker_num, request)
514
- unregister_call(request.transaction)
962
+ def start_workers
963
+ maybe_log("Spawning #{@threads_requested} worker threads")
964
+ @queue = ::Queue.new
965
+ @active_workers = {}
966
+ (1..@threads_requested).each do |worker_num|
967
+ @active_workers[worker_num] = ::Thread.new { worker_thread(worker_num) }
515
968
  end
516
- ensure
517
- maybe_worker_log(worker_num, "Stopping")
518
- ::Ractor.current.send(Message.new(:thread_stopped, data: worker_num), move: true)
519
969
  end
520
970
 
521
971
  ##
522
- # In the **running phase**, the Server listens on the Ractor's inbox and
523
- # handles messages for normal operation:
972
+ # This is the main loop, listening on the inbox and handling messages for
973
+ # normal operation:
524
974
  #
525
- # * If it receives a `call` request, it adds it to the job queue from
526
- # which a worker thread will pick it up. It also adds the request to
527
- # a list of pending requests.
528
- # * If it receives a `stop` request, we proceed to the stopping phase.
529
- # * If it receives a `thread_stopped` message, that indicates one of
530
- # the worker threads has unexpectedly stopped. We don't expect this
531
- # to happen until the stopping phase, so if we do see it here, we
532
- # conclude that something has gone wrong, and we proceed to the
533
- # stopping phase.
975
+ # * If it receives a CallMessage, it either runs the method (when in
976
+ # sequential mode) or adds it to the job queue (when in worker mode).
977
+ # * If it receives a StopMessage, it exits the main loop and proceeds
978
+ # to the termination logic.
979
+ # * If it receives a JoinMessage, it adds it to the list of join ports
980
+ # to notify once the wrapper completes.
981
+ # * If it receives a WorkerStoppedMessage, that indicates a worker
982
+ # thread has unexpectedly stopped. We conclude something has gone
983
+ # wrong with a worker, and we bail, stopping the remaining workers
984
+ # and proceeding to termination logic.
534
985
  #
535
- def running_phase
986
+ def main_loop
536
987
  loop do
537
- maybe_log("Waiting for message")
538
- request = ::Ractor.receive
539
- next unless request.is_a?(Message)
540
- case request.type
541
- when :call
542
- @queue.enq(request)
543
- register_call(request)
544
- maybe_log("Queued method #{request.data.first} (transaction=#{request.transaction})")
545
- when :thread_stopped
546
- maybe_log("Thread unexpectedly stopped: #{request.data}")
547
- @thread_count -= 1
988
+ maybe_log("Waiting for message in running phase")
989
+ message = @port.receive
990
+ case message
991
+ when CallMessage
992
+ maybe_log("Received CallMessage", call_message: message)
993
+ if @threads_requested
994
+ @queue.enq(message)
995
+ else
996
+ handle_method(message)
997
+ end
998
+ when WorkerStoppedMessage
999
+ maybe_log("Received unexpected WorkerStoppedMessage")
1000
+ @active_workers.delete(message.worker_num) if @threads_requested
548
1001
  break
549
- when :stop
1002
+ when StopMessage
550
1003
  maybe_log("Received stop")
551
1004
  break
1005
+ when JoinMessage
1006
+ maybe_log("Received and queueing join request")
1007
+ @join_requests << message.reply_port
552
1008
  end
553
1009
  end
554
1010
  end
555
1011
 
556
1012
  ##
557
- # In the **stopping phase**, we close the job queue, which signals to all
558
- # worker threads that they should finish their current task and then
559
- # terminate. We then wait for acknowledgement messages from all workers
560
- # before proceeding to the next phase. Any `call` requests received
561
- # during stopping are refused (i.e. we send back an error response.) Any
562
- # further `stop` requests are ignored.
1013
+ # This signals workers to stop by closing the queue, and then waits for
1014
+ # all workers to report in that they have stopped. It is called only if
1015
+ # worker threading is enabled.
1016
+ #
1017
+ # Responds to messages to indicate the wrapper is stopping and no longer
1018
+ # accepting new method requests:
1019
+ #
1020
+ # * If it receives a CallMessage, it sends back a refusal exception.
1021
+ # * If it receives a StopMessage, it does nothing (i.e. the stop
1022
+ # operation is idempotent).
1023
+ # * If it receives a JoinMessage, it adds it to the list of join ports
1024
+ # to notify once the wrapper completes. At this point the wrapper is
1025
+ # not yet considered complete because workers are still processing
1026
+ # earlier method calls.
1027
+ # * If it receives a WorkerStoppedMessage, it updates its count of
1028
+ # running workers.
563
1029
  #
564
- def stopping_phase
1030
+ # This phase continues until all workers have signaled that they have
1031
+ # stopped.
1032
+ #
1033
+ def stop_workers
565
1034
  @queue.close
566
- while @thread_count.positive?
567
- maybe_log("Waiting for message while stopping")
568
- message = ::Ractor.receive
569
- next unless request.is_a?(Message)
570
- case message.type
571
- when :call
1035
+ until @active_workers.empty?
1036
+ maybe_log("Waiting for message in stopping phase")
1037
+ message = @port.receive
1038
+ case message
1039
+ when CallMessage
572
1040
  refuse_method(message)
573
- when :thread_stopped
574
- @thread_count -= 1
1041
+ when WorkerStoppedMessage
1042
+ maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}")
1043
+ @active_workers.delete(message.worker_num)
1044
+ when StopMessage
1045
+ maybe_log("Stop received when already stopping")
1046
+ when JoinMessage
1047
+ maybe_log("Received and queueing join request")
1048
+ @join_requests << message.reply_port
575
1049
  end
576
1050
  end
577
1051
  end
578
1052
 
579
1053
  ##
580
- # In the **cleanup phase**, The Server closes its inbox, and iterates
581
- # through one final time to ensure it has responded to all remaining
582
- # requests with a refusal. It also makes another pass through the pending
583
- # requests; if there are any left, it probably means a worker thread died
584
- # without responding to it preoprly, so we send back an error message.
1054
+ # This is called when the Server is ready to terminate completely.
1055
+ # It closes the inbox and responds to any remaining contents.
585
1056
  #
586
- def cleanup_phase
587
- ::Ractor.current.close_incoming
588
- maybe_log("Checking message queue for cleanup")
1057
+ def cleanup
1058
+ maybe_log("Closing inbox")
1059
+ @port.close
1060
+ maybe_log("Draining inbox")
589
1061
  loop do
590
- message = ::Ractor.receive
591
- refuse_method(message) if message.is_a?(Message) && message.type == :call
1062
+ message = begin
1063
+ @port.receive
1064
+ rescue ::Ractor::ClosedError
1065
+ maybe_log("Inbox is empty")
1066
+ nil
1067
+ end
1068
+ break if message.nil?
1069
+ case message
1070
+ when CallMessage
1071
+ refuse_method(message)
1072
+ when WorkerStoppedMessage
1073
+ maybe_log("Unexpected WorkerStoppedMessage when in cleanup")
1074
+ when StopMessage
1075
+ maybe_log("Stop received when already stopping")
1076
+ when JoinMessage
1077
+ maybe_log("Received and responding immediately to join request")
1078
+ send_join_reply(message.reply_port)
1079
+ end
592
1080
  end
593
- maybe_log("Checking current calls for cleanup")
594
- @current_calls.each_value do |request|
595
- refuse_method(request)
1081
+ maybe_log("Responding to join requests")
1082
+ @join_requests.each { |port| send_join_reply(port) }
1083
+ end
1084
+
1085
+ ##
1086
+ # Called from the ensure block in run when an unexpected exception
1087
+ # terminated the server. Drains pending requests that are not otherwise
1088
+ # being handled, responding to all pending callers and join requesters,
1089
+ # and also joins any worker threads.
1090
+ #
1091
+ def crash_cleanup
1092
+ maybe_log("Running crash cleanup after: #{@crash_exception.message} (#{@crash_exception.class})")
1093
+ error = CrashedError.new("Server crashed: #{@crash_exception.message} (#{@crash_exception.class})")
1094
+ # `@queue` should not be nil in threaded mode, but we're checking
1095
+ # anyway just in case a crash happened during setup
1096
+ drain_queue_after_crash(@queue, error) if @threads_requested && @queue
1097
+ drain_inbox_after_crash(@port, error)
1098
+ # `@active_workers` should not be nil in threaded mode, but we're
1099
+ # checking anyway just in case a crash happened during setup
1100
+ join_workers_after_crash(@active_workers) if @threads_requested && @active_workers
1101
+ @join_requests.each { |port| send_join_reply(port) }
1102
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1103
+ maybe_log("Suppressed exception during crash_cleanup: #{e.message} (#{e.class})")
1104
+ end
1105
+
1106
+ ##
1107
+ # Drains any remaining queued call messages after a crash, sending errors
1108
+ # to callers whose calls had not yet been dispatched to a worker thread.
1109
+ #
1110
+ def drain_queue_after_crash(queue, error)
1111
+ queue.close
1112
+ loop do
1113
+ message = queue.deq
1114
+ break if message.nil?
1115
+ begin
1116
+ message.reply_port.send(ExceptionMessage.new(error))
1117
+ rescue ::Ractor::Error
1118
+ maybe_log("Failed to send crash error to queued caller", call_message: message)
1119
+ end
596
1120
  end
597
- rescue ::Ractor::ClosedError
598
- maybe_log("Message queue is empty")
1121
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1122
+ maybe_log("Suppressed exception during drain_queue_after_crash: " \
1123
+ "#{e.message} (#{e.class})")
1124
+ end
1125
+
1126
+ ##
1127
+ # Drains any remaining inbox messages after a crash, sending errors to
1128
+ # pending callers and responding to any join requests.
1129
+ #
1130
+ def drain_inbox_after_crash(port, error)
1131
+ begin
1132
+ port.close
1133
+ rescue ::Ractor::Error
1134
+ # Port was already closed (maybe because it was the cause of the crash)
1135
+ end
1136
+ loop do
1137
+ message = begin
1138
+ port.receive
1139
+ rescue ::Ractor::Error
1140
+ nil
1141
+ end
1142
+ break if message.nil?
1143
+ case message
1144
+ when CallMessage
1145
+ begin
1146
+ message.reply_port.send(ExceptionMessage.new(error))
1147
+ rescue ::Ractor::Error
1148
+ maybe_log("Failed to send crash error to caller", call_message: message)
1149
+ end
1150
+ when JoinMessage
1151
+ send_join_reply(message.reply_port)
1152
+ when WorkerStoppedMessage, StopMessage
1153
+ # Ignore
1154
+ end
1155
+ end
1156
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1157
+ maybe_log("Suppressed exception during drain_inbox_after_crash: #{e.message} (#{e.class})")
599
1158
  end
600
1159
 
601
1160
  ##
602
- # This is called within a worker thread to handle a method call request.
1161
+ # Wait until all workers have stopped after a crash
1162
+ #
1163
+ def join_workers_after_crash(workers)
1164
+ workers.each_value do |thread|
1165
+ thread.join
1166
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1167
+ maybe_log("Suppressed exception during join_workers_after_crash: #{e.message} (#{e.class})")
1168
+ end
1169
+ end
1170
+
1171
+ ##
1172
+ # A worker thread repeatedly pulls a method call requests off the job
1173
+ # queue, handles it, and sends back a response. It also removes the
1174
+ # request from the pending request list to signal that it has responded.
1175
+ # If no job is available, the thread blocks while waiting. If the queue
1176
+ # is closed, the worker will send an acknowledgement message and then
1177
+ # terminate.
1178
+ #
1179
+ def worker_thread(worker_num)
1180
+ maybe_log("Worker starting", worker_num: worker_num)
1181
+ loop do
1182
+ maybe_log("Waiting for job", worker_num: worker_num)
1183
+ message = @queue.deq
1184
+ break if message.nil?
1185
+ handle_method(message, worker_num: worker_num)
1186
+ end
1187
+ ensure
1188
+ maybe_log("Worker stopping", worker_num: worker_num)
1189
+ begin
1190
+ @port.send(WorkerStoppedMessage.new(worker_num))
1191
+ rescue ::Ractor::ClosedError
1192
+ maybe_log("Worker unable to report stop, possibly due to server crash", worker_num: worker_num)
1193
+ end
1194
+ end
1195
+
1196
+ ##
1197
+ # This is called to handle a method call request.
603
1198
  # It calls the method on the wrapped object, and then sends back a
604
1199
  # response to the caller. If an exception was raised, it sends back an
605
1200
  # error response. It tries very hard always to send a response of some
606
1201
  # kind; if an error occurs while constructing or sending a response, it
607
- # will catch the exception and try to send a simpler response.
1202
+ # will catch the exception and try to send a simpler response. If a block
1203
+ # was passed to the method, it is also handled here.
608
1204
  #
609
- def handle_method(worker_num, request)
610
- method_name, args, kwargs = request.data
611
- transaction = request.transaction
612
- sender = request.sender
613
- maybe_worker_log(worker_num, "Running method #{method_name} (transaction=#{transaction})")
1205
+ def handle_method(message, worker_num: nil)
1206
+ block = make_block(message)
1207
+ maybe_log("Running method", worker_num: worker_num, call_message: message)
1208
+ result = @object.__send__(message.method_name, *message.args, **message.kwargs, &block)
1209
+ result = @stub if result.equal?(@object)
1210
+ result = nil if message.settings.results == :void
1211
+ maybe_log("Sending return value", worker_num: worker_num, call_message: message)
1212
+ message.reply_port.send(ReturnMessage.new(result), move: message.settings.results == :move)
1213
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
1214
+ maybe_log("Sending exception", worker_num: worker_num, call_message: message)
614
1215
  begin
615
- result = @object.send(method_name, *args, **kwargs)
616
- maybe_worker_log(worker_num, "Sending result (transaction=#{transaction})")
617
- sender.send(Message.new(:result, data: result, transaction: transaction),
618
- move: (@method_settings[method_name] || @method_settings[nil]).move_return?)
619
- rescue ::Exception => e # rubocop:disable Lint/RescueException
620
- maybe_worker_log(worker_num, "Sending exception (transaction=#{transaction})")
1216
+ message.reply_port.send(ExceptionMessage.new(e))
1217
+ rescue ::Exception # rubocop:disable Lint/RescueException
621
1218
  begin
622
- sender.send(Message.new(:error, data: e, transaction: transaction))
623
- rescue ::StandardError
624
- safe_error = begin
625
- ::StandardError.new(e.inspect)
626
- rescue ::StandardError
627
- ::StandardError.new("Unknown error")
628
- end
629
- sender.send(Message.new(:error, data: safe_error, transaction: transaction))
1219
+ message.reply_port.send(ExceptionMessage.new(::RuntimeError.new(e.inspect)))
1220
+ rescue ::Exception # rubocop:disable Lint/RescueException
1221
+ maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
630
1222
  end
631
1223
  end
632
1224
  end
633
1225
 
634
1226
  ##
635
- # This is called from the main Ractor thread to report to a caller that
636
- # the wrapper cannot handle a requested method call, likely because the
637
- # wrapper is shutting down.
1227
+ # Creates a block appropriate to the block specification received with
1228
+ # the method call message. This could return:
638
1229
  #
639
- def refuse_method(request)
640
- maybe_log("Refusing method call (transaction=#{message.transaction})")
641
- error = ::Ractor::ClosedError.new
642
- request.sender.send(Message.new(:error, data: error, transaction: message.transaction))
643
- end
644
-
645
- def register_call(request)
646
- @mutex.synchronize do
647
- @current_calls[request.transaction] = request
1230
+ # * nil if there was no block
1231
+ # * the proc itself, if a shareable proc was received
1232
+ # * otherwise a proc that sends a message back to the caller, along
1233
+ # with the block arguments, to run the block in the caller's
1234
+ # environment
1235
+ #
1236
+ def make_block(message)
1237
+ return message.block_arg unless message.block_arg == :send_block_message
1238
+ proc do |*args, **kwargs|
1239
+ reply_port = ::Ractor::Port.new
1240
+ reply_message = begin
1241
+ args.map! { |arg| arg.equal?(@object) ? @stub : arg }
1242
+ kwargs.transform_values! { |arg| arg.equal?(@object) ? @stub : arg }
1243
+ yield_message = YieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port)
1244
+ message.reply_port.send(yield_message, move: message.settings.block_arguments == :move)
1245
+ reply_port.receive
1246
+ ensure
1247
+ reply_port.close
1248
+ end
1249
+ case reply_message
1250
+ when ExceptionMessage
1251
+ raise reply_message.exception
1252
+ when ReturnMessage
1253
+ reply_message.value
1254
+ end
648
1255
  end
649
1256
  end
650
1257
 
651
- def unregister_call(transaction)
652
- @mutex.synchronize do
653
- @current_calls.delete(transaction)
1258
+ ##
1259
+ # This is called from the main Ractor thread to report to a caller that
1260
+ # the wrapper cannot handle a requested method call, likely because the
1261
+ # wrapper is shutting down.
1262
+ #
1263
+ def refuse_method(message)
1264
+ maybe_log("Refusing method call", call_message: message)
1265
+ begin
1266
+ error = StoppedError.new("Wrapper is shutting down")
1267
+ message.reply_port.send(ExceptionMessage.new(error))
1268
+ rescue ::Ractor::Error
1269
+ maybe_log("Failed to send refusal message", call_message: message)
654
1270
  end
655
1271
  end
656
1272
 
657
- def maybe_log(str)
658
- return unless @logging
659
- time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
660
- $stderr.puts("[#{time} Ractor::Wrapper/#{@name} Server]: #{str}")
661
- $stderr.flush
1273
+ ##
1274
+ # This attempts to send a signal that a wrapper join has completed.
1275
+ #
1276
+ def send_join_reply(port)
1277
+ port.send(JoinReplyMessage.new.freeze)
1278
+ rescue ::Ractor::ClosedError
1279
+ maybe_log("Join reply port is closed")
662
1280
  end
663
1281
 
664
- def maybe_worker_log(worker_num, str)
665
- return unless @logging
666
- time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
667
- $stderr.puts("[#{time} Ractor::Wrapper/#{@name} Worker/#{worker_num}]: #{str}")
1282
+ ##
1283
+ # Print out a log message
1284
+ #
1285
+ def maybe_log(str, call_message: nil, worker_num: nil, transaction: nil, method_name: nil)
1286
+ return unless @enable_logging
1287
+ transaction ||= call_message&.transaction
1288
+ method_name ||= call_message&.method_name
1289
+ metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper:#{@name}"]
1290
+ metadata << "Worker:#{worker_num}" if worker_num
1291
+ metadata << "Transaction:#{transaction}" if transaction
1292
+ metadata << "Method:#{method_name}" if method_name
1293
+ metadata = metadata.join(" ")
1294
+ $stderr.puts("[#{metadata}] #{str}")
668
1295
  $stderr.flush
1296
+ rescue ::StandardError
1297
+ # Swallow any errors during logging
669
1298
  end
670
1299
  end
671
1300
  end