ractor-wrapper 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba8fea27b45cfb0ee50c95d09c19c43e3aa185db4871e1e5f892551de35c33eb
4
- data.tar.gz: ae2558e2e07b3bc74c2409c10f91a997b391709c8d1ee10157327684f4528bc5
3
+ metadata.gz: 9fec3af2b1b8b9c105260fe2fca50d69e48de205dd2dd791592317ee41286af3
4
+ data.tar.gz: e7b4487502427ec05f3dc530925e9efecd8d599e75f4dc2b82c7371592986f59
5
5
  SHA512:
6
- metadata.gz: 0e76769526ea1db2f95819275b87252a29c188aaad1a1caf5d610d84b9e1cbe18344f4266c96e2274a2ff972c6faf910393be811dfa3496c0a715aeda415f7d4
7
- data.tar.gz: ac39e5fc611ad01bedfaf2f4c065108d9d487dfd040a21ed26845d1d92968cd137aed98c96cb5a7e45ec3485e6da03d5338685521799edf221f0f836557495e5
6
+ metadata.gz: 0ab154c16e2ed53f65a042bf18b85448cb0115e6b1616db5cce96084767ae1f00c23392ba48560177f34c43d757b59e282c05891702af927f40f72fe989b33e1
7
+ data.tar.gz: 8e16f5694e46c571deac8d66f56bb23467572bad838038d941955f2edaca76537ef741df79b63da0bc28c31708a31ac3ba699e3a2a661b56552dc6b1050625a0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Release History
2
2
 
3
+ ### v0.2.0 / 2021-03-08
4
+
5
+ * BREAKING CHANGE: The wrapper now copies (instead of moves) arguments and return values by default.
6
+ * It is now possible to control, per method, whether arguments and return values are copied or moved.
7
+ * Fixed: The respond_to? method did not work correctly for stubs.
8
+ * Improved: The wrapper server lifecycle is a bit more robust against worker crashes.
9
+
3
10
  ### v0.1.0 / 2021-03-02
4
11
 
5
12
  * Initial release. HIGHLY EXPERIMENTAL.
data/README.md CHANGED
@@ -16,18 +16,19 @@ Require it in your code:
16
16
 
17
17
  You can then create wrappers for objects. See the example below.
18
18
 
19
- Ractor::Wrapper requires Ruby 3.0.0 or later.
19
+ `Ractor::Wrapper` requires Ruby 3.0.0 or later.
20
20
 
21
- WARNING: This is a highly experimental library, and not currently intended for
22
- production use. (As of Ruby 3.0.0, the same can be said of Ractors in general.)
21
+ WARNING: This is a highly experimental library, and currently _not_ recommended
22
+ for production use. (As of Ruby 3.0.0, the same can be said of Ractors in
23
+ general.)
23
24
 
24
25
  ## About Ractor::Wrapper
25
26
 
26
27
  Ractors for the most part cannot access objects concurrently with other
27
28
  Ractors unless the object is _shareable_ (that is, deeply immutable along
28
- with a few other restrictions.) If multiple Ractors need to access a shared
29
- resource that is stateful or otherwise not Ractor-shareable, that resource
30
- must itself be a Ractor.
29
+ with a few other restrictions.) If multiple Ractors need to interact with a
30
+ shared resource that is stateful or otherwise not Ractor-shareable, that
31
+ resource must itself be implemented and accessed as a Ractor.
31
32
 
32
33
  `Ractor::Wrapper` makes it possible for such a shared resource to be
33
34
  implemented as an ordinary object and accessed using ordinary method calls. It
@@ -50,49 +51,53 @@ The following example shows how to share a single `Faraday::Conection`
50
51
  object among multiple Ractors. Because `Faraday::Connection` is not itself
51
52
  thread-safe, this example serializes all calls to it.
52
53
 
53
- require "faraday"
54
- require "ractor/wrapper"
55
-
56
- # Create a Faraday connection and a wrapper for it.
57
- connection = Faraday.new "http://example.com"
58
- wrapper = Ractor::Wrapper.new(connection)
59
-
60
- # At this point, the connection ojbect cannot be accessed directly
61
- # because it has been "moved" to the wrapper's internal Ractor.
62
- # connection.get("/whoops") # <= raises an error
63
-
64
- # However, any number of Ractors can now access it through the wrapper.
65
- # By default, access to the object is serialized; methods will not be
66
- # invoked concurrently. (To allow concurrent access, set up threads when
67
- # creating the wrapper.)
68
- r1 = Ractor.new(wrapper) do |w|
69
- 10.times do
70
- w.stub.get("/hello")
71
- end
72
- :ok
73
- end
74
- r2 = Ractor.new(wrapper) do |w|
75
- 10.times do
76
- w.stub.get("/ruby")
77
- end
78
- :ok
79
- end
80
-
81
- # Wait for the two above Ractors to finish.
82
- r1.take
83
- r2.take
84
-
85
- # After you stop the wrapper, you can retrieve the underlying
86
- # connection object and access it directly again.
87
- wrapper.async_stop
88
- connection = wrapper.recover_object
89
- connection.get("/finally")
54
+ ```ruby
55
+ require "faraday"
56
+ require "ractor/wrapper"
57
+
58
+ # Create a Faraday connection and a wrapper for it.
59
+ connection = Faraday.new "http://example.com"
60
+ wrapper = Ractor::Wrapper.new(connection)
61
+
62
+ # At this point, the connection object cannot be accessed directly
63
+ # because it has been "moved" to the wrapper's internal Ractor.
64
+ # connection.get("/whoops") # <= raises an error
65
+
66
+ # However, any number of Ractors can now access it through the wrapper.
67
+ # By default, access to the object is serialized; methods will not be
68
+ # invoked concurrently. (To allow concurrent access, set up threads when
69
+ # creating the wrapper.)
70
+ r1 = Ractor.new(wrapper) do |w|
71
+ 10.times do
72
+ w.stub.get("/hello")
73
+ end
74
+ :ok
75
+ end
76
+ r2 = Ractor.new(wrapper) do |w|
77
+ 10.times do
78
+ w.stub.get("/ruby")
79
+ end
80
+ :ok
81
+ end
82
+
83
+ # Wait for the two above Ractors to finish.
84
+ r1.take
85
+ r2.take
86
+
87
+ # After you stop the wrapper, you can retrieve the underlying
88
+ # connection object and access it directly again.
89
+ wrapper.async_stop
90
+ connection = wrapper.recover_object
91
+ connection.get("/finally")
92
+ ```
90
93
 
91
94
  ### Features
92
95
 
93
96
  * Provides a method interface to an object running in a different Ractor.
94
97
  * Supports arbitrary method arguments and return values.
95
98
  * Supports exceptions thrown by the method.
99
+ * Can be configured to copy or move arguments, return values, and
100
+ exceptions, per method.
96
101
  * Can serialize method calls for non-concurrency-safe objects, or run
97
102
  methods concurrently in multiple worker threads for thread-safe objects.
98
103
  * Can gracefully shut down the wrapper and retrieve the original object.
@@ -127,7 +132,7 @@ Development is done in GitHub at https://github.com/dazuma/ractor-wrapper.
127
132
 
128
133
  The library uses [toys](https://dazuma.github.io/toys) for testing and CI. To
129
134
  run the test suite, `gem install toys` and then run `toys ci`. You can also run
130
- unit tests, rubocop, and builds independently.
135
+ unit tests, rubocop, and builds independently.
131
136
 
132
137
  ## License
133
138
 
@@ -6,13 +6,17 @@ class Ractor
6
6
  # An experimental class that wraps a non-shareable object, allowing multiple
7
7
  # Ractors to access it concurrently.
8
8
  #
9
+ # 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
11
+ # Ractors in general.)
12
+ #
9
13
  # ## What is Ractor::Wrapper?
10
14
  #
11
15
  # Ractors for the most part cannot access objects concurrently with other
12
16
  # 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
+ # 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.
16
20
  #
17
21
  # `Ractor::Wrapper` makes it possible for such a shared resource to be
18
22
  # implemented as an object and accessed using ordinary method calls. It does
@@ -41,7 +45,7 @@ class Ractor
41
45
  # connection = Faraday.new "http://example.com"
42
46
  # wrapper = Ractor::Wrapper.new(connection)
43
47
  #
44
- # # At this point, the connection ojbect cannot be accessed directly
48
+ # # At this point, the connection object cannot be accessed directly
45
49
  # # because it has been "moved" to the wrapper's internal Ractor.
46
50
  # # connection.get("/whoops") # <= raises an error
47
51
  #
@@ -76,6 +80,8 @@ class Ractor
76
80
  # * Provides a method interface to an object running in a different Ractor.
77
81
  # * Supports arbitrary method arguments and return values.
78
82
  # * Supports exceptions thrown by the method.
83
+ # * Can be configured to copy or move arguments, return values, and
84
+ # exceptions, per method.
79
85
  # * Can serialize method calls for non-concurrency-safe objects, or run
80
86
  # methods concurrently in multiple worker threads for thread-safe objects.
81
87
  # * Can gracefully shut down the wrapper and retrieve the original object.
@@ -106,19 +112,34 @@ class Ractor
106
112
  # configuration is frozen once the object is constructed.)
107
113
  #
108
114
  # @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.
115
+ # @param threads [Integer] The number of worker threads to run.
116
+ # Defaults to 1, which causes the worker to serialize calls.
111
117
  #
112
- def initialize(object, threads: nil, logging: false, name: nil)
118
+ def initialize(object,
119
+ threads: 1,
120
+ move: false,
121
+ move_arguments: nil,
122
+ move_return: nil,
123
+ logging: false,
124
+ name: nil)
125
+ @method_settings = {}
113
126
  self.threads = threads
114
127
  self.logging = logging
115
128
  self.name = name
129
+ configure_method(move: move, move_arguments: move_arguments, move_return: move_return)
116
130
  yield self if block_given?
131
+ @method_settings.freeze
117
132
 
118
133
  maybe_log("Starting server")
119
134
  @ractor = ::Ractor.new(name: name) { Server.new.run }
120
- opts = {name: @name, threads: @threads, logging: @logging}
121
- @ractor.send([object, opts], move: true)
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)
122
143
 
123
144
  maybe_log("Server ready")
124
145
  @stub = Stub.new(self)
@@ -128,28 +149,25 @@ class Ractor
128
149
  ##
129
150
  # Set the number of threads to run in the wrapper. If the underlying object
130
151
  # 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.
152
+ # object is not thread-safe, you should leave this set to its default of 1,
153
+ # which effectively causes calls to be serialized.
134
154
  #
135
155
  # This method can be called only during an initialization block.
156
+ # All settings are frozen once the wrapper is active.
136
157
  #
137
- # @param value [Integer,nil]
158
+ # @param value [Integer]
138
159
  #
139
160
  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
161
+ value = value.to_i
162
+ value = 1 if value < 1
163
+ @threads = value
147
164
  end
148
165
 
149
166
  ##
150
167
  # Enable or disable internal debug logging.
151
168
  #
152
169
  # This method can be called only during an initialization block.
170
+ # All settings are frozen once the wrapper is active.
153
171
  #
154
172
  # @param value [Boolean]
155
173
  #
@@ -158,9 +176,11 @@ class Ractor
158
176
  end
159
177
 
160
178
  ##
161
- # Set the name of this wrapper, shown in logging.
179
+ # Set the name of this wrapper. This is shown in logging, and is also used
180
+ # as the name of the wrapping Ractor.
162
181
  #
163
182
  # This method can be called only during an initialization block.
183
+ # All settings are frozen once the wrapper is active.
164
184
  #
165
185
  # @param value [String, nil]
166
186
  #
@@ -168,6 +188,32 @@ class Ractor
168
188
  @name = value ? value.to_s.freeze : nil
169
189
  end
170
190
 
191
+ ##
192
+ # Configure the move semantics for the given method (or the default
193
+ # settings if no method name is given.) That is, determine whether
194
+ # arguments, return values, and/or exceptions are copied or moved when
195
+ # communicated with the wrapper. By default, all objects are copied.
196
+ #
197
+ # This method can be called only during an initialization block.
198
+ # All settings are frozen once the wrapper is active.
199
+ #
200
+ # @param method_name [Symbol, nil] The name of the method being configured,
201
+ # or `nil` to set defaults for all methods not configured explicitly.
202
+ # @param move [Boolean] Whether to move all communication. This value, if
203
+ # given, is used if `move_arguments`, `move_return`, or
204
+ # `move_exceptions` are not set.
205
+ # @param move_arguments [Boolean] Whether to move arguments.
206
+ # @param move_return [Boolean] Whether to move return values.
207
+ #
208
+ def configure_method(method_name = nil,
209
+ move: false,
210
+ move_arguments: nil,
211
+ move_return: nil)
212
+ method_name = method_name.to_sym unless method_name.nil?
213
+ @method_settings[method_name] =
214
+ MethodSettings.new(move: move, move_arguments: move_arguments, move_return: move_return)
215
+ end
216
+
171
217
  ##
172
218
  # Return the wrapper stub. This is an object that responds to the same
173
219
  # methods as the wrapped object, providing an easy way to call a wrapper.
@@ -177,15 +223,14 @@ class Ractor
177
223
  attr_reader :stub
178
224
 
179
225
  ##
180
- # Return the number of threads used by the wrapper, or `nil` for no
181
- # no threading.
226
+ # Return the number of threads used by the wrapper.
182
227
  #
183
- # @return [Integer, nil]
228
+ # @return [Integer]
184
229
  #
185
230
  attr_reader :threads
186
231
 
187
232
  ##
188
- # Return whether logging is enabled for this wrapper
233
+ # Return whether logging is enabled for this wrapper.
189
234
  #
190
235
  # @return [Boolean]
191
236
  #
@@ -199,7 +244,21 @@ class Ractor
199
244
  attr_reader :name
200
245
 
201
246
  ##
202
- # A lower-level interface for calling the wrapper.
247
+ # Return the method settings for the given method name. This returns the
248
+ # default method settings if the given method is not configured explicitly
249
+ # by name.
250
+ #
251
+ # @param method_name [Symbol,nil] The method name, or `nil` to return the
252
+ # defaults.
253
+ # @return [MethodSettings]
254
+ #
255
+ def method_settings(method_name)
256
+ method_name = method_name.to_sym
257
+ @method_settings[method_name] || @method_settings[nil]
258
+ end
259
+
260
+ ##
261
+ # A lower-level interface for calling methods through the wrapper.
203
262
  #
204
263
  # @param method_name [Symbol] The name of the method to call
205
264
  # @param args [arguments] The positional arguments
@@ -209,8 +268,9 @@ class Ractor
209
268
  def call(method_name, *args, **kwargs)
210
269
  request = Message.new(:call, data: [method_name, args, kwargs])
211
270
  transaction = request.transaction
212
- maybe_log("Sending method #{method_name} (transaction=#{transaction})")
213
- @ractor.send(request, move: true)
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)
214
274
  reply = ::Ractor.receive_if { |msg| msg.is_a?(Message) && msg.transaction == transaction }
215
275
  case reply.type
216
276
  when :result
@@ -241,9 +301,11 @@ class Ractor
241
301
  end
242
302
 
243
303
  ##
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.
304
+ # Retrieves the original object that was wrapped. This should be called
305
+ # only after a stop request has been issued using {#async_stop}, and may
306
+ # block until the wrapper has fully stopped.
307
+ #
308
+ # Only one ractor may call this method; any additional calls will fail.
247
309
  #
248
310
  # @return [Object] The original wrapped object
249
311
  #
@@ -276,19 +338,75 @@ class Ractor
276
338
 
277
339
  ##
278
340
  # Forward calls to {Ractor::Wrapper#call}.
341
+ # @private
279
342
  #
280
343
  def method_missing(name, *args, **kwargs)
281
344
  @wrapper.call(name, *args, **kwargs)
282
345
  end
283
346
 
347
+ ##
348
+ # Forward respond_to queries.
284
349
  # @private
350
+ #
285
351
  def respond_to_missing?(name, include_all)
286
- @wrapper.respond_to?(name, include_all)
352
+ @wrapper.call(:respond_to?, name, include_all)
287
353
  end
288
354
  end
289
355
 
290
- # @private
356
+ ##
357
+ # Settings for a method call. Specifies how a method's arguments and
358
+ # return value are communicated (i.e. copy or move semantics.)
359
+ #
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
369
+
370
+ ##
371
+ # @return [Boolean] Whether to move arguments
372
+ #
373
+ def move_arguments?
374
+ @move_arguments
375
+ end
376
+
377
+ ##
378
+ # @return [Boolean] Whether to move return values
379
+ #
380
+ def move_return?
381
+ @move_return
382
+ end
383
+
384
+ private
385
+
386
+ def interpret_setting(setting, default)
387
+ if setting.nil?
388
+ default ? true : false
389
+ else
390
+ setting ? true : false
391
+ end
392
+ end
393
+ end
394
+
395
+ ##
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.)
407
+ #
291
408
  class Message
409
+ # @private
292
410
  def initialize(type, data: nil, transaction: nil)
293
411
  @sender = ::Ractor.current
294
412
  @type = type
@@ -297,9 +415,16 @@ class Ractor
297
415
  freeze
298
416
  end
299
417
 
418
+ # @private
300
419
  attr_reader :type
420
+
421
+ # @private
301
422
  attr_reader :sender
423
+
424
+ # @private
302
425
  attr_reader :transaction
426
+
427
+ # @private
303
428
  attr_reader :data
304
429
 
305
430
  private
@@ -309,19 +434,34 @@ class Ractor
309
434
  end
310
435
  end
311
436
 
437
+ ##
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.
442
+ #
443
+ # See the {#run} method for an overview of the Server implementation and
444
+ # lifecycle.
445
+ #
312
446
  # @private
447
+ #
313
448
  class Server
449
+ ##
450
+ # Handle the server lifecycle, running through the following phases:
451
+ #
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.
456
+ #
457
+ # The server returns the wrapped object, allowing one client Ractor to
458
+ # take it.
459
+ #
314
460
  def run
315
- @object, opts = ::Ractor.receive
316
- @logging = opts[:logging]
317
- @name = opts[:name]
318
- maybe_log("Server started")
319
-
320
- queue = start_threads(opts[:threads])
321
- running_phase(queue)
322
- stopping_phase if queue
461
+ init_phase
462
+ running_phase
463
+ stopping_phase
323
464
  cleanup_phase
324
-
325
465
  @object
326
466
  rescue ::StandardError => e
327
467
  maybe_log("Unexpected error: #{e.inspect}")
@@ -330,80 +470,142 @@ class Ractor
330
470
 
331
471
  private
332
472
 
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) }
473
+ ##
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.
481
+ #
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) }
339
495
  end
340
- ::Thread.new { monitor_thread(threads) }
341
- queue
496
+ maybe_log("Server initialized")
342
497
  end
343
498
 
344
- def worker_thread(worker_num, queue)
499
+ ##
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.
506
+ #
507
+ def worker_thread(worker_num)
345
508
  maybe_worker_log(worker_num, "Starting")
346
509
  loop do
347
510
  maybe_worker_log(worker_num, "Waiting for job")
348
- request = queue.deq
349
- if request.nil?
350
- break
351
- end
511
+ request = @queue.deq
512
+ break if request.nil?
352
513
  handle_method(worker_num, request)
514
+ unregister_call(request.transaction)
353
515
  end
516
+ ensure
354
517
  maybe_worker_log(worker_num, "Stopping")
518
+ ::Ractor.current.send(Message.new(:thread_stopped, data: worker_num), move: true)
355
519
  end
356
520
 
357
- def monitor_thread(workers)
358
- workers.each(&:join)
359
- maybe_log("All workers finished")
360
- ::Ractor.current.send(Message.new(:threads_stopped))
361
- end
362
-
363
- def running_phase(queue)
521
+ ##
522
+ # In the **running phase**, the Server listens on the Ractor's inbox and
523
+ # handles messages for normal operation:
524
+ #
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.
534
+ #
535
+ def running_phase
364
536
  loop do
365
537
  maybe_log("Waiting for message")
366
- request = ::Ractor.receive_if { |msg| msg.is_a?(Message) }
538
+ request = ::Ractor.receive
539
+ next unless request.is_a?(Message)
367
540
  case request.type
368
541
  when :call
369
- if queue
370
- queue.enq(request)
371
- maybe_log("Queued method #{request.data.first} (transaction=#{request.transaction})")
372
- else
373
- handle_method(0, request)
374
- end
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
548
+ break
375
549
  when :stop
376
550
  maybe_log("Received stop")
377
- queue&.close
378
551
  break
379
552
  end
380
553
  end
381
554
  end
382
555
 
556
+ ##
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.
563
+ #
383
564
  def stopping_phase
384
- loop do
385
- maybe_log("Waiting for message")
386
- message = ::Ractor.receive_if { |msg| msg.is_a?(Message) }
565
+ @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)
387
570
  case message.type
388
571
  when :call
389
572
  refuse_method(message)
390
- when :threads_stopped
391
- break
573
+ when :thread_stopped
574
+ @thread_count -= 1
392
575
  end
393
576
  end
394
577
  end
395
578
 
579
+ ##
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.
585
+ #
396
586
  def cleanup_phase
397
587
  ::Ractor.current.close_incoming
588
+ maybe_log("Checking message queue for cleanup")
398
589
  loop do
399
- maybe_log("Checking queue for cleanup")
400
590
  message = ::Ractor.receive
401
591
  refuse_method(message) if message.is_a?(Message) && message.type == :call
402
592
  end
593
+ maybe_log("Checking current calls for cleanup")
594
+ @current_calls.each_value do |request|
595
+ refuse_method(request)
596
+ end
403
597
  rescue ::Ractor::ClosedError
404
- maybe_log("Queue is empty")
598
+ maybe_log("Message queue is empty")
405
599
  end
406
600
 
601
+ ##
602
+ # This is called within a worker thread to handle a method call request.
603
+ # It calls the method on the wrapped object, and then sends back a
604
+ # response to the caller. If an exception was raised, it sends back an
605
+ # error response. It tries very hard always to send a response of some
606
+ # kind; if an error occurs while constructing or sending a response, it
607
+ # will catch the exception and try to send a simpler response.
608
+ #
407
609
  def handle_method(worker_num, request)
408
610
  method_name, args, kwargs = request.data
409
611
  transaction = request.transaction
@@ -412,19 +614,46 @@ class Ractor
412
614
  begin
413
615
  result = @object.send(method_name, *args, **kwargs)
414
616
  maybe_worker_log(worker_num, "Sending result (transaction=#{transaction})")
415
- sender.send(Message.new(:result, data: result, transaction: transaction), move: true)
617
+ sender.send(Message.new(:result, data: result, transaction: transaction),
618
+ move: (@method_settings[method_name] || @method_settings[nil]).move_return?)
416
619
  rescue ::Exception => e # rubocop:disable Lint/RescueException
417
620
  maybe_worker_log(worker_num, "Sending exception (transaction=#{transaction})")
418
- sender.send(Message.new(:error, data: e, transaction: transaction))
621
+ begin
622
+ sender.send(Message.new(:error, data: e, transaction: transaction))
623
+ rescue ::StandardError
624
+ safe_error = begin
625
+ ::StandardError.new(e.inspect)
626
+ rescue ::StandardError
627
+ ::StandardError.new("Unknown error")
628
+ end
629
+ sender.send(Message.new(:error, data: safe_error, transaction: transaction))
630
+ end
419
631
  end
420
632
  end
421
633
 
634
+ ##
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.
638
+ #
422
639
  def refuse_method(request)
423
640
  maybe_log("Refusing method call (transaction=#{message.transaction})")
424
641
  error = ::Ractor::ClosedError.new
425
642
  request.sender.send(Message.new(:error, data: error, transaction: message.transaction))
426
643
  end
427
644
 
645
+ def register_call(request)
646
+ @mutex.synchronize do
647
+ @current_calls[request.transaction] = request
648
+ end
649
+ end
650
+
651
+ def unregister_call(transaction)
652
+ @mutex.synchronize do
653
+ @current_calls.delete(transaction)
654
+ end
655
+ end
656
+
428
657
  def maybe_log(str)
429
658
  return unless @logging
430
659
  time = ::Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%L")
@@ -5,6 +5,6 @@ class Ractor
5
5
  #
6
6
  # @return [String]
7
7
  #
8
- VERSION = "0.1.0".freeze
8
+ VERSION = "0.2.0".freeze
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractor-wrapper
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniel Azuma
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-02 00:00:00.000000000 Z
11
+ date: 2021-03-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: An experimental class that wraps a non-shareable object, allowing multiple
14
14
  Ractors to access it concurrently.
@@ -28,7 +28,12 @@ files:
28
28
  homepage: https://github.com/dazuma/ractor-wrapper
29
29
  licenses:
30
30
  - MIT
31
- metadata: {}
31
+ metadata:
32
+ bug_tracker_uri: https://github.com/dazuma/ractor-wrapper/issues
33
+ changelog_uri: https://rubydoc.info/gems/ractor-wrapper/0.2.0/file/CHANGELOG.md
34
+ documentation_uri: https://rubydoc.info/gems/ractor-wrapper/0.2.0
35
+ homepage_uri: https://github.com/dazuma/ractor-wrapper
36
+ source_code_uri: https://github.com/dazuma/ractor-wrapper
32
37
  post_install_message:
33
38
  rdoc_options: []
34
39
  require_paths: