ractor-wrapper 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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: