omq-ractor 0.1.1 → 0.1.3
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 +4 -4
- data/README.md +26 -11
- data/lib/omq/ractor.rb +113 -5
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4672a215899078075ce99ebd8fdff3b985803dee92e0e17577f9748f2e2a89d0
|
|
4
|
+
data.tar.gz: 7ca1e9d1cc2470901b2bd17c8a1ef8f4db19e08cb118cde74415c350c06e5f04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f9702ec46b247fadeecedec98a4786992bfc4c47dffb01af09643514ffa835ca2747ae013b3a5e7bab5062d07997c4362a267ae4f8ab3c396f5fc8fa8a8cc63e
|
|
7
|
+
data.tar.gz: 90e3ed42c28560345f9171612029ed85d0f54b67b4ea9e2890da94b28797c4c2855f8b044b7f2c84968bddc974f870e545069a0803ac0c663f50aa884c78ea88
|
data/README.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# OMQ::Ractor -- Networked Ractors
|
|
2
2
|
|
|
3
|
+
[](https://github.com/paddor/omq-ractor/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/omq-ractor)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
|
|
3
8
|
Ruby Ractors give you true parallelism -- each Ractor gets its own GVL,
|
|
4
9
|
so CPU-bound work runs on separate cores. But they can only talk to each
|
|
5
10
|
other inside a single process, using `Ractor::Port`. No networking, no
|
|
@@ -45,8 +50,7 @@ Async do
|
|
|
45
50
|
worker = OMQ::Ractor.new(pull, push) do |omq|
|
|
46
51
|
pull_p, push_p = omq.sockets # handshake (must be first call)
|
|
47
52
|
|
|
48
|
-
|
|
49
|
-
msg = pull_p.receive
|
|
53
|
+
while msg = pull_p.receive # nil on close
|
|
50
54
|
push_p << expensive_transform(msg)
|
|
51
55
|
end
|
|
52
56
|
end
|
|
@@ -61,25 +65,36 @@ lightweight wrappers around `Ractor::Port` pairs.
|
|
|
61
65
|
|
|
62
66
|
### Multiplexing with Ractor.select
|
|
63
67
|
|
|
68
|
+
`Ractor.select` waits on multiple `Ractor::Port` objects and returns
|
|
69
|
+
`[port, value]`. Use `#to_port` to get the underlying port, and
|
|
70
|
+
`#socket_for` to map back to the proxy:
|
|
71
|
+
|
|
64
72
|
```ruby
|
|
65
73
|
worker = OMQ::Ractor.new(pull_a, pull_b, push) do |omq|
|
|
66
|
-
|
|
74
|
+
sockets = omq.sockets
|
|
75
|
+
a, b, out = sockets
|
|
67
76
|
|
|
68
77
|
loop do
|
|
69
|
-
|
|
70
|
-
|
|
78
|
+
port, msg = Ractor.select(a.to_port, b.to_port)
|
|
79
|
+
break if msg.nil? # socket closed
|
|
80
|
+
source = sockets.socket_for(port) # => a or b
|
|
81
|
+
out << process(source, msg)
|
|
71
82
|
end
|
|
72
83
|
end
|
|
73
84
|
```
|
|
74
85
|
|
|
86
|
+
Note: `Ractor.select` returns raw port values, bypassing `SocketProxy#receive`.
|
|
87
|
+
For topic-based sockets, `msg` will be the full `[topic, payload]` array --
|
|
88
|
+
use `#receive` or `#receive_with_topic` on a single proxy instead if you
|
|
89
|
+
need topic stripping.
|
|
90
|
+
|
|
75
91
|
### Bidirectional (PAIR, REQ/REP, DEALER)
|
|
76
92
|
|
|
77
93
|
```ruby
|
|
78
94
|
worker = OMQ::Ractor.new(pair) do |omq|
|
|
79
95
|
p = omq.sockets.first
|
|
80
96
|
|
|
81
|
-
|
|
82
|
-
msg = p.receive
|
|
97
|
+
while msg = p.receive
|
|
83
98
|
p << transform(msg)
|
|
84
99
|
end
|
|
85
100
|
end
|
|
@@ -122,8 +137,7 @@ Async do
|
|
|
122
137
|
|
|
123
138
|
OMQ::Ractor.new(pull, push) do |omq|
|
|
124
139
|
p_in, p_out = omq.sockets
|
|
125
|
-
|
|
126
|
-
msg = p_in.receive
|
|
140
|
+
while msg = p_in.receive
|
|
127
141
|
p_out << expensive_transform(msg)
|
|
128
142
|
end
|
|
129
143
|
end
|
|
@@ -160,8 +174,9 @@ Use `serialize: false` for raw messages (frozen string arrays):
|
|
|
160
174
|
```ruby
|
|
161
175
|
worker = OMQ::Ractor.new(pull, push, serialize: false) do |omq|
|
|
162
176
|
p_in, p_out = omq.sockets
|
|
163
|
-
msg = p_in.receive # frozen string array, e.g. ["hello"]
|
|
164
|
-
|
|
177
|
+
while msg = p_in.receive # frozen string array, e.g. ["hello"]
|
|
178
|
+
p_out << [msg.first.upcase] # must send frozen string arrays
|
|
179
|
+
end
|
|
165
180
|
end
|
|
166
181
|
```
|
|
167
182
|
|
data/lib/omq/ractor.rb
CHANGED
|
@@ -33,6 +33,7 @@ module OMQ
|
|
|
33
33
|
|
|
34
34
|
HANDSHAKE_TIMEOUT = 0.1
|
|
35
35
|
|
|
36
|
+
|
|
36
37
|
# Socket types that use topic/group-based routing.
|
|
37
38
|
# These get topic-aware connection wrappers that preserve
|
|
38
39
|
# the first frame (topic/group) as a plain string for matching.
|
|
@@ -45,6 +46,8 @@ module OMQ
|
|
|
45
46
|
# the wrapped class (e.g. DirectPipe) still work.
|
|
46
47
|
#
|
|
47
48
|
module TransparentDelegator
|
|
49
|
+
# @param klass [Class] class to check against
|
|
50
|
+
# @return [Boolean] true if self or the wrapped object is_a? klass
|
|
48
51
|
def is_a?(klass)
|
|
49
52
|
super || __getobj__.is_a?(klass)
|
|
50
53
|
end
|
|
@@ -56,11 +59,18 @@ module OMQ
|
|
|
56
59
|
# The send pump is single-threaded, so identity check suffices.
|
|
57
60
|
#
|
|
58
61
|
class SerializeCache
|
|
62
|
+
# @return [SerializeCache]
|
|
59
63
|
def initialize
|
|
60
64
|
@last = nil
|
|
61
65
|
@bytes = nil
|
|
62
66
|
end
|
|
63
67
|
|
|
68
|
+
|
|
69
|
+
# Returns the Marshal-dumped bytes for +obj+, reusing the cached result
|
|
70
|
+
# if +obj+ is the same object as the last call.
|
|
71
|
+
#
|
|
72
|
+
# @param obj [Object] object to serialize
|
|
73
|
+
# @return [String] frozen Marshal bytes
|
|
64
74
|
def marshal(obj)
|
|
65
75
|
return @bytes if obj.equal?(@last)
|
|
66
76
|
@last = obj
|
|
@@ -75,19 +85,29 @@ module OMQ
|
|
|
75
85
|
class MarshalConnection < SimpleDelegator
|
|
76
86
|
include TransparentDelegator
|
|
77
87
|
|
|
88
|
+
# @param conn [Object] underlying connection to wrap
|
|
89
|
+
# @param cache [SerializeCache] shared serialization cache
|
|
78
90
|
def initialize(conn, cache)
|
|
79
91
|
super(conn)
|
|
80
92
|
@cache = cache
|
|
81
93
|
end
|
|
82
94
|
|
|
95
|
+
|
|
96
|
+
# @param parts [Array<String>] message frames to serialize and send
|
|
97
|
+
# @return [void]
|
|
83
98
|
def send_message(parts)
|
|
84
99
|
super([@cache.marshal(parts)])
|
|
85
100
|
end
|
|
86
101
|
|
|
102
|
+
|
|
103
|
+
# @param parts [Array<String>] message frames to serialize and write
|
|
104
|
+
# @return [void]
|
|
87
105
|
def write_message(parts)
|
|
88
106
|
super([@cache.marshal(parts)])
|
|
89
107
|
end
|
|
90
108
|
|
|
109
|
+
|
|
110
|
+
# @return [Object] deserialized message
|
|
91
111
|
def receive_message
|
|
92
112
|
Marshal.load(super.first)
|
|
93
113
|
end
|
|
@@ -99,6 +119,8 @@ module OMQ
|
|
|
99
119
|
class ShareableConnection < SimpleDelegator
|
|
100
120
|
include TransparentDelegator
|
|
101
121
|
|
|
122
|
+
# @param obj [Object] message to freeze and send via Ractor.make_shareable
|
|
123
|
+
# @return [void]
|
|
102
124
|
def send_message(obj)
|
|
103
125
|
super(::Ractor.make_shareable(obj))
|
|
104
126
|
end
|
|
@@ -112,19 +134,29 @@ module OMQ
|
|
|
112
134
|
class TopicMarshalConnection < SimpleDelegator
|
|
113
135
|
include TransparentDelegator
|
|
114
136
|
|
|
137
|
+
# @param conn [Object] underlying connection to wrap
|
|
138
|
+
# @param cache [SerializeCache] shared serialization cache
|
|
115
139
|
def initialize(conn, cache)
|
|
116
140
|
super(conn)
|
|
117
141
|
@cache = cache
|
|
118
142
|
end
|
|
119
143
|
|
|
144
|
+
|
|
145
|
+
# @param parts [Array<String>] message frames; first frame is topic
|
|
146
|
+
# @return [void]
|
|
120
147
|
def send_message(parts)
|
|
121
148
|
super([parts[0], @cache.marshal(parts[1..])])
|
|
122
149
|
end
|
|
123
150
|
|
|
151
|
+
|
|
152
|
+
# @param parts [Array<String>] message frames; first frame is topic
|
|
153
|
+
# @return [void]
|
|
124
154
|
def write_message(parts)
|
|
125
155
|
super([parts[0], @cache.marshal(parts[1..])])
|
|
126
156
|
end
|
|
127
157
|
|
|
158
|
+
|
|
159
|
+
# @return [Array<String>] deserialized message with topic as first element
|
|
128
160
|
def receive_message
|
|
129
161
|
parts = super
|
|
130
162
|
[parts[0], *Marshal.load(parts[1])]
|
|
@@ -137,6 +169,8 @@ module OMQ
|
|
|
137
169
|
class TopicShareableConnection < SimpleDelegator
|
|
138
170
|
include TransparentDelegator
|
|
139
171
|
|
|
172
|
+
# @param parts [Array<String>] message frames to freeze and send
|
|
173
|
+
# @return [void]
|
|
140
174
|
def send_message(parts)
|
|
141
175
|
super(::Ractor.make_shareable(parts))
|
|
142
176
|
end
|
|
@@ -148,10 +182,16 @@ module OMQ
|
|
|
148
182
|
# Raised by SocketProxy#receive after the socket has been closed.
|
|
149
183
|
# The first receive after closure returns nil; subsequent calls raise.
|
|
150
184
|
#
|
|
151
|
-
class SocketClosedError < IOError
|
|
185
|
+
class SocketClosedError < IOError
|
|
186
|
+
end
|
|
152
187
|
|
|
153
188
|
|
|
189
|
+
# Ractor-side proxy for an OMQ socket. Provides #receive, #<<, and #publish
|
|
190
|
+
# to communicate with the main-thread socket through Ractor ports.
|
|
154
191
|
class SocketProxy
|
|
192
|
+
# @param input_port [Ractor::Port, nil] port for receiving messages (nil if write-only)
|
|
193
|
+
# @param output_port [Ractor::Port, nil] port for sending messages (nil if read-only)
|
|
194
|
+
# @param topic_type [Boolean] whether this is a topic-based socket (PUB/SUB, RADIO/DISH)
|
|
155
195
|
def initialize(input_port, output_port, topic_type)
|
|
156
196
|
@in = input_port
|
|
157
197
|
@out = output_port
|
|
@@ -159,6 +199,7 @@ module OMQ
|
|
|
159
199
|
@closed = false
|
|
160
200
|
end
|
|
161
201
|
|
|
202
|
+
|
|
162
203
|
# Receives the next message from this socket.
|
|
163
204
|
# Returns nil once when the socket closes, then raises
|
|
164
205
|
# SocketClosedError on subsequent calls.
|
|
@@ -176,6 +217,7 @@ module OMQ
|
|
|
176
217
|
@topic_type ? msg.last : msg
|
|
177
218
|
end
|
|
178
219
|
|
|
220
|
+
|
|
179
221
|
# Receives the next message with its topic (PUB/SUB, RADIO/DISH).
|
|
180
222
|
#
|
|
181
223
|
# @return [Array(String, Object), nil] [topic, payload], or nil on close
|
|
@@ -191,6 +233,7 @@ module OMQ
|
|
|
191
233
|
[msg.first, msg.last]
|
|
192
234
|
end
|
|
193
235
|
|
|
236
|
+
|
|
194
237
|
# Sends a message through this socket.
|
|
195
238
|
# For topic-based sockets, wraps as ["", obj] (all subscribers).
|
|
196
239
|
#
|
|
@@ -207,6 +250,7 @@ module OMQ
|
|
|
207
250
|
self
|
|
208
251
|
end
|
|
209
252
|
|
|
253
|
+
|
|
210
254
|
# Publishes a message with an explicit topic (PUB/SUB, RADIO/DISH).
|
|
211
255
|
#
|
|
212
256
|
# @param msg [Object] payload
|
|
@@ -219,6 +263,7 @@ module OMQ
|
|
|
219
263
|
self
|
|
220
264
|
end
|
|
221
265
|
|
|
266
|
+
|
|
222
267
|
# Returns the input port for use with Ractor.select.
|
|
223
268
|
#
|
|
224
269
|
# @return [Ractor::Port]
|
|
@@ -230,19 +275,70 @@ module OMQ
|
|
|
230
275
|
end
|
|
231
276
|
|
|
232
277
|
|
|
278
|
+
# Array of SocketProxy objects with a port→proxy lookup for
|
|
279
|
+
# Ractor.select results.
|
|
280
|
+
#
|
|
281
|
+
class SocketSet < Array
|
|
282
|
+
# @param proxies [Array<SocketProxy>] socket proxies to include
|
|
283
|
+
def initialize(proxies)
|
|
284
|
+
super(proxies)
|
|
285
|
+
@by_port = {}
|
|
286
|
+
proxies.each do |proxy|
|
|
287
|
+
begin
|
|
288
|
+
@by_port[proxy.to_port] = proxy if proxy.to_port
|
|
289
|
+
rescue ::Ractor::ClosedError
|
|
290
|
+
# Skip non-readable proxies.
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# Returns the SocketProxy whose input port matches +port+.
|
|
297
|
+
# Use after Ractor.select to map back to the proxy:
|
|
298
|
+
#
|
|
299
|
+
# port, msg = Ractor.select(a.to_port, b.to_port)
|
|
300
|
+
# source = sockets.socket_for(port)
|
|
301
|
+
#
|
|
302
|
+
# @param port [Ractor::Port]
|
|
303
|
+
# @return [SocketProxy, nil]
|
|
304
|
+
#
|
|
305
|
+
def socket_for(port)
|
|
306
|
+
@by_port[port]
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
|
|
233
311
|
# -- Context -------------------------------------------------------
|
|
234
312
|
|
|
235
313
|
# Frozen, shareable context passed to the worker Ractor.
|
|
236
314
|
# The user calls #sockets to trigger the setup handshake.
|
|
237
315
|
#
|
|
316
|
+
# An optional +data+ object (any Ractor.make_shareable-able value)
|
|
317
|
+
# can be passed via OMQ::Ractor.new(data: …) and retrieved inside
|
|
318
|
+
# the worker block with +omq.data+. This is the only way to pass
|
|
319
|
+
# extra information into a worker block under Ruby 4.0's strict
|
|
320
|
+
# Ractor isolation, which forbids capturing any outer local variable.
|
|
321
|
+
#
|
|
238
322
|
class Context
|
|
239
|
-
|
|
323
|
+
# @param setup_port [Ractor::Port] port for the setup handshake
|
|
324
|
+
# @param output_ports [Array<Ractor::Port, nil>] output ports for writable sockets
|
|
325
|
+
# @param socket_configs [Array<Hash>] per-socket configuration hashes
|
|
326
|
+
# @param data [Object, nil] optional shareable data for the worker
|
|
327
|
+
def initialize(setup_port, output_ports, socket_configs, data: nil)
|
|
240
328
|
@setup_port = setup_port
|
|
241
329
|
@output_ports = output_ports
|
|
242
330
|
@socket_configs = socket_configs
|
|
331
|
+
@data = data
|
|
243
332
|
::Ractor.make_shareable(self)
|
|
244
333
|
end
|
|
245
334
|
|
|
335
|
+
|
|
336
|
+
# User-supplied shareable data (passed as +data:+ to OMQ::Ractor.new).
|
|
337
|
+
#
|
|
338
|
+
# @return [Object, nil]
|
|
339
|
+
#
|
|
340
|
+
attr_reader :data
|
|
341
|
+
|
|
246
342
|
# Performs the setup handshake and returns SocketProxy objects.
|
|
247
343
|
#
|
|
248
344
|
# @return [Array<SocketProxy>]
|
|
@@ -252,9 +348,10 @@ module OMQ
|
|
|
252
348
|
|
|
253
349
|
@setup_port.send(input_ports)
|
|
254
350
|
|
|
255
|
-
@socket_configs.each_with_index.map do |cfg, i|
|
|
351
|
+
proxies = @socket_configs.each_with_index.map do |cfg, i|
|
|
256
352
|
SocketProxy.new(input_ports[i], @output_ports[i], cfg[:topic_type])
|
|
257
353
|
end
|
|
354
|
+
SocketSet.new(proxies)
|
|
258
355
|
end
|
|
259
356
|
end
|
|
260
357
|
|
|
@@ -265,10 +362,14 @@ module OMQ
|
|
|
265
362
|
#
|
|
266
363
|
# @param sockets [Array<Socket>] sockets to bridge
|
|
267
364
|
# @param serialize [Boolean] whether to auto-serialize per connection (default: true)
|
|
365
|
+
# @param data [Object, nil] optional shareable data accessible as +omq.data+
|
|
366
|
+
# inside the worker block. Under Ruby 4.0's strict Ractor isolation,
|
|
367
|
+
# worker blocks cannot close over outer local variables; use +data:+ to
|
|
368
|
+
# pass configuration into the block.
|
|
268
369
|
# @yield [Context] block executes inside the worker Ractor;
|
|
269
370
|
# must call omq.sockets immediately
|
|
270
371
|
#
|
|
271
|
-
def initialize(*sockets, serialize: true, &block)
|
|
372
|
+
def initialize(*sockets, serialize: true, data: nil, &block)
|
|
272
373
|
raise ArgumentError, "no sockets given" if sockets.empty?
|
|
273
374
|
raise ArgumentError, "no block given" unless block
|
|
274
375
|
|
|
@@ -283,6 +384,7 @@ module OMQ
|
|
|
283
384
|
serialize: serialize, topic_type: topic_type }
|
|
284
385
|
end
|
|
285
386
|
|
|
387
|
+
|
|
286
388
|
# Main Ractor creates output ports (one per writable socket)
|
|
287
389
|
@output_ports = socket_configs.map { |cfg| cfg[:writable] ? ::Ractor::Port.new : nil }
|
|
288
390
|
output_ports = @output_ports
|
|
@@ -293,7 +395,8 @@ module OMQ
|
|
|
293
395
|
# Build frozen context for the worker
|
|
294
396
|
frozen_configs = ::Ractor.make_shareable(socket_configs)
|
|
295
397
|
frozen_outputs = ::Ractor.make_shareable(output_ports)
|
|
296
|
-
|
|
398
|
+
frozen_data = data ? ::Ractor.make_shareable(data) : nil
|
|
399
|
+
ctx = Context.new(setup_port, frozen_outputs, frozen_configs, data: frozen_data)
|
|
297
400
|
|
|
298
401
|
# Install connection wrappers for per-connection serialization
|
|
299
402
|
install_connection_wrappers(socket_configs) if serialize
|
|
@@ -328,6 +431,7 @@ module OMQ
|
|
|
328
431
|
# Waits for the worker Ractor to finish naturally.
|
|
329
432
|
# The worker must return from its block on its own.
|
|
330
433
|
#
|
|
434
|
+
# @return [void]
|
|
331
435
|
def join
|
|
332
436
|
await_ractor { @ractor.join }
|
|
333
437
|
ensure
|
|
@@ -338,6 +442,7 @@ module OMQ
|
|
|
338
442
|
# Returns the worker Ractor's return value.
|
|
339
443
|
# The worker must return from its block on its own.
|
|
340
444
|
#
|
|
445
|
+
# @return [Object] the worker block's return value
|
|
341
446
|
def value
|
|
342
447
|
await_ractor { @ractor.value }
|
|
343
448
|
ensure
|
|
@@ -349,6 +454,7 @@ module OMQ
|
|
|
349
454
|
# Sends nil through all input ports, causing proxy.receive
|
|
350
455
|
# to return nil (first time) or raise SocketClosedError.
|
|
351
456
|
#
|
|
457
|
+
# @return [void]
|
|
352
458
|
def close
|
|
353
459
|
@input_ports.each { |p| p&.send(nil) rescue nil }
|
|
354
460
|
await_ractor { @ractor.join } rescue nil
|
|
@@ -479,6 +585,7 @@ module OMQ
|
|
|
479
585
|
wr.close rescue nil
|
|
480
586
|
end
|
|
481
587
|
|
|
588
|
+
|
|
482
589
|
# Async task: wait on pipe, drain queue, enqueue to engine
|
|
483
590
|
@input_tasks << @parent_task.async(transient: true, annotation: "ractor output bridge") do
|
|
484
591
|
loop do
|
|
@@ -504,6 +611,7 @@ module OMQ
|
|
|
504
611
|
|
|
505
612
|
SHUTDOWN = :__omq_ractor_shutdown__
|
|
506
613
|
|
|
614
|
+
|
|
507
615
|
def cleanup_bridges
|
|
508
616
|
@input_tasks.each { |t| t.stop rescue nil }
|
|
509
617
|
# Unblock output bridge Threads waiting on port.receive
|