ractor-server 0.1.1 → 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: '029685fbbe91ed839a800417dfe53141a7bbce3fb5058575f4607a7956872ccc'
4
- data.tar.gz: 71376351108500ba31e6161ed4229fc836bc90b49d1992c8e0d9cbe4a07a8678
3
+ metadata.gz: e363bbd188b2a662459734263104b8e18a6fc31984a4ba76d258b8bc49c7ae07
4
+ data.tar.gz: daf7ade9149e2ee7ff3e39d71def5516566e5329751caef920d2ae0cd0e25fe1
5
5
  SHA512:
6
- metadata.gz: f728a4a7862ed48732aaf688480e0f5ae64af885a02175b34bbfdec6185a6eb972680ce5d279b24d3d78ef8e9ac26b5a67102f8b1183d227c55b08a55138b138
7
- data.tar.gz: 8166754425d64a1747fb3f13b95e2a4a5d1a8c70e1dde578841f161e4aa645dbe1adfafb75a8dcfa63f4fb62b52f9e7dcf7f352597ecec3898818e65674ee1ab
6
+ metadata.gz: 6395fd4a6db49def30c07f3031f6ffc3fb68b2110c51d14dc2c4d6bf0eea5b5bf16ba424207b39d469a45bcd30e8a4387dd841d81884840e5c53078445444061
7
+ data.tar.gz: 87ce7921d6973573f6a7b281a5e97234ceab96b50dc75388527e09e1380bb274b3d5a71b5de2409499cea8732188f978f7ad82646cdcd4a5b6bebdfdce89ed4f
@@ -35,7 +35,7 @@ jobs:
35
35
  fail-fast: false
36
36
  matrix:
37
37
  os: [ ubuntu ]
38
- ruby: [ 2.7 ]
38
+ ruby: [ '3.0' ]
39
39
  steps:
40
40
  - name: checkout
41
41
  uses: actions/checkout@v2
@@ -25,3 +25,8 @@ Naming/MethodParameterName:
25
25
 
26
26
  Metrics/MethodLength:
27
27
  Max: 16
28
+
29
+ Lint/RescueException:
30
+ Enabled: false
31
+ Security/MarshalLoad:
32
+ Enabled: false
data/README.md CHANGED
@@ -15,13 +15,13 @@ class RactorHash < Hash
15
15
  include Ractor::Server
16
16
  end
17
17
 
18
- H = RactorHash.start
18
+ H = RactorHash.start # => starts Server, returns instance of a Client
19
19
 
20
- Ractor.new { H[:example] = 42 }.take
20
+ Ractor.new { H[:example] = 42 }.take # => 42
21
21
  puts Ractor.new { H[:example] }.take # => 42
22
22
  ```
23
23
 
24
- Calls with blocks are atomic yet allow reentrant calls:
24
+ Calls are atomic but also allow reentrant calls from blocks:
25
25
 
26
26
  ```ruby
27
27
  ractors = 3.times.map do |i|
@@ -38,6 +38,34 @@ puts H # => {:example => 42, :foo => 0, :bar => 0}
38
38
 
39
39
  The first ractor to call `fetch_values` will have its block called twice; only the `fetch_values` has completed will the other Ractors have their calls to `fetch_values` run. The block is reentrant as it calls `[]=`; that call will not wait.
40
40
 
41
+ Moreover, exceptions are propagated transparently between Client and Server.
42
+
43
+ ```ruby
44
+ H.fetch_values(:z) { raise ArgumentError } # => ArgumentError
45
+ # (raised by Client, propagated to the Server, then back to the Client)
46
+
47
+ H.fetch_values(:z) # => KeyError
48
+ # (raised by Hash#fetch_values) on the Server, propagated to the Client)
49
+ ```
50
+
51
+ Block can also `return`, `break` or `throw` on the client-side and the server will be aware of that
52
+ so that any `ensure` blocks are called and server doesn't hang expecting a return value.
53
+
54
+ ```ruby
55
+ class RactorHash
56
+ def fetch_values(*)
57
+ super
58
+ ensure
59
+ puts 'here'
60
+ end
61
+ end
62
+
63
+ def foo
64
+ H.fetch_values(:z) { return 42 }
65
+ end
66
+ foo # => 42, prints 'here'
67
+ ```
68
+
41
69
  The implementation relies on three layers of functionality.
42
70
 
43
71
  ### Low-level API: `Request`
@@ -103,6 +131,25 @@ The method `receive_request` will only receive a `Request` that was sent with `s
103
131
 
104
132
  The method `Request#receive` will only receive a `Request` that is a direct response to the receiver.
105
133
 
134
+ #### Exceptions
135
+
136
+ Instead of responsing with data, it is possible to respond by raising (on the remote side) an error with `send_exception`.
137
+
138
+ Calling `send_exception` wraps the original exception in a `Ractor::Remote`:
139
+
140
+ ```ruby
141
+ ractor = Ractor.new do
142
+ request, data = receive_request
143
+ rescue ArgumentError => e
144
+ puts e # => 'example' (ArgumentError)
145
+ end
146
+
147
+ ractor.send_exception(ArgumentError.new('example'))
148
+ ractor.take
149
+ ```
150
+
151
+ Calling `send_exception` again on the wrapped `Ractor::Remote` will unwrap it. This way, if an exception travels from the client to the server and back to the client, this voyage will be transparent to the client.
152
+
106
153
  #### Implementation
107
154
 
108
155
  `send_request` / `receive_request` use `Ractor#send` and `Ractor#receive_if` with the following layout:
@@ -120,7 +167,8 @@ One may specify the expected syncing for a `Request`:
120
167
  * `:tell`: receiver may not reply ("do this, I'm assuming it will get done")
121
168
  * `:ask`: receiver must reply exactly once with sync type `:conclude` ("do this, let me know when done, and don't me ask questions")
122
169
  * `:conclude`: as with `:tell`, receiver may not reply. Must be in response of `ask` or `converse`
123
- * `:converse`: receiver may reply has many times as desired (with sync type `:tell`, `:ask`, or `:converse`) and must then reply exactly once with sync type `:conclude`. ("do this, ask questions if need be, and let me know when done")
170
+ * `:converse`: receiver may reply has many times as desired (with sync type `:tell`, `:ask`, or `:converse`) and must then reply exactly once with sync type `:conclude`. ("do this, ask questions if need be, and let me know when done"). Exception: if the receiver replies with a `converse`, both `converse` requests may be interrupted with a response of sync `interrupt`; the receiver may no longer reply, not even for the final `:conclude`.
171
+ * `:interrupt`: acts as a kind of "double conclude". Not only should receiver not reply, but outer `conclude`.
124
172
 
125
173
  The API uses `send_request`/`send` with a `sync:` named argument:
126
174
 
@@ -252,7 +300,7 @@ end
252
300
 
253
301
  It may be necessary to customize the `Client` interface.
254
302
 
255
- For example in the `SharedObject` example above, it may be more efficient if the shared object is always shareable. This can be done by customizing the client:
303
+ For example in the `SharedObject` example above, it may be more efficient if the held object is always shareable. This can be done by customizing the client:
256
304
 
257
305
  ```ruby
258
306
  class SharedObject
@@ -295,7 +343,6 @@ end
295
343
 
296
344
  ## To do
297
345
 
298
- * Exception rescuing and propagation
299
346
  * API to pass block via makeshareable
300
347
  * Monitoring
301
348
  * Promise-style communication
@@ -165,18 +165,32 @@ class Ractor
165
165
  loop do
166
166
  response, result = rq.receive
167
167
  case response.sync
168
- in :converse
169
- block_result = with_requests_nested(response) { yield(result) }
170
- Ractor.make_shareable(block_result) if share_inputs?(method)
171
- response.conclude block_result
172
- in :conclude
173
- return result
168
+ in :converse then handle_yield(method, response) { yield result }
169
+ in :conclude then return result
174
170
  end
175
171
  end
176
172
  ensure
177
173
  debug(:await) { "Finished waiting for #{rq}" }
178
174
  end
179
175
 
176
+ private def handle_yield(method, response)
177
+ begin
178
+ status, result = with_requests_nested(response) do
179
+ [:ok, yield]
180
+ rescue Exception => e
181
+ [:exception, e]
182
+ end
183
+ ensure
184
+ response.interrupt unless status # throw/return/...
185
+ end
186
+ if status == :exception
187
+ response.send_exception(result)
188
+ else
189
+ Ractor.make_shareable(result) if share_inputs?(method)
190
+ response.conclude result
191
+ end
192
+ end
193
+
180
194
  private def with_requests_nested(context)
181
195
  store = Thread.current
182
196
  prev = store[@nest_request_key]
@@ -5,6 +5,8 @@ using Ractor::Server::Talk
5
5
 
6
6
  class Ractor
7
7
  module Server
8
+ WRAP_IN_REMOTE_ERROR = false # unsure what's best
9
+
8
10
  class Request
9
11
  include Debugging
10
12
  attr_reader :response_to, :initiating_ractor, :sync, :info
@@ -38,7 +40,7 @@ class Ractor
38
40
  Request.send(initiating_ractor, *args, **options, response_to: self)
39
41
  end
40
42
 
41
- %i[tell ask converse conclude].each do |sync|
43
+ %i[tell ask converse conclude interrupt].each do |sync|
42
44
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
43
45
  def #{sync}(*args, **options) # def tell(*args, **options)
44
46
  send(*args, **options, sync: :#{sync}) # send(*args, **options, sync: :tell)
@@ -50,9 +52,25 @@ class Ractor
50
52
  RUBY
51
53
  end
52
54
 
55
+ class WrappedException
56
+ # Use Marshal to circumvent https://bugs.ruby-lang.org/issues/17577
57
+ def initialize(exception)
58
+ @exception = Marshal.dump(exception)
59
+ end
60
+
61
+ def exception
62
+ Marshal.load(@exception)
63
+ end
64
+ end
65
+ private_constant :WrappedException
66
+
67
+ def send_exception(exception)
68
+ send(WrappedException.new(exception), sync: sync && :conclude)
69
+ end
70
+
53
71
  def receive
54
72
  enforce_sync_when_receiving!
55
- Request.receive_if(&self)
73
+ unwrap(Request.receive_if(&self))
56
74
  end
57
75
 
58
76
  def inspect
@@ -103,7 +121,7 @@ class Ractor
103
121
  request
104
122
  end
105
123
 
106
- %i[tell ask converse conclude].each do |sync|
124
+ %i[tell ask converse conclude interrupt].each do |sync|
107
125
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
108
126
  def #{sync}(r, *args, **options) # def tell(r, *args, **options)
109
127
  send(r, *args, **options, sync: :#{sync}) # send(r, *args, **options, sync: :tell)
@@ -112,15 +130,39 @@ class Ractor
112
130
  end
113
131
  end
114
132
 
133
+ private def unwrap(message)
134
+ _rq, arg = message
135
+ raise_exception(arg) if arg.is_a?(WrappedException)
136
+
137
+ message
138
+ end
139
+
140
+ private def raise_exception(exc)
141
+ raise exc unless WRAP_IN_REMOTE_ERROR
142
+
143
+ if exc.exception.is_a?(Ractor::RemoteError)
144
+ debug(:exception) { 'Received RemoteError, raising original cause' }
145
+ raise exc.exception.cause
146
+ else
147
+ debug(:exception) { 'Received exception, raising RemoveError' }
148
+ begin
149
+ raise exc.exception
150
+ rescue Exception
151
+ raise Ractor::RemoteError # => sets `cause` to exc.exception
152
+ end
153
+ end
154
+ end
155
+
115
156
  # @api private
116
157
  def enforce_sync_when_sending!
117
158
  # Only dynamic checks are done here; static validity checked in constructor
118
159
  case sync
119
- when :conclude
160
+ when :conclude, :interrupt
120
161
  registry = Request.pending_send_conclusion
121
162
  raise Talk::Error, "Request #{response_to} already answered" unless registry[response_to]
122
163
 
123
164
  registry[response_to] = false
165
+ Request.pending_receive_conclusion[response_to.response_to] = false if sync == :interrupt
124
166
  when :ask, :converse
125
167
  Request.pending_receive_conclusion[self] = true
126
168
  end
@@ -130,8 +172,9 @@ class Ractor
130
172
  def sync_after_receiving
131
173
  # Only dynamic checks are done here; static validity checked in constructor
132
174
  case sync
133
- when :conclude
175
+ when :conclude, :interrupt
134
176
  Request.pending_receive_conclusion[response_to] = false
177
+ Request.pending_send_conclusion[response_to.response_to] = false if sync == :interrupt
135
178
  when :ask, :converse
136
179
  Request.pending_send_conclusion[self] = true
137
180
  end
@@ -140,7 +183,7 @@ class Ractor
140
183
  # Receiver is request to receive a reply from
141
184
  private def enforce_sync_when_receiving!
142
185
  case sync
143
- when :tell, :conclude
186
+ when :tell, :conclude, :interrupt
144
187
  raise Talk::Error, "Can not receive from a Request for a `#{sync}` sync: #{self}"
145
188
  when :ask, :converse
146
189
  return :ok if Request.pending_receive_conclusion[self]
@@ -153,7 +196,7 @@ class Ractor
153
196
  ractor.name || "##{ractor.to_s.match(/#(\d+) /)[1]}"
154
197
  end
155
198
 
156
- private def enforce_valid_sync!
199
+ private def enforce_valid_sync! # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
157
200
  case [response_to&.sync, sync]
158
201
  in [nil, nil]
159
202
  :ok_unsynchronized
@@ -161,10 +204,15 @@ class Ractor
161
204
  :ok_talk
162
205
  in [:ask | :converse, :conclude]
163
206
  :ok_concluding
164
- in [:tell | :conclude => from, _]
207
+ in [:ask | :converse, :interrupt]
208
+ raise Talk::Error, 'Can only interrupt a 2-level conversation' unless response_to.response_to&.converse?
209
+
210
+ :ok_interrupting
211
+ in [:tell | :conclude | :interrupt => from, _]
165
212
  raise Talk::Error, "Can not respond to a Request with `#{from.inspect}` sync"
166
213
  in [:ask, _]
167
- raise Talk::Error, "Request with `ask` sync must be responded with a `conclude` sync, got #{sync.inspect}"
214
+ raise Talk::Error, 'Request with `ask` sync must be responded with a `conclude`' \
215
+ "or `interrupt` sync, got #{sync.inspect}"
168
216
  in [_, nil]
169
217
  raise Talk::Error, "Specify sync to respond to a Request with #{sync.inspect}"
170
218
  else
@@ -17,26 +17,35 @@ class Ractor
17
17
  :done
18
18
  end
19
19
 
20
+ INTERRUPT = Object.new.freeze
21
+ private_constant :INTERRUPT
22
+
20
23
  private def process(rq, method_name, args, options, block = nil)
21
- if rq.converse?
22
- public_send(method_name, *args, **options) do |yield_arg|
23
- yield_client(rq, yield_arg)
24
- end
24
+ catch(INTERRUPT) do
25
+ if rq.converse?
26
+ public_send(method_name, *args, **options) do |yield_arg|
27
+ yield_client(rq, yield_arg)
28
+ end
29
+ else
30
+ public_send(method_name, *args, **options, &block)
31
+ end => result
32
+ rescue Exception => e
33
+ rq.send_exception(e) unless rq.tell?
25
34
  else
26
- public_send(method_name, *args, **options, &block)
27
- end => result
28
-
29
- rq.conclude(result) unless rq.tell?
35
+ rq.conclude(result) unless rq.tell?
36
+ end
30
37
  end
31
38
 
32
39
  private def yield_client(rq, arg)
33
40
  yield_request = rq.converse(arg)
34
41
  loop do
35
42
  rq, *data = yield_request.receive
36
- return data.first if rq.conclude?
37
-
38
- # Reentrant request
39
- process(rq, *data)
43
+ case rq.sync
44
+ when :conclude then return data.first
45
+ when :interrupt then throw INTERRUPT
46
+ else # Reentrant request
47
+ process(rq, *data)
48
+ end
40
49
  end
41
50
  end
42
51
 
@@ -1,27 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  # shareable_constant_value: literal
3
3
 
4
- module RefinementExporter
5
- refine Module do
6
- # See https://bugs.ruby-lang.org/issues/17374#note-8
7
- def refine(what, export: false)
8
- mod = super(what)
9
- return mod unless export
10
-
11
- export = self if export == true
12
- export.class_eval do
13
- mod.instance_methods(false).each do |method|
14
- define_method(method, mod.instance_method(method))
15
- end
16
- mod.private_instance_methods(false).each do |method|
17
- private define_method(method, mod.instance_method(method))
18
- end
19
- end
20
- mod
21
- end
22
- end
23
- end
24
- using RefinementExporter
4
+ require 'refine_export'
5
+ using RefineExport
25
6
 
26
7
  class Ractor
27
8
  module Server
@@ -3,6 +3,6 @@
3
3
 
4
4
  class Ractor
5
5
  module Server
6
- VERSION = '0.1.1'
6
+ VERSION = '0.2.0'
7
7
  end
8
8
  end
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
28
28
  spec.require_paths = ['lib']
29
29
 
30
30
  # Uncomment to register a new dependency of your gem
31
+ spec.add_dependency 'refine_export'
31
32
  spec.add_dependency 'require_relative_dir', '>= 1.1.0'
32
33
 
33
34
  # For more information and examples about making a new gem, checkout our
metadata CHANGED
@@ -1,15 +1,29 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ractor-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marc-Andre Lafortune
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-01-22 00:00:00.000000000 Z
11
+ date: 2021-01-25 00:00:00.000000000 Z
12
12
  dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: refine_export
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
13
27
  - !ruby/object:Gem::Dependency
14
28
  name: require_relative_dir
15
29
  requirement: !ruby/object:Gem::Requirement