ractor-wrapper 0.1.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,166 +1,387 @@
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.
10
+ #
11
+ # WARNING: This is a highly experimental library, and currently _not_
12
+ # recommended for production use. (As of Ruby 4.0.0, the same can be said of
13
+ # Ractors in general.)
8
14
  #
9
15
  # ## What is Ractor::Wrapper?
10
16
  #
11
- # Ractors for the most part cannot access objects concurrently with other
12
- # Ractors unless the object is _shareable_ (that is, deeply immutable along
13
- # with a few other restrictions.) If multiple Ractors need to access a shared
14
- # resource that is stateful or otherwise not Ractor-shareable, that resource
15
- # 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
16
86
  #
17
- # `Ractor::Wrapper` makes it possible for such a shared resource to be
18
- # implemented as an object and accessed using ordinary method calls. It does
19
- # this by "wrapping" the object in a Ractor, and mapping method calls to
20
- # message passing. This may make it easier to implement such a resource with
21
- # a simple class rather than a full-blown Ractor with message passing, and it
22
- # may also useful for adapting existing legacy object-based implementations.
87
+ # ## SQLite3 example
23
88
  #
24
- # Given a shared resource object, `Ractor::Wrapper` starts a new Ractor and
25
- # "runs" the object within that Ractor. It provides you with a stub object
26
- # on which you can invoke methods. The wrapper responds to these method calls
27
- # by sending messages to the internal Ractor, which invokes the shared object
28
- # and then sends back the result. If the underlying object is thread-safe,
29
- # you can configure the wrapper to run multiple threads that can run methods
30
- # 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.
31
91
  #
32
- # ## Example usage
92
+ # require "ractor/wrapper"
93
+ # require "sqlite3"
33
94
  #
34
- # The following example shows how to share a single `Faraday::Conection`
35
- # object among multiple Ractors. Because `Faraday::Connection` is not itself
36
- # 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)
37
98
  #
38
- # 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)
39
104
  #
40
- # # Create a Faraday connection and a wrapper for it.
41
- # connection = Faraday.new "http://example.com"
42
- # 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")
43
108
  #
44
- # # At this point, the connection ojbect cannot be accessed directly
45
- # # because it has been "moved" to the wrapper's internal Ractor.
46
- # # 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")
47
112
  #
48
- # # However, any number of Ractors can now access it through the wrapper.
49
- # # By default, access to the object is serialized; methods will not be
50
- # # invoked concurrently.
51
- # r1 = Ractor.new(wrapper) do |w|
52
- # 10.times do
53
- # 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")
54
119
  # end
55
120
  # :ok
56
121
  # end
57
- # r2 = Ractor.new(wrapper) do |w|
58
- # 10.times do
59
- # 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")
60
125
  # end
61
126
  # :ok
62
127
  # end
63
128
  #
64
129
  # # Wait for the two above Ractors to finish.
65
- # r1.take
66
- # r2.take
130
+ # r1.join
131
+ # r2.join
67
132
  #
68
- # # After you stop the wrapper, you can retrieve the underlying
69
- # # 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.
70
135
  # wrapper.async_stop
71
- # connection = wrapper.recover_object
72
- # 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
73
142
  #
74
143
  # ## Features
75
144
  #
76
- # * Provides a method interface to an object running in a different Ractor.
145
+ # * Provides a Ractor-shareable method interface to a non-shareable object.
77
146
  # * Supports arbitrary method arguments and return values.
78
- # * Supports exceptions thrown by the method.
79
- # * Can serialize method calls for non-concurrency-safe objects, or run
80
- # 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.
81
155
  # * Can gracefully shut down the wrapper and retrieve the original object.
82
156
  #
83
157
  # ## Caveats
84
158
  #
85
- # Ractor::Wrapper is subject to some limitations (and bugs) of Ractors, as of
86
- # Ruby 3.0.0.
87
- #
88
- # * You cannot pass blocks to wrapped methods.
89
159
  # * Certain types cannot be used as method arguments or return values
90
- # because Ractor does not allow them to be moved between Ractors. These
91
- # include threads, procs, backtraces, and a few others.
92
- # * You can call wrapper methods from multiple Ractors concurrently, but
93
- # you cannot call them from multiple Threads within a single Ractor.
94
- # (This is due to https://bugs.ruby-lang.org/issues/17624)
95
- # * If you close the incoming port on a Ractor, it will no longer be able
96
- # to call out via a wrapper. If you close its incoming port while a call
97
- # is currently pending, that call may hang. (This is due to
98
- # 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.
99
173
  #
100
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
+
101
274
  ##
102
275
  # Create a wrapper around the given object.
103
276
  #
104
- # If you pass an optional block, the wrapper itself will be yielded to it
105
- # at which time you can set additional configuration options. (The
106
- # 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.
107
281
  #
108
282
  # @param object [Object] The non-shareable object to wrap.
109
- # @param threads [Integer,nil] The number of worker threads to run.
110
- # Defaults to `nil`, which causes the worker to serialize calls.
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.
288
+ # @param threads [Integer] The number of worker threads to run.
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.
111
312
  #
112
- def initialize(object, threads: nil, logging: false, name: nil)
313
+ def initialize(object,
314
+ use_current_ractor: false,
315
+ name: nil,
316
+ threads: 0,
317
+ move_data: false,
318
+ move_arguments: 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
+
326
+ @method_settings = {}
327
+ self.name = name || object_id.to_s
328
+ self.enable_logging = enable_logging
113
329
  self.threads = threads
114
- self.logging = logging
115
- self.name = name
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)
116
336
  yield self if block_given?
337
+ @method_settings.freeze
117
338
 
118
- maybe_log("Starting server")
119
- @ractor = ::Ractor.new(name: name) { Server.new.run }
120
- opts = {name: @name, threads: @threads, logging: @logging}
121
- @ractor.send([object, opts], move: true)
122
-
123
- maybe_log("Server ready")
339
+ if use_current_ractor
340
+ setup_local_server(object)
341
+ else
342
+ setup_isolated_server(object)
343
+ end
124
344
  @stub = Stub.new(self)
345
+
125
346
  freeze
126
347
  end
127
348
 
128
349
  ##
129
350
  # Set the number of threads to run in the wrapper. If the underlying object
130
- # is thread-safe, this allows concurrent calls to it. If the underlying
131
- # object is not thread-safe, you should leave this set to `nil`, which will
132
- # cause calls to be serialized. Setting the thread count to 1 is
133
- # effectively the same as no threading.
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.
134
355
  #
135
356
  # This method can be called only during an initialization block.
357
+ # All settings are frozen once the wrapper is active.
136
358
  #
137
- # @param value [Integer,nil]
359
+ # @param value [Integer]
138
360
  #
139
361
  def threads=(value)
140
- if value
141
- value = value.to_i
142
- value = 1 if value < 1
143
- @threads = value
144
- else
145
- @threads = nil
146
- end
362
+ value = value.to_i
363
+ value = 0 if value.negative?
364
+ @threads = value
147
365
  end
148
366
 
149
367
  ##
150
368
  # Enable or disable internal debug logging.
151
369
  #
152
370
  # This method can be called only during an initialization block.
371
+ # All settings are frozen once the wrapper is active.
153
372
  #
154
373
  # @param value [Boolean]
155
374
  #
156
- def logging=(value)
157
- @logging = value ? true : false
375
+ def enable_logging=(value)
376
+ @enable_logging = value ? true : false
158
377
  end
159
378
 
160
379
  ##
161
- # Set the name of this wrapper, shown in logging.
380
+ # Set the name of this wrapper. This is shown in logging, and is also used
381
+ # as the name of the wrapping Ractor.
162
382
  #
163
383
  # This method can be called only during an initialization block.
384
+ # All settings are frozen once the wrapper is active.
164
385
  #
165
386
  # @param value [String, nil]
166
387
  #
@@ -169,56 +390,142 @@ class Ractor
169
390
  end
170
391
 
171
392
  ##
172
- # Return the wrapper stub. This is an object that responds to the same
173
- # methods as the wrapped object, providing an easy way to call a wrapper.
393
+ # Configure the move semantics for the given method (or the default
394
+ # settings if no method name is given.) That is, determine whether
395
+ # arguments, return values, and/or exceptions are copied or moved when
396
+ # communicated with the wrapper. By default, all objects are copied.
174
397
  #
175
- # @return [Ractor::Wrapper::Stub]
398
+ # This method can be called only during an initialization block.
399
+ # All settings are frozen once the wrapper is active.
176
400
  #
177
- attr_reader :stub
401
+ # @param method_name [Symbol, nil] The name of the method being configured,
402
+ # or `nil` to set defaults for all methods not configured explicitly.
403
+ # @param move_data [boolean] If true, communication for this method will
404
+ # move instead of copy arguments and return values. Default is false.
405
+ # This setting can be overridden by other `:move_*` settings.
406
+ # @param move_arguments [boolean] If true, arguments for this method are
407
+ # moved instead of copied. If not set, uses the `:move_data` setting.
408
+ # @param move_results [boolean] If true, return values for this method are
409
+ # moved instead of copied. If not set, uses the `:move_data` setting.
410
+ # @param move_block_arguments [boolean] If true, arguments to blocks passed
411
+ # to this method are moved instead of copied. If not set, uses the
412
+ # `:move_data` setting.
413
+ # @param move_block_results [boolean] If true, result values from blocks
414
+ # passed to this method are moved instead of copied. If not set, uses
415
+ # the `:move_data` setting.
416
+ # @param execute_blocks_in_place [boolean] If true, blocks passed to this
417
+ # method are made shareable and passed into the wrapper to be executed
418
+ # in the wrapped environment. If false (the default), blocks are
419
+ # replaced by a proc that passes messages back out to the caller and
420
+ # executes the block in the caller's environment.
421
+ #
422
+ def configure_method(method_name = nil,
423
+ move_data: false,
424
+ move_arguments: nil,
425
+ move_results: nil,
426
+ move_block_arguments: nil,
427
+ move_block_results: nil,
428
+ execute_blocks_in_place: nil)
429
+ method_name = method_name.to_sym unless method_name.nil?
430
+ @method_settings[method_name] =
431
+ MethodSettings.new(move_data: move_data,
432
+ move_arguments: move_arguments,
433
+ move_results: move_results,
434
+ move_block_arguments: move_block_arguments,
435
+ move_block_results: move_block_results,
436
+ execute_blocks_in_place: execute_blocks_in_place)
437
+ end
178
438
 
179
439
  ##
180
- # Return the number of threads used by the wrapper, or `nil` for no
181
- # no threading.
440
+ # Return the name of this wrapper.
182
441
  #
183
- # @return [Integer, nil]
442
+ # @return [String]
184
443
  #
185
- attr_reader :threads
444
+ attr_reader :name
186
445
 
187
446
  ##
188
- # Return whether logging is enabled for this wrapper
447
+ # Determine whether this wrapper runs in the current Ractor
448
+ #
449
+ # @return [boolean]
450
+ #
451
+ def use_current_ractor?
452
+ @ractor.nil?
453
+ end
454
+
455
+ ##
456
+ # Return whether logging is enabled for this wrapper.
189
457
  #
190
458
  # @return [Boolean]
191
459
  #
192
- attr_reader :logging
460
+ def enable_logging?
461
+ @enable_logging
462
+ end
193
463
 
194
464
  ##
195
- # Return the name of this wrapper.
465
+ # Return the number of worker threads used by the wrapper.
196
466
  #
197
- # @return [String, nil]
467
+ # @return [Integer]
198
468
  #
199
- attr_reader :name
469
+ attr_reader :threads
470
+
471
+ ##
472
+ # Return the method settings for the given method name. This returns the
473
+ # default method settings if the given method is not configured explicitly
474
+ # by name.
475
+ #
476
+ # @param method_name [Symbol,nil] The method name, or `nil` to return the
477
+ # defaults.
478
+ # @return [MethodSettings]
479
+ #
480
+ def method_settings(method_name)
481
+ method_name = method_name.to_sym
482
+ @method_settings[method_name] || @method_settings[nil]
483
+ end
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
200
492
 
201
493
  ##
202
- # A lower-level interface for calling the wrapper.
494
+ # A lower-level interface for calling methods through the wrapper.
203
495
  #
204
496
  # @param method_name [Symbol] The name of the method to call
205
497
  # @param args [arguments] The positional arguments
206
498
  # @param kwargs [keywords] The keyword arguments
207
499
  # @return [Object] The return value
208
500
  #
209
- def call(method_name, *args, **kwargs)
210
- request = Message.new(:call, data: [method_name, args, kwargs])
211
- transaction = request.transaction
212
- maybe_log("Sending method #{method_name} (transaction=#{transaction})")
213
- @ractor.send(request, move: true)
214
- reply = ::Ractor.receive_if { |msg| msg.is_a?(Message) && msg.transaction == transaction }
215
- case reply.type
216
- when :result
217
- maybe_log("Received result for method #{method_name} (transaction=#{transaction})")
218
- reply.data
219
- when :error
220
- maybe_log("Received exception for method #{method_name} (transaction=#{transaction})")
221
- 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
222
529
  end
223
530
  end
224
531
 
@@ -226,14 +533,14 @@ class Ractor
226
533
  # Request that the wrapper stop. All currently running calls will complete
227
534
  # before the wrapper actually terminates. However, any new calls will fail.
228
535
  #
229
- # 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
230
537
  # different ractors).
231
538
  #
232
539
  # @return [self]
233
540
  #
234
541
  def async_stop
235
- maybe_log("Stopping #{name}")
236
- @ractor.send(Message.new(:stop))
542
+ maybe_log("Stopping wrapper")
543
+ @port.send(StopMessage.new.freeze)
237
544
  self
238
545
  rescue ::Ractor::ClosedError
239
546
  # Ignore to allow stops to be idempotent.
@@ -241,87 +548,241 @@ class Ractor
241
548
  end
242
549
 
243
550
  ##
244
- # Return the original object that was wrapped. The object is returned after
245
- # the wrapper finishes stopping. Only one ractor may call this method; any
246
- # additional calls will fail.
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
+
569
+ ##
570
+ # Retrieves the original object that was wrapped. This should be called
571
+ # only after a stop request has been issued using {#async_stop}, and may
572
+ # block until the wrapper has fully stopped.
573
+ #
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.
247
581
  #
248
582
  # @return [Object] The original wrapped object
249
583
  #
250
- def recovered_object
251
- @ractor.take
584
+ def recover_object
585
+ raise ::Ractor::Error, "cannot recover an object from a local wrapper" unless @ractor
586
+ @ractor.value
252
587
  end
253
588
 
254
- private
589
+ #### private items below ####
255
590
 
256
- def maybe_log(str)
257
- return unless logging
258
- time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
259
- $stderr.puts("[#{time} Ractor::Wrapper/#{name}]: #{str}")
260
- $stderr.flush
261
- end
591
+ ##
592
+ # @private
593
+ # Message sent to initialize a server.
594
+ #
595
+ InitMessage = ::Data.define(:object, :enable_logging, :threads)
262
596
 
263
597
  ##
264
- # A stub that forwards calls to a wrapper.
598
+ # @private
599
+ # Message sent to a server to call a method
265
600
  #
266
- class Stub
267
- ##
268
- # Create a stub given a wrapper.
269
- #
270
- # @param wrapper [Ractor::Wrapper]
271
- #
272
- def initialize(wrapper)
273
- @wrapper = wrapper
274
- freeze
275
- end
601
+ CallMessage = ::Data.define(:method_name, :args, :kwargs, :block_arg,
602
+ :transaction, :settings, :reply_port)
276
603
 
277
- ##
278
- # Forward calls to {Ractor::Wrapper#call}.
279
- #
280
- def method_missing(name, *args, **kwargs)
281
- @wrapper.call(name, *args, **kwargs)
282
- end
604
+ ##
605
+ # @private
606
+ # Message sent to a server when a worker thread terminates
607
+ #
608
+ WorkerStoppedMessage = ::Data.define(:worker_num)
283
609
 
284
- # @private
285
- def respond_to_missing?(name, include_all)
286
- @wrapper.respond_to?(name, include_all)
610
+ ##
611
+ # @private
612
+ # Message sent to a server to request it to stop
613
+ #
614
+ StopMessage = ::Data.define
615
+
616
+ ##
617
+ # @private
618
+ # Message sent to a server to request a join response
619
+ #
620
+ JoinMessage = ::Data.define(:reply_port)
621
+
622
+ ##
623
+ # @private
624
+ # Message sent to report a return value
625
+ #
626
+ ReturnMessage = ::Data.define(:value)
627
+
628
+ ##
629
+ # @private
630
+ # Message sent to report an exception result
631
+ #
632
+ ExceptionMessage = ::Data.define(:exception)
633
+
634
+ ##
635
+ # @private
636
+ # Message sent from a server to request a yield block run
637
+ #
638
+ YieldMessage = ::Data.define(:args, :kwargs, :reply_port)
639
+
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)
287
656
  end
288
657
  end
289
658
 
290
- # @private
291
- class Message
292
- def initialize(type, data: nil, transaction: nil)
293
- @sender = ::Ractor.current
294
- @type = type
295
- @data = data
296
- @transaction = transaction || new_transaction
297
- freeze
659
+ ##
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.
663
+ #
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)
298
670
  end
671
+ @port = @ractor.default_port
672
+ @port.send(object, move: true)
673
+ end
299
674
 
300
- attr_reader :type
301
- attr_reader :sender
302
- attr_reader :transaction
303
- attr_reader :data
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
304
681
 
305
- private
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
692
+ end
693
+ end
306
694
 
307
- def new_transaction
308
- ::Random.rand(7958661109946400884391936).to_s(36).freeze
695
+ ##
696
+ # Handle a call to a block directed to run in the caller environment.
697
+ #
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
309
715
  end
310
716
  end
311
717
 
718
+ ##
719
+ # Prints out a log message
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
+ ##
312
732
  # @private
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
+ #
313
743
  class Server
314
- def run
315
- @object, opts = ::Ractor.receive
316
- @logging = opts[:logging]
317
- @name = opts[:name]
318
- maybe_log("Server started")
744
+ ##
745
+ # @private
746
+ # Create and run a server hosted in the current Ractor
747
+ #
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
756
+ #
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
319
762
 
320
- queue = start_threads(opts[:threads])
321
- running_phase(queue)
322
- stopping_phase if queue
323
- cleanup_phase
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
324
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.
779
+ #
780
+ def run
781
+ receive_remote_object if @isolated
782
+ start_workers if @threads
783
+ main_loop
784
+ stop_workers if @threads
785
+ cleanup
325
786
  @object
326
787
  rescue ::StandardError => e
327
788
  maybe_log("Unexpected error: #{e.inspect}")
@@ -330,112 +791,260 @@ class Ractor
330
791
 
331
792
  private
332
793
 
333
- def start_threads(thread_count)
334
- return nil unless thread_count
335
- queue = ::Queue.new
336
- maybe_log("Spawning #{thread_count} threads")
337
- threads = (1..thread_count).map do |worker_num|
338
- ::Thread.new { worker_thread(worker_num, queue) }
339
- end
340
- ::Thread.new { monitor_thread(threads) }
341
- queue
794
+ ##
795
+ # Receive the moved remote object. Called if the server is run in a
796
+ # separate Ractor.
797
+ #
798
+ def receive_remote_object
799
+ maybe_log("Waiting for remote object")
800
+ @object = @port.receive
342
801
  end
343
802
 
344
- def worker_thread(worker_num, queue)
345
- maybe_worker_log(worker_num, "Starting")
346
- loop do
347
- maybe_worker_log(worker_num, "Waiting for job")
348
- request = queue.deq
349
- if request.nil?
350
- break
351
- end
352
- handle_method(worker_num, request)
803
+ ##
804
+ # Start the worker threads. Each thread picks up methods to run from a
805
+ # shared queue. Called only if worker threading is enabled.
806
+ #
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) }
353
812
  end
354
- maybe_worker_log(worker_num, "Stopping")
355
- end
356
-
357
- def monitor_thread(workers)
358
- workers.each(&:join)
359
- maybe_log("All workers finished")
360
- ::Ractor.current.send(Message.new(:threads_stopped))
361
813
  end
362
814
 
363
- def running_phase(queue)
815
+ ##
816
+ # This is the main loop, listening on the inbox and handling messages for
817
+ # normal operation:
818
+ #
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.
829
+ #
830
+ def main_loop
364
831
  loop do
365
- maybe_log("Waiting for message")
366
- request = ::Ractor.receive_if { |msg| msg.is_a?(Message) }
367
- case request.type
368
- when :call
369
- if queue
370
- queue.enq(request)
371
- maybe_log("Queued method #{request.data.first} (transaction=#{request.transaction})")
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)
372
839
  else
373
- handle_method(0, request)
840
+ handle_method(message)
374
841
  end
375
- when :stop
842
+ when WorkerStoppedMessage
843
+ maybe_log("Received unexpected WorkerStoppedMessage")
844
+ @threads -= 1 if @threads
845
+ break
846
+ when StopMessage
376
847
  maybe_log("Received stop")
377
- queue&.close
378
848
  break
849
+ when JoinMessage
850
+ maybe_log("Received and queueing join request")
851
+ @join_requests << message.reply_port
379
852
  end
380
853
  end
381
854
  end
382
855
 
383
- def stopping_phase
856
+ ##
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.
873
+ #
874
+ # This phase continues until all workers have signaled that they have
875
+ # stopped.
876
+ #
877
+ def stop_workers
878
+ @queue.close
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")
384
907
  loop do
385
- maybe_log("Waiting for message")
386
- message = ::Ractor.receive_if { |msg| msg.is_a?(Message) }
387
- case message.type
388
- when :call
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
389
917
  refuse_method(message)
390
- when :threads_stopped
391
- break
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)
392
925
  end
393
926
  end
394
927
  end
395
928
 
396
- def cleanup_phase
397
- ::Ractor.current.close_incoming
929
+ ##
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.
936
+ #
937
+ def worker_thread(worker_num)
938
+ maybe_log("Worker starting", worker_num: worker_num)
398
939
  loop do
399
- maybe_log("Checking queue for cleanup")
400
- message = ::Ractor.receive
401
- 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)
944
+ end
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)
402
951
  end
403
- rescue ::Ractor::ClosedError
404
- maybe_log("Queue is empty")
405
952
  end
406
953
 
407
- def handle_method(worker_num, request)
408
- method_name, args, kwargs = request.data
409
- transaction = request.transaction
410
- sender = request.sender
411
- maybe_worker_log(worker_num, "Running method #{method_name} (transaction=#{transaction})")
954
+ ##
955
+ # This is called to handle a method call request.
956
+ # It calls the method on the wrapped object, and then sends back a
957
+ # response to the caller. If an exception was raised, it sends back an
958
+ # error response. It tries very hard always to send a response of some
959
+ # kind; if an error occurs while constructing or sending a response, it
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.
962
+ #
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)
412
966
  begin
413
- result = @object.send(method_name, *args, **kwargs)
414
- maybe_worker_log(worker_num, "Sending result (transaction=#{transaction})")
415
- sender.send(Message.new(:result, data: result, transaction: transaction), move: true)
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?)
416
970
  rescue ::Exception => e # rubocop:disable Lint/RescueException
417
- maybe_worker_log(worker_num, "Sending exception (transaction=#{transaction})")
418
- sender.send(Message.new(:error, data: e, transaction: transaction))
971
+ maybe_log("Sending exception", worker_num: worker_num, call_message: message)
972
+ begin
973
+ message.reply_port.send(ExceptionMessage.new(e))
974
+ rescue ::StandardError
975
+ begin
976
+ message.reply_port.send(ExceptionMessage.new(::StandardError.new(e.inspect)))
977
+ rescue ::StandardError
978
+ maybe_log("Failure to send method response", worker_num: worker_num, call_message: message)
979
+ end
980
+ end
419
981
  end
420
982
  end
421
983
 
422
- def refuse_method(request)
423
- maybe_log("Refusing method call (transaction=#{message.transaction})")
424
- error = ::Ractor::ClosedError.new
425
- request.sender.send(Message.new(:error, data: error, transaction: message.transaction))
984
+ ##
985
+ # Creates a block appropriate to the block specification received with
986
+ # the method call message. This could return:
987
+ #
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
1008
+ end
426
1009
  end
427
1010
 
428
- def maybe_log(str)
429
- return unless @logging
430
- time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
431
- $stderr.puts("[#{time} Ractor::Wrapper/#{@name} Server]: #{str}")
432
- $stderr.flush
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)
1023
+ end
1024
+ end
1025
+
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")
433
1033
  end
434
1034
 
435
- def maybe_worker_log(worker_num, str)
436
- return unless @logging
437
- time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
438
- $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}")
439
1048
  $stderr.flush
440
1049
  end
441
1050
  end