ractor-wrapper 0.2.0 → 0.3.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,156 +1,357 @@
1
+ # frozen_string_literal: true
2
+
1
3
  ##
2
4
  # See ruby-doc.org for info on Ractors.
3
5
  #
4
6
  class Ractor
5
7
  ##
6
- # An experimental class that wraps a non-shareable object, allowing multiple
7
- # Ractors to access it concurrently.
8
+ # An experimental class that wraps a non-shareable object in an actor,
9
+ # allowing multiple Ractors to access it concurrently.
8
10
  #
9
11
  # 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
12
+ # recommended for production use. (As of Ruby 4.0.0, the same can be said of
11
13
  # Ractors in general.)
12
14
  #
13
15
  # ## What is Ractor::Wrapper?
14
16
  #
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.
17
+ # For the most part, unless an object is _sharable_, which generally means
18
+ # deeply immutable along with a few other restrictions, it cannot be accessed
19
+ # directly from another Ractor. This makes it difficult for multiple Ractors
20
+ # to share a resource that is stateful. Such a resource must typically itself
21
+ # be implemented as a Ractor and accessed via message passing.
22
+ #
23
+ # Ractor::Wrapper makes it possible for an ordinary non-shareable object to
24
+ # be accessed from multiple Ractors. It does this by "wrapping" the object
25
+ # with an actor that listens for messages and invokes the object's methods in
26
+ # a controlled single-Ractor environment. It then provides a stub object that
27
+ # reproduces the interface of the original object, but responds to method
28
+ # calls by sending messages to the wrapper. Ractor::Wrapper can be used to
29
+ # implement simple actors by writing "plain" Ruby objects, or to adapt
30
+ # existing non-shareable objects to a multi-Ractor world.
31
+ #
32
+ # ## Net::HTTP example
33
+ #
34
+ # The following example shows how to share a single Net::HTTP session object
35
+ # among multiple Ractors.
36
+ #
37
+ # require "ractor/wrapper"
38
+ # require "net/http"
39
+ #
40
+ # # Create a Net::HTTP session. Net::HTTP sessions are not shareable,
41
+ # # so normally only one Ractor can access them at a time.
42
+ # http = Net::HTTP.new("example.com")
43
+ # http.start
44
+ #
45
+ # # Create a wrapper around the session. This moves the session into an
46
+ # # internal Ractor and listens for method call requests. By default, a
47
+ # # wrapper serializes calls, handling one at a time, for compatibility
48
+ # # with non-thread-safe objects.
49
+ # wrapper = Ractor::Wrapper.new(http)
50
+ #
51
+ # # At this point, the session object can no longer be accessed directly
52
+ # # because it is now owned by the wrapper's internal Ractor.
53
+ # # http.get("/whoops") # <= raises Ractor::MovedError
54
+ #
55
+ # # However, you can access the session via the stub object provided by
56
+ # # the wrapper. This stub proxies the call to the wrapper's internal
57
+ # # Ractor. And it's shareable, so any number of Ractors can use it.
58
+ # response = wrapper.stub.get("/")
59
+ #
60
+ # # Here, we start two Ractors, and pass the stub to each one. Each
61
+ # # Ractor can simply call methods on the stub as if it were the original
62
+ # # connection object. Internally, of course, the calls are proxied to
63
+ # # the original object via the wrapper, and execution is serialized.
64
+ # r1 = Ractor.new(wrapper.stub) do |stub|
65
+ # 5.times do
66
+ # stub.get("/hello")
67
+ # end
68
+ # :ok
69
+ # end
70
+ # r2 = Ractor.new(wrapper.stub) do |stub|
71
+ # 5.times do
72
+ # stub.get("/ruby")
73
+ # end
74
+ # :ok
75
+ # end
76
+ #
77
+ # # Wait for the two above Ractors to finish.
78
+ # r1.join
79
+ # r2.join
80
+ #
81
+ # # After you stop the wrapper, you can retrieve the underlying session
82
+ # # object and access it directly again.
83
+ # wrapper.async_stop
84
+ # http = wrapper.recover_object
85
+ # http.finish
20
86
  #
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.
87
+ # ## SQLite3 example
27
88
  #
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.
89
+ # The following example shows how to share a SQLite3 database among multiple
90
+ # Ractors.
35
91
  #
36
- # ## Example usage
92
+ # require "ractor/wrapper"
93
+ # require "sqlite3"
37
94
  #
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.
95
+ # # Create a SQLite3 database. These objects are not shareable, so
96
+ # # normally only one Ractor can access them.
97
+ # db = SQLite3::Database.new($my_database_path)
41
98
  #
42
- # require "faraday"
99
+ # # Create a wrapper around the database. A SQLite3::Database object
100
+ # # cannot be moved between Ractors, so we configure the wrapper to run
101
+ # # in the current Ractor. You can also configure it to run multiple
102
+ # # worker threads because the database object itself is thread-safe.
103
+ # wrapper = Ractor::Wrapper.new(db, use_current_ractor: true, threads: 2)
43
104
  #
44
- # # Create a Faraday connection and a wrapper for it.
45
- # connection = Faraday.new "http://example.com"
46
- # wrapper = Ractor::Wrapper.new(connection)
105
+ # # At this point, the database object can still be accessed directly
106
+ # # because it hasn't been moved to a different Ractor.
107
+ # rows = db.execute("select * from numbers")
47
108
  #
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
109
+ # # You can also access the database via the stub object provided by the
110
+ # # wrapper.
111
+ # rows = wrapper.stub.execute("select * from numbers")
51
112
  #
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")
113
+ # # Here, we start two Ractors, and pass the stub to each one. The
114
+ # # wrapper's two worker threads will handle the requests in the order
115
+ # # received.
116
+ # r1 = Ractor.new(wrapper.stub) do |db_stub|
117
+ # 5.times do
118
+ # rows = db_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 |db_stub|
123
+ # 5.times do
124
+ # rows = db_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::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
175
+ ##
176
+ # A stub that forwards calls to a wrapper.
177
+ #
178
+ # This object is shareable and can be passed to any Ractor.
179
+ #
180
+ class Stub
181
+ ##
182
+ # Create a stub given a wrapper.
183
+ #
184
+ # @param wrapper [Ractor::Wrapper]
185
+ #
186
+ def initialize(wrapper)
187
+ @wrapper = wrapper
188
+ freeze
189
+ end
190
+
191
+ ##
192
+ # Forward calls to {Ractor::Wrapper#call}.
193
+ # @private
194
+ #
195
+ def method_missing(name, ...)
196
+ @wrapper.call(name, ...)
197
+ end
198
+
199
+ ##
200
+ # Forward respond_to queries.
201
+ # @private
202
+ #
203
+ def respond_to_missing?(name, include_all)
204
+ @wrapper.call(:respond_to?, name, include_all)
205
+ end
206
+ end
207
+
208
+ ##
209
+ # Settings for a method call. Specifies how a method's arguments and
210
+ # return value are communicated (i.e. copy or move semantics.)
211
+ #
212
+ class MethodSettings
213
+ # @private
214
+ def initialize(move_data: false,
215
+ move_arguments: nil,
216
+ move_results: nil,
217
+ move_block_arguments: nil,
218
+ move_block_results: nil,
219
+ execute_blocks_in_place: nil)
220
+ @move_arguments = interpret_setting(move_arguments, move_data)
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
226
+ end
227
+
228
+ ##
229
+ # @return [Boolean] Whether to move arguments
230
+ #
231
+ def move_arguments?
232
+ @move_arguments
233
+ end
234
+
235
+ ##
236
+ # @return [Boolean] Whether to move return values
237
+ #
238
+ def move_results?
239
+ @move_results
240
+ end
241
+
242
+ ##
243
+ # @return [Boolean] Whether to move arguments to a block
244
+ #
245
+ def move_block_arguments?
246
+ @move_block_arguments
247
+ end
248
+
249
+ ##
250
+ # @return [Boolean] Whether to move block results
251
+ #
252
+ def move_block_results?
253
+ @move_block_results
254
+ end
255
+
256
+ ##
257
+ # @return [Boolean] Whether to call blocks in-place
258
+ #
259
+ def execute_blocks_in_place?
260
+ @execute_blocks_in_place
261
+ end
262
+
263
+ private
264
+
265
+ def interpret_setting(setting, default)
266
+ if setting.nil?
267
+ default ? true : false
268
+ else
269
+ setting ? true : false
270
+ end
271
+ end
272
+ end
273
+
107
274
  ##
108
275
  # Create a wrapper around the given object.
109
276
  #
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.)
277
+ # If you pass an optional block, the wrapper itself will be yielded to it,
278
+ # at which time you can set additional configuration options. In
279
+ # particular, method-specific configuration must be set in this block.
280
+ # The configuration is frozen once the object is constructed.
113
281
  #
114
282
  # @param object [Object] The non-shareable object to wrap.
283
+ # @param use_current_ractor [boolean] If true, the wrapper is run in a
284
+ # thread in the current Ractor instead of spawning a new Ractor (the
285
+ # default behavior). This option can be used if the wrapped object
286
+ # cannot be moved or must run in the main Ractor.
287
+ # @param name [String] A name for this wrapper. Used during logging.
115
288
  # @param threads [Integer] The number of worker threads to run.
116
- # Defaults to 1, which causes the worker to serialize calls.
289
+ # Defaults to 0, which causes the wrapper to run sequentially without
290
+ # spawning workers.
291
+ # @param move_data [boolean] If true, all communication will by default
292
+ # move instead of copy arguments and return values. Default is false.
293
+ # This setting can be overridden by other `:move_*` settings.
294
+ # @param move_arguments [boolean] If true, all arguments will be moved
295
+ # instead of copied by default. If not set, uses the `:move_data`
296
+ # setting.
297
+ # @param move_results [boolean] If true, return values are moved instead of
298
+ # copied by default. If not set, uses the `:move_data` setting.
299
+ # @param move_block_arguments [boolean] If true, arguments to blocks are
300
+ # moved instead of copied by default. If not set, uses the `:move_data`
301
+ # setting.
302
+ # @param move_block_results [boolean] If true, result values from blocks
303
+ # are moved instead of copied by default. If not set, uses the
304
+ # `:move_data` setting.
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.
310
+ # @param enable_logging [boolean] Set to true to enable logging. Default
311
+ # is false.
117
312
  #
118
313
  def initialize(object,
119
- threads: 1,
120
- move: false,
314
+ use_current_ractor: false,
315
+ name: nil,
316
+ threads: 0,
317
+ move_data: false,
121
318
  move_arguments: nil,
122
- move_return: nil,
123
- logging: false,
124
- name: nil)
319
+ move_results: nil,
320
+ move_block_arguments: nil,
321
+ move_block_results: nil,
322
+ execute_blocks_in_place: nil,
323
+ enable_logging: false)
324
+ raise ::Ractor::MovedError, "cannot wrap a moved object" if ::Ractor::MovedObject === object
325
+
125
326
  @method_settings = {}
327
+ self.name = name || object_id.to_s
328
+ self.enable_logging = enable_logging
126
329
  self.threads = threads
127
- self.logging = logging
128
- self.name = name
129
- configure_method(move: move, move_arguments: move_arguments, move_return: move_return)
330
+ configure_method(move_data: move_data,
331
+ move_arguments: move_arguments,
332
+ move_results: move_results,
333
+ move_block_arguments: move_block_arguments,
334
+ move_block_results: move_block_results,
335
+ execute_blocks_in_place: execute_blocks_in_place)
130
336
  yield self if block_given?
131
337
  @method_settings.freeze
132
338
 
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")
339
+ if use_current_ractor
340
+ setup_local_server(object)
341
+ else
342
+ setup_isolated_server(object)
343
+ end
145
344
  @stub = Stub.new(self)
345
+
146
346
  freeze
147
347
  end
148
348
 
149
349
  ##
150
350
  # 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.
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.
154
355
  #
155
356
  # This method can be called only during an initialization block.
156
357
  # All settings are frozen once the wrapper is active.
@@ -159,7 +360,7 @@ class Ractor
159
360
  #
160
361
  def threads=(value)
161
362
  value = value.to_i
162
- value = 1 if value < 1
363
+ value = 0 if value.negative?
163
364
  @threads = value
164
365
  end
165
366
 
@@ -171,8 +372,8 @@ class Ractor
171
372
  #
172
373
  # @param value [Boolean]
173
374
  #
174
- def logging=(value)
175
- @logging = value ? true : false
375
+ def enable_logging=(value)
376
+ @enable_logging = value ? true : false
176
377
  end
177
378
 
178
379
  ##
@@ -199,49 +400,73 @@ class Ractor
199
400
  #
200
401
  # @param method_name [Symbol, nil] The name of the method being configured,
201
402
  # 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.
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.
207
421
  #
208
422
  def configure_method(method_name = nil,
209
- move: false,
423
+ move_data: false,
210
424
  move_arguments: nil,
211
- move_return: nil)
425
+ move_results: nil,
426
+ move_block_arguments: nil,
427
+ move_block_results: nil,
428
+ execute_blocks_in_place: nil)
212
429
  method_name = method_name.to_sym unless method_name.nil?
213
430
  @method_settings[method_name] =
214
- MethodSettings.new(move: move, move_arguments: move_arguments, move_return: move_return)
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)
215
437
  end
216
438
 
217
439
  ##
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.
440
+ # Return the name of this wrapper.
220
441
  #
221
- # @return [Ractor::Wrapper::Stub]
442
+ # @return [String]
222
443
  #
223
- attr_reader :stub
444
+ attr_reader :name
224
445
 
225
446
  ##
226
- # Return the number of threads used by the wrapper.
447
+ # Determine whether this wrapper runs in the current Ractor
227
448
  #
228
- # @return [Integer]
449
+ # @return [boolean]
229
450
  #
230
- attr_reader :threads
451
+ def use_current_ractor?
452
+ @ractor.nil?
453
+ end
231
454
 
232
455
  ##
233
456
  # Return whether logging is enabled for this wrapper.
234
457
  #
235
458
  # @return [Boolean]
236
459
  #
237
- attr_reader :logging
460
+ def enable_logging?
461
+ @enable_logging
462
+ end
238
463
 
239
464
  ##
240
- # Return the name of this wrapper.
465
+ # Return the number of worker threads used by the wrapper.
241
466
  #
242
- # @return [String, nil]
467
+ # @return [Integer]
243
468
  #
244
- attr_reader :name
469
+ attr_reader :threads
245
470
 
246
471
  ##
247
472
  # Return the method settings for the given method name. This returns the
@@ -257,6 +482,14 @@ class Ractor
257
482
  @method_settings[method_name] || @method_settings[nil]
258
483
  end
259
484
 
485
+ ##
486
+ # Return the wrapper stub. This is an object that responds to the same
487
+ # methods as the wrapped object, providing an easy way to call a wrapper.
488
+ #
489
+ # @return [Ractor::Wrapper::Stub]
490
+ #
491
+ attr_reader :stub
492
+
260
493
  ##
261
494
  # A lower-level interface for calling methods through the wrapper.
262
495
  #
@@ -265,20 +498,34 @@ class Ractor
265
498
  # @param kwargs [keywords] The keyword arguments
266
499
  # @return [Object] The return value
267
500
  #
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
501
+ def call(method_name, *args, **kwargs, &)
502
+ reply_port = ::Ractor::Port.new
503
+ transaction = ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
504
+ settings = method_settings(method_name)
505
+ block_arg = make_block_arg(settings, &)
506
+ message = CallMessage.new(method_name: method_name,
507
+ args: args,
508
+ kwargs: kwargs,
509
+ block_arg: block_arg,
510
+ transaction: transaction,
511
+ settings: settings,
512
+ reply_port: reply_port)
513
+ maybe_log("Sending method", method_name: method_name, transaction: transaction)
514
+ @port.send(message, move: settings.move_arguments?)
515
+ loop do
516
+ reply_message = reply_port.receive
517
+ case reply_message
518
+ when YieldMessage
519
+ handle_yield(reply_message, transaction, settings, method_name, &)
520
+ when ReturnMessage
521
+ maybe_log("Received result", method_name: method_name, transaction: transaction)
522
+ reply_port.close
523
+ return reply_message.value
524
+ when ExceptionMessage
525
+ maybe_log("Received exception", method_name: method_name, transaction: transaction)
526
+ reply_port.close
527
+ raise reply_message.exception
528
+ end
282
529
  end
283
530
  end
284
531
 
@@ -286,182 +533,256 @@ class Ractor
286
533
  # Request that the wrapper stop. All currently running calls will complete
287
534
  # before the wrapper actually terminates. However, any new calls will fail.
288
535
  #
289
- # This metnod is idempotent and can be called multiple times (even from
536
+ # This method is idempotent and can be called multiple times (even from
290
537
  # different ractors).
291
538
  #
292
539
  # @return [self]
293
540
  #
294
541
  def async_stop
295
- maybe_log("Stopping #{name}")
296
- @ractor.send(Message.new(:stop))
542
+ maybe_log("Stopping wrapper")
543
+ @port.send(StopMessage.new.freeze)
297
544
  self
298
545
  rescue ::Ractor::ClosedError
299
546
  # Ignore to allow stops to be idempotent.
300
547
  self
301
548
  end
302
549
 
550
+ ##
551
+ # Blocks until the wrapper has fully stopped.
552
+ #
553
+ # @return [self]
554
+ #
555
+ def join
556
+ if @ractor
557
+ @ractor.join
558
+ else
559
+ reply_port = ::Ractor::Port.new
560
+ @port.send(JoinMessage.new(reply_port))
561
+ reply_port.receive
562
+ reply_port.close
563
+ end
564
+ self
565
+ rescue ::Ractor::ClosedError
566
+ self
567
+ end
568
+
303
569
  ##
304
570
  # Retrieves the original object that was wrapped. This should be called
305
571
  # only after a stop request has been issued using {#async_stop}, and may
306
572
  # block until the wrapper has fully stopped.
307
573
  #
308
- # Only one ractor may call this method; any additional calls will fail.
574
+ # This can be called only if the wrapper was *not* configured with
575
+ # `use_current_ractor: true`. If the wrapper had that configuration, the
576
+ # object will not be moved, and does not need to be recovered. In such a
577
+ # case, any calls to this method will raise Ractor::Error.
578
+ #
579
+ # Only one ractor may call this method; any additional calls will fail with
580
+ # a Ractor::Error.
309
581
  #
310
582
  # @return [Object] The original wrapped object
311
583
  #
312
- def recovered_object
313
- @ractor.take
584
+ def recover_object
585
+ raise ::Ractor::Error, "cannot recover an object from a local wrapper" unless @ractor
586
+ @ractor.value
314
587
  end
315
588
 
316
- private
589
+ #### private items below ####
317
590
 
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
591
+ ##
592
+ # @private
593
+ # Message sent to initialize a server.
594
+ #
595
+ InitMessage = ::Data.define(:object, :enable_logging, :threads)
324
596
 
325
597
  ##
326
- # A stub that forwards calls to a wrapper.
598
+ # @private
599
+ # Message sent to a server to call a method
327
600
  #
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
601
+ CallMessage = ::Data.define(:method_name, :args, :kwargs, :block_arg,
602
+ :transaction, :settings, :reply_port)
338
603
 
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
604
+ ##
605
+ # @private
606
+ # Message sent to a server when a worker thread terminates
607
+ #
608
+ WorkerStoppedMessage = ::Data.define(:worker_num)
346
609
 
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
610
+ ##
611
+ # @private
612
+ # Message sent to a server to request it to stop
613
+ #
614
+ StopMessage = ::Data.define
355
615
 
356
616
  ##
357
- # Settings for a method call. Specifies how a method's arguments and
358
- # return value are communicated (i.e. copy or move semantics.)
617
+ # @private
618
+ # Message sent to a server to request a join response
359
619
  #
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
620
+ JoinMessage = ::Data.define(:reply_port)
369
621
 
370
- ##
371
- # @return [Boolean] Whether to move arguments
372
- #
373
- def move_arguments?
374
- @move_arguments
375
- end
622
+ ##
623
+ # @private
624
+ # Message sent to report a return value
625
+ #
626
+ ReturnMessage = ::Data.define(:value)
376
627
 
377
- ##
378
- # @return [Boolean] Whether to move return values
379
- #
380
- def move_return?
381
- @move_return
382
- end
628
+ ##
629
+ # @private
630
+ # Message sent to report an exception result
631
+ #
632
+ ExceptionMessage = ::Data.define(:exception)
383
633
 
384
- private
634
+ ##
635
+ # @private
636
+ # Message sent from a server to request a yield block run
637
+ #
638
+ YieldMessage = ::Data.define(:args, :kwargs, :reply_port)
385
639
 
386
- def interpret_setting(setting, default)
387
- if setting.nil?
388
- default ? true : false
389
- else
390
- setting ? true : false
391
- end
640
+ private
641
+
642
+ ##
643
+ # Start a server in the current Ractor.
644
+ # Passes the object directly to the server.
645
+ #
646
+ def setup_local_server(object)
647
+ maybe_log("Starting local server")
648
+ @ractor = nil
649
+ @port = ::Ractor::Port.new
650
+ ::Thread.new do
651
+ Server.run_local(object: object,
652
+ port: @port,
653
+ name: name,
654
+ enable_logging: enable_logging?,
655
+ threads: threads)
392
656
  end
393
657
  end
394
658
 
395
659
  ##
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.
399
- #
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.)
660
+ # Start a server in an isolated Ractor.
661
+ # This must send the object separately since it must be moved into the
662
+ # server's Ractor.
407
663
  #
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
664
+ def setup_isolated_server(object)
665
+ maybe_log("Starting isolated server")
666
+ @ractor = ::Ractor.new(name, enable_logging?, threads, name: "wrapper:#{name}") do |name, enable_logging, threads|
667
+ Server.run_isolated(name: name,
668
+ enable_logging: enable_logging,
669
+ threads: threads)
416
670
  end
671
+ @port = @ractor.default_port
672
+ @port.send(object, move: true)
673
+ end
417
674
 
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
675
+ ##
676
+ # Create a transaction ID, used for logging
677
+ #
678
+ def make_transaction
679
+ ::Random.rand(7_958_661_109_946_400_884_391_936).to_s(36).freeze
680
+ end
431
681
 
432
- def new_transaction
433
- ::Random.rand(7958661109946400884391936).to_s(36).freeze
682
+ ##
683
+ # Create the shareable object representing a block in a method call
684
+ #
685
+ def make_block_arg(settings, &)
686
+ if !block_given?
687
+ nil
688
+ elsif settings.execute_blocks_in_place?
689
+ ::Ractor.shareable_proc(&)
690
+ else
691
+ :send_block_message
434
692
  end
435
693
  end
436
694
 
437
695
  ##
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.
696
+ # Handle a call to a block directed to run in the caller environment.
442
697
  #
443
- # See the {#run} method for an overview of the Server implementation and
444
- # lifecycle.
698
+ def handle_yield(message, transaction, settings, method_name)
699
+ maybe_log("Yielding to block", method_name: method_name, transaction: transaction)
700
+ begin
701
+ block_result = yield(*message.args, **message.kwargs)
702
+ maybe_log("Sending block result", method_name: method_name, transaction: transaction)
703
+ message.reply_port.send(ReturnMessage.new(block_result), move: settings.move_block_results?)
704
+ rescue ::Exception => e # rubocop:disable Lint/RescueException
705
+ maybe_log("Sending block exception", method_name: method_name, transaction: transaction)
706
+ begin
707
+ message.reply_port.send(ExceptionMessage.new(e))
708
+ rescue ::StandardError
709
+ begin
710
+ message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
711
+ rescue ::StandardError
712
+ maybe_log("Failure to send block reply", method_name: method_name, transaction: transaction)
713
+ end
714
+ end
715
+ end
716
+ end
717
+
718
+ ##
719
+ # Prints out a log message
445
720
  #
721
+ def maybe_log(str, transaction: nil, method_name: nil)
722
+ return unless enable_logging?
723
+ metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper/#{name}"]
724
+ metadata << "Transaction/#{transaction}" if transaction
725
+ metadata << "Method/#{method_name}" if method_name
726
+ metadata = metadata.join(" ")
727
+ $stderr.puts("[#{metadata}] #{str}")
728
+ $stderr.flush
729
+ end
730
+
731
+ ##
446
732
  # @private
447
733
  #
734
+ # Server is the backend implementation of a wrapper. It listens for method
735
+ # call requests on a port, and calls the wrapped object in a controlled
736
+ # environment.
737
+ #
738
+ # It can run:
739
+ #
740
+ # * Either hosted by an external Ractor or isolated in a dedicated Ractor
741
+ # * Either sequentially or concurrently using worker threads.
742
+ #
448
743
  class Server
449
744
  ##
450
- # Handle the server lifecycle, running through the following phases:
745
+ # @private
746
+ # Create and run a server hosted in the current Ractor
451
747
  #
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.
748
+ def self.run_local(object:, port:, name:, enable_logging: false, threads: 0)
749
+ server = new(isolated: false, object:, port:, name:, enable_logging:, threads:)
750
+ server.run
751
+ end
752
+
753
+ ##
754
+ # @private
755
+ # Create and run a server in an isolated Ractor
456
756
  #
457
- # The server returns the wrapped object, allowing one client Ractor to
458
- # take it.
757
+ def self.run_isolated(name:, enable_logging: false, threads: 0)
758
+ port = ::Ractor.current.default_port
759
+ server = new(isolated: true, object: nil, port:, name:, enable_logging:, threads:)
760
+ server.run
761
+ end
762
+
763
+ # @private
764
+ def initialize(isolated:, object:, port:, name:, enable_logging:, threads:)
765
+ @isolated = isolated
766
+ @object = object
767
+ @port = port
768
+ @name = name
769
+ @enable_logging = enable_logging
770
+ @threads = threads.positive? ? threads : nil
771
+ @join_requests = []
772
+ end
773
+
774
+ ##
775
+ # @private
776
+ # Handle the server lifecycle.
777
+ # Returns the wrapped object, so it can be recovered if the server is run
778
+ # in a Ractor.
459
779
  #
460
780
  def run
461
- init_phase
462
- running_phase
463
- stopping_phase
464
- cleanup_phase
781
+ receive_remote_object if @isolated
782
+ start_workers if @threads
783
+ main_loop
784
+ stop_workers if @threads
785
+ cleanup
465
786
  @object
466
787
  rescue ::StandardError => e
467
788
  maybe_log("Unexpected error: #{e.inspect}")
@@ -471,200 +792,259 @@ class Ractor
471
792
  private
472
793
 
473
794
  ##
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.
795
+ # Receive the moved remote object. Called if the server is run in a
796
+ # separate Ractor.
481
797
  #
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")
798
+ def receive_remote_object
799
+ maybe_log("Waiting for remote object")
800
+ @object = @port.receive
497
801
  end
498
802
 
499
803
  ##
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.
804
+ # Start the worker threads. Each thread picks up methods to run from a
805
+ # shared queue. Called only if worker threading is enabled.
506
806
  #
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)
807
+ def start_workers
808
+ @queue = ::Queue.new
809
+ maybe_log("Spawning #{@threads} worker threads")
810
+ (1..@threads).map do |worker_num|
811
+ ::Thread.new { worker_thread(worker_num) }
515
812
  end
516
- ensure
517
- maybe_worker_log(worker_num, "Stopping")
518
- ::Ractor.current.send(Message.new(:thread_stopped, data: worker_num), move: true)
519
813
  end
520
814
 
521
815
  ##
522
- # In the **running phase**, the Server listens on the Ractor's inbox and
523
- # handles messages for normal operation:
816
+ # This is the main loop, listening on the inbox and handling messages for
817
+ # normal operation:
524
818
  #
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.
819
+ # * If it receives a CallMessage, it either runs the method (when in
820
+ # sequential mode) or adds it to the job queue (when in worker mode).
821
+ # * If it receives a StopMessage, it exits the main loop and proceeds
822
+ # to the termination logic.
823
+ # * If it receives a JoinMessage, it adds it to the list of join ports
824
+ # to notify once the wrapper completes.
825
+ # * If it receives a WorkerStoppedMessage, that indicates a worker
826
+ # thread has unexpectedly stopped. We conclude something has gone
827
+ # wrong with a worker, and we bail, stopping the remaining workers
828
+ # and proceeding to termination logic.
534
829
  #
535
- def running_phase
830
+ def main_loop
536
831
  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
832
+ maybe_log("Waiting for message in running phase")
833
+ message = @port.receive
834
+ case message
835
+ when CallMessage
836
+ maybe_log("Received CallMessage", call_message: message)
837
+ if @threads
838
+ @queue.enq(message)
839
+ else
840
+ handle_method(message)
841
+ end
842
+ when WorkerStoppedMessage
843
+ maybe_log("Received unexpected WorkerStoppedMessage")
844
+ @threads -= 1 if @threads
548
845
  break
549
- when :stop
846
+ when StopMessage
550
847
  maybe_log("Received stop")
551
848
  break
849
+ when JoinMessage
850
+ maybe_log("Received and queueing join request")
851
+ @join_requests << message.reply_port
552
852
  end
553
853
  end
554
854
  end
555
855
 
556
856
  ##
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.
857
+ # This signals workers to stop by closing the queue, and then waits for
858
+ # all workers to report in that they have stopped. It is called only if
859
+ # worker threading is enabled.
860
+ #
861
+ # Responds to messages to indicate the wrapper is stopping and no longer
862
+ # accepting new method requests:
863
+ #
864
+ # * If it receives a CallMessage, it sends back a refusal exception.
865
+ # * If it receives a StopMessage, it does nothing (i.e. the stop
866
+ # operation is idempotent).
867
+ # * If it receives a JoinMessage, it adds it to the list of join ports
868
+ # to notify once the wrapper completes. At this point the wrapper is
869
+ # not yet considered complete because workers are still processing
870
+ # earlier method calls.
871
+ # * If it receives a WorkerStoppedMessage, it updates its count of
872
+ # running workers.
563
873
  #
564
- def stopping_phase
874
+ # This phase continues until all workers have signaled that they have
875
+ # stopped.
876
+ #
877
+ def stop_workers
565
878
  @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
879
+ while @threads.positive?
880
+ maybe_log("Waiting for message in stopping phase")
881
+ message = @port.receive
882
+ case message
883
+ when CallMessage
884
+ refuse_method(message)
885
+ when WorkerStoppedMessage
886
+ maybe_log("Acknowledged WorkerStoppedMessage: #{message.worker_num}")
887
+ @threads -= 1
888
+ when StopMessage
889
+ maybe_log("Stop received when already stopping")
890
+ when JoinMessage
891
+ maybe_log("Received and queueing join request")
892
+ @join_requests << message.reply_port
893
+ end
894
+ end
895
+ end
896
+
897
+ ##
898
+ # This is called when the Server is ready to terminate completely.
899
+ # It closes the inbox and responds to any remaining contents.
900
+ #
901
+ def cleanup
902
+ maybe_log("Closing inbox")
903
+ @port.close
904
+ maybe_log("Responding to join requests")
905
+ @join_requests.each { |port| send_join_reply(port) }
906
+ maybe_log("Draining inbox")
907
+ loop do
908
+ message = begin
909
+ @port.receive
910
+ rescue ::Ractor::ClosedError
911
+ maybe_log("Inbox is empty")
912
+ nil
913
+ end
914
+ break if message.nil?
915
+ case message
916
+ when CallMessage
572
917
  refuse_method(message)
573
- when :thread_stopped
574
- @thread_count -= 1
918
+ when WorkerStoppedMessage
919
+ maybe_log("Unexpected WorkerStoppedMessage when in cleanup")
920
+ when StopMessage
921
+ maybe_log("Stop received when already stopping")
922
+ when JoinMessage
923
+ maybe_log("Received and responding immediately to join request")
924
+ send_join_reply(message.reply_port)
575
925
  end
576
926
  end
577
927
  end
578
928
 
579
929
  ##
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.
930
+ # A worker thread repeatedly pulls a method call requests off the job
931
+ # queue, handles it, and sends back a response. It also removes the
932
+ # request from the pending request list to signal that it has responded.
933
+ # If no job is available, the thread blocks while waiting. If the queue
934
+ # is closed, the worker will send an acknowledgement message and then
935
+ # terminate.
585
936
  #
586
- def cleanup_phase
587
- ::Ractor.current.close_incoming
588
- maybe_log("Checking message queue for cleanup")
937
+ def worker_thread(worker_num)
938
+ maybe_log("Worker starting", worker_num: worker_num)
589
939
  loop do
590
- message = ::Ractor.receive
591
- refuse_method(message) if message.is_a?(Message) && message.type == :call
940
+ maybe_log("Waiting for job", worker_num: worker_num)
941
+ message = @queue.deq
942
+ break if message.nil?
943
+ handle_method(message, worker_num: worker_num)
592
944
  end
593
- maybe_log("Checking current calls for cleanup")
594
- @current_calls.each_value do |request|
595
- refuse_method(request)
945
+ ensure
946
+ maybe_log("Worker stopping", worker_num: worker_num)
947
+ begin
948
+ @port.send(WorkerStoppedMessage.new(worker_num))
949
+ rescue ::Ractor::ClosedError
950
+ maybe_log("Orphaned worker thread", worker_num: worker_num)
596
951
  end
597
- rescue ::Ractor::ClosedError
598
- maybe_log("Message queue is empty")
599
952
  end
600
953
 
601
954
  ##
602
- # This is called within a worker thread to handle a method call request.
955
+ # This is called to handle a method call request.
603
956
  # It calls the method on the wrapped object, and then sends back a
604
957
  # response to the caller. If an exception was raised, it sends back an
605
958
  # error response. It tries very hard always to send a response of some
606
959
  # kind; if an error occurs while constructing or sending a response, it
607
- # will catch the exception and try to send a simpler response.
960
+ # will catch the exception and try to send a simpler response. If a block
961
+ # was passed to the method, it is also handled here.
608
962
  #
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})")
963
+ def handle_method(message, worker_num: nil)
964
+ block = make_block(message)
965
+ maybe_log("Running method", worker_num: worker_num, call_message: message)
614
966
  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?)
967
+ result = @object.__send__(message.method_name, *message.args, **message.kwargs, &block)
968
+ maybe_log("Sending return value", worker_num: worker_num, call_message: message)
969
+ message.reply_port.send(ReturnMessage.new(result), move: message.settings.move_results?)
619
970
  rescue ::Exception => e # rubocop:disable Lint/RescueException
620
- maybe_worker_log(worker_num, "Sending exception (transaction=#{transaction})")
971
+ maybe_log("Sending exception", worker_num: worker_num, call_message: message)
621
972
  begin
622
- sender.send(Message.new(:error, data: e, transaction: transaction))
973
+ message.reply_port.send(ExceptionMessage.new(e))
623
974
  rescue ::StandardError
624
- safe_error = begin
625
- ::StandardError.new(e.inspect)
975
+ begin
976
+ message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
626
977
  rescue ::StandardError
627
- ::StandardError.new("Unknown error")
978
+ maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
628
979
  end
629
- sender.send(Message.new(:error, data: safe_error, transaction: transaction))
630
980
  end
631
981
  end
632
982
  end
633
983
 
634
984
  ##
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.
985
+ # Creates a block appropriate to the block specification received with
986
+ # the method call message. This could return:
638
987
  #
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
988
+ # * nil if there was no block
989
+ # * the proc itself, if a shareable proc was received
990
+ # * otherwise a proc that sends a message back to the caller, along
991
+ # with the block arguments, to run the block in the caller's
992
+ # environment
993
+ #
994
+ def make_block(message)
995
+ return message.block_arg unless message.block_arg == :send_block_message
996
+ proc do |*args, **kwargs|
997
+ reply_port = ::Ractor::Port.new
998
+ yield_message = YieldMessage.new(args: args, kwargs: kwargs, reply_port: reply_port)
999
+ message.reply_port.send(yield_message, move: message.settings.move_block_arguments?)
1000
+ reply_message = reply_port.receive
1001
+ reply_port.close
1002
+ case reply_message
1003
+ when ExceptionMessage
1004
+ raise reply_message.exception
1005
+ when ReturnMessage
1006
+ reply_message.value
1007
+ end
648
1008
  end
649
1009
  end
650
1010
 
651
- def unregister_call(transaction)
652
- @mutex.synchronize do
653
- @current_calls.delete(transaction)
1011
+ ##
1012
+ # This is called from the main Ractor thread to report to a caller that
1013
+ # the wrapper cannot handle a requested method call, likely because the
1014
+ # wrapper is shutting down.
1015
+ #
1016
+ def refuse_method(message)
1017
+ maybe_log("Refusing method call", call_message: message)
1018
+ begin
1019
+ error = ::Ractor::ClosedError.new("Wrapper is shutting down")
1020
+ message.reply_port.send(ExceptionMessage.new(error))
1021
+ rescue ::Ractor::Error
1022
+ maybe_log("Failed to send refusal message", call_message: message)
654
1023
  end
655
1024
  end
656
1025
 
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
1026
+ ##
1027
+ # This attempts to send a signal that a wrapper join has completed.
1028
+ #
1029
+ def send_join_reply(port)
1030
+ port.send(nil)
1031
+ rescue ::Ractor::ClosedError
1032
+ maybe_log("Join reply port is closed")
662
1033
  end
663
1034
 
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}")
1035
+ ##
1036
+ # Print out a log message
1037
+ #
1038
+ def maybe_log(str, call_message: nil, worker_num: nil, transaction: nil, method_name: nil)
1039
+ return unless @enable_logging
1040
+ transaction ||= call_message&.transaction
1041
+ method_name ||= call_message&.method_name
1042
+ metadata = [::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L"), "Ractor::Wrapper/#{@name}"]
1043
+ metadata << "Worker/#{worker_num}" if worker_num
1044
+ metadata << "Transaction/#{transaction}" if transaction
1045
+ metadata << "Method/#{method_name}" if method_name
1046
+ metadata = metadata.join(" ")
1047
+ $stderr.puts("[#{metadata}] #{str}")
668
1048
  $stderr.flush
669
1049
  end
670
1050
  end