omq-ractor 0.1.1
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 +7 -0
- data/LICENSE +15 -0
- data/README.md +264 -0
- data/lib/omq/ractor.rb +516 -0
- data/lib/omq-ractor.rb +3 -0
- metadata +57 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: '08afc746c52ec650ff7913ac2c58f164b206c547902539949ae2ac1c3e727e45'
|
|
4
|
+
data.tar.gz: e3ef4264d010e074fabc0076f3ff8fe5f743e940fd07b389b4ab4f84b8db0e17
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9585c7fc0a48b60f5daf61629ba32850945ee8fd07799b6e09e6f4c35810d13950f67a8c1f08ab7ff42c041c93e6225d176755b91a329c120b1dcba6d461f746
|
|
7
|
+
data.tar.gz: 8a9295ef54bf9edc729d2e0c47593b8cdf11cc10cfb46bba1f8b004bcd28c89f9b809f3ab9577ef12ef75822773a0db44046a2ed751317278146b3c292ba3194
|
data/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026, Patrik Wenger
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# OMQ::Ractor -- Networked Ractors
|
|
2
|
+
|
|
3
|
+
Ruby Ractors give you true parallelism -- each Ractor gets its own GVL,
|
|
4
|
+
so CPU-bound work runs on separate cores. But they can only talk to each
|
|
5
|
+
other inside a single process, using `Ractor::Port`. No networking, no
|
|
6
|
+
message patterns, no load balancing.
|
|
7
|
+
|
|
8
|
+
`OMQ::Ractor` changes that. It connects Ractors to OMQ sockets -- a
|
|
9
|
+
pure Ruby messaging library with TCP, IPC, and in-process transports.
|
|
10
|
+
Your Ractors can now talk across processes, across machines, using
|
|
11
|
+
patterns like load-balanced pipelines, publish/subscribe, and
|
|
12
|
+
request/reply. All in pure Ruby, no C extensions.
|
|
13
|
+
|
|
14
|
+
The I/O stays in the main Ractor (on the Async fiber scheduler). Worker
|
|
15
|
+
Ractors do pure computation. Messages flow between them transparently,
|
|
16
|
+
serialized per-connection: zero-copy for in-process, Marshal for the
|
|
17
|
+
network.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## The problem
|
|
21
|
+
|
|
22
|
+
Ractors and Async don't mix. `Async::Queue` wraps a `Thread::Queue`
|
|
23
|
+
internally -- it can't be shared between Ractors or even copied into
|
|
24
|
+
one. So you can't just pass an Async queue to a Ractor and have objects
|
|
25
|
+
flow between them.
|
|
26
|
+
|
|
27
|
+
`Ractor::Port#receive` blocks the fiber scheduler. Calling it inside
|
|
28
|
+
Async freezes the entire reactor -- no other fibers run until the port
|
|
29
|
+
has data. Same for `Ractor#join` and `Ractor#value`.
|
|
30
|
+
|
|
31
|
+
Without OMQ::Ractor, connecting Ractors to the network means writing
|
|
32
|
+
your own bridge: threads, pipes, queues, serialization, error handling.
|
|
33
|
+
For every direction, every transport.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
require "omq"
|
|
40
|
+
|
|
41
|
+
Async do
|
|
42
|
+
pull = OMQ::PULL.bind("tcp://0.0.0.0:5555")
|
|
43
|
+
push = OMQ::PUSH.connect("tcp://results.internal:5556")
|
|
44
|
+
|
|
45
|
+
worker = OMQ::Ractor.new(pull, push) do |omq|
|
|
46
|
+
pull_p, push_p = omq.sockets # handshake (must be first call)
|
|
47
|
+
|
|
48
|
+
loop do
|
|
49
|
+
msg = pull_p.receive
|
|
50
|
+
push_p << expensive_transform(msg)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
worker.join
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The block runs inside a Ruby Ractor with its own GVL. `omq.sockets`
|
|
59
|
+
performs a setup handshake and returns `SocketProxy` objects --
|
|
60
|
+
lightweight wrappers around `Ractor::Port` pairs.
|
|
61
|
+
|
|
62
|
+
### Multiplexing with Ractor.select
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
worker = OMQ::Ractor.new(pull_a, pull_b, push) do |omq|
|
|
66
|
+
a, b, out = omq.sockets
|
|
67
|
+
|
|
68
|
+
loop do
|
|
69
|
+
source, msg = Ractor.select(a.to_port, b.to_port)
|
|
70
|
+
out << process(msg)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Bidirectional (PAIR, REQ/REP, DEALER)
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
worker = OMQ::Ractor.new(pair) do |omq|
|
|
79
|
+
p = omq.sockets.first
|
|
80
|
+
|
|
81
|
+
loop do
|
|
82
|
+
msg = p.receive
|
|
83
|
+
p << transform(msg)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### PUB/SUB with topics
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
worker = OMQ::Ractor.new(pub) do |omq|
|
|
92
|
+
pub_p = omq.sockets.first
|
|
93
|
+
|
|
94
|
+
pub_p << obj # all subscribers (empty topic)
|
|
95
|
+
pub_p.publish(obj, topic: "prices.") # matching subscribers only
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
worker = OMQ::Ractor.new(sub) do |omq|
|
|
99
|
+
sub_p = omq.sockets.first
|
|
100
|
+
|
|
101
|
+
obj = sub_p.receive # payload only (topic stripped)
|
|
102
|
+
topic, obj = sub_p.receive_with_topic # both
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Topic prefix matching works normally. The topic stays as a plain string
|
|
107
|
+
frame; only the payload is serialized.
|
|
108
|
+
|
|
109
|
+
### Worker pool
|
|
110
|
+
|
|
111
|
+
PUSH round-robins across connected peers. Multiple Ractors on the same
|
|
112
|
+
endpoint = parallel workers:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
Async do
|
|
116
|
+
source = OMQ::PUSH.bind("inproc://work")
|
|
117
|
+
sink = OMQ::PULL.bind("inproc://results")
|
|
118
|
+
|
|
119
|
+
workers = 4.times.map do
|
|
120
|
+
pull = OMQ::PULL.connect("inproc://work")
|
|
121
|
+
push = OMQ::PUSH.connect("inproc://results")
|
|
122
|
+
|
|
123
|
+
OMQ::Ractor.new(pull, push) do |omq|
|
|
124
|
+
p_in, p_out = omq.sockets
|
|
125
|
+
loop do
|
|
126
|
+
msg = p_in.receive
|
|
127
|
+
p_out << expensive_transform(msg)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Feed work, collect results
|
|
133
|
+
100.times { |i| source << job(i) }
|
|
134
|
+
100.times { sink.receive }
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
## Per-connection serialization
|
|
140
|
+
|
|
141
|
+
With `serialize: true` (default), messages are automatically converted
|
|
142
|
+
between Ruby objects and wire-format bytes:
|
|
143
|
+
|
|
144
|
+
transport send receive
|
|
145
|
+
--------- -------------------------- ---------------
|
|
146
|
+
inproc Ractor.make_shareable pass-through
|
|
147
|
+
(freeze in place, no copy)
|
|
148
|
+
ipc/tcp Marshal.dump Marshal.load
|
|
149
|
+
(cached for fan-out)
|
|
150
|
+
|
|
151
|
+
Serialization happens at the connection level, not the socket level. A
|
|
152
|
+
single socket with both inproc and tcp connections serializes differently
|
|
153
|
+
for each.
|
|
154
|
+
|
|
155
|
+
For ipc/tcp, a SerializeCache ensures fan-out (PUB to N subscribers)
|
|
156
|
+
calls Marshal.dump once per message regardless of subscriber count.
|
|
157
|
+
|
|
158
|
+
Use `serialize: false` for raw messages (frozen string arrays):
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
worker = OMQ::Ractor.new(pull, push, serialize: false) do |omq|
|
|
162
|
+
p_in, p_out = omq.sockets
|
|
163
|
+
msg = p_in.receive # frozen string array, e.g. ["hello"]
|
|
164
|
+
p_out << [msg.first.upcase] # must send frozen string arrays
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
With `serialize: true`, both ends of a tcp/ipc connection must agree on
|
|
169
|
+
the format. Two OMQ::Ractor instances communicate Ruby objects
|
|
170
|
+
transparently. Mixing Ractor-wrapped and regular sockets over tcp/ipc
|
|
171
|
+
requires `serialize: false`.
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
## Architecture
|
|
175
|
+
|
|
176
|
+
```
|
|
177
|
+
Main Ractor (Async) Worker Ractor
|
|
178
|
+
------------------- --------------
|
|
179
|
+
socket.receive ---> input_port ---> proxy.receive
|
|
180
|
+
(Async fiber) (worker owns) (user code)
|
|
181
|
+
|
|
182
|
+
socket.send <--- output_port <--- proxy.<<
|
|
183
|
+
(Async fiber) (main owns) (user code)
|
|
184
|
+
^
|
|
185
|
+
|
|
|
186
|
+
IO.pipe + Thread::Queue
|
|
187
|
+
(Thread does port.receive,
|
|
188
|
+
signals Async via pipe)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Input bridge: Async fiber reads from socket, sends to worker's input
|
|
192
|
+
port. Ractor::Port#send is non-blocking, safe in Async.
|
|
193
|
+
|
|
194
|
+
Output bridge: a Thread reads from the worker's output port
|
|
195
|
+
(port.receive blocks the fiber scheduler, can't be an Async fiber),
|
|
196
|
+
pushes to a Thread::Queue, and signals an Async fiber via IO.pipe. The
|
|
197
|
+
Async fiber drains the queue and feeds the engine directly -- avoiding
|
|
198
|
+
a Reactor.run round-trip per message.
|
|
199
|
+
|
|
200
|
+
Setup handshake: the worker must call `omq.sockets` as its first
|
|
201
|
+
action. This creates worker-owned input ports, sends them to the main
|
|
202
|
+
Ractor, and returns SocketProxy objects. The main Ractor waits up to
|
|
203
|
+
100ms; if the handshake doesn't complete, the Ractor is stopped and
|
|
204
|
+
an error is raised.
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
## Performance
|
|
208
|
+
|
|
209
|
+
Inproc, Ruby 4.0.2 +YJIT, 4-core VM. Speedup relative to inline
|
|
210
|
+
(single-core, no Ractor):
|
|
211
|
+
|
|
212
|
+
```
|
|
213
|
+
bare Ractor OMQ::Ractor
|
|
214
|
+
----------- -----------
|
|
215
|
+
fib(30) ~25ms/call, 200 items:
|
|
216
|
+
1 worker: 1.0x 0.9x
|
|
217
|
+
2 workers: 1.7x 1.6x
|
|
218
|
+
4 workers: 3.1x 2.3x
|
|
219
|
+
|
|
220
|
+
fib(32) ~61ms/call, 100 items:
|
|
221
|
+
1 worker: 1.0x 0.8x
|
|
222
|
+
2 workers: 1.6x 1.4x
|
|
223
|
+
4 workers: 2.5x 2.2x
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Bare Ractors top out around 2.5-3.1x on 4 cores. fib allocates no
|
|
227
|
+
objects (small Integers are immediate values), so this isn't GC -- it's
|
|
228
|
+
Ruby's Ractor overhead itself (YJIT code cache contention, VM internal
|
|
229
|
+
locks, OS thread scheduling). OMQ adds a 5th thread (main reactor)
|
|
230
|
+
competing for 4 cores. The gap narrows with heavier work per message
|
|
231
|
+
(0.8x at 25ms, 0.3x at 61ms).
|
|
232
|
+
|
|
233
|
+
Bridge overhead (passthrough, no CPU work):
|
|
234
|
+
|
|
235
|
+
```
|
|
236
|
+
Baseline (no Ractor): 528k msg/s 1.9 us/msg
|
|
237
|
+
OMQ::Ractor: 149k msg/s 6.7 us/msg
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Reactor responsiveness during CPU work:
|
|
241
|
+
|
|
242
|
+
```
|
|
243
|
+
Echo latency while 50x fib(30) crunches in Ractor:
|
|
244
|
+
p50: 54 us p95: 3.1 ms (GC) max: 13 ms (GC)
|
|
245
|
+
|
|
246
|
+
Without Ractor: reactor blocked for 1252ms
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
## Limitations
|
|
251
|
+
|
|
252
|
+
- Worker Ractors do pure computation. No Async, no I/O scheduling, no
|
|
253
|
+
fiber scheduler. All I/O stays in the main Ractor.
|
|
254
|
+
|
|
255
|
+
- Each OMQ::Ractor wraps its own socket instances. For parallel workers,
|
|
256
|
+
create multiple Ractors with separate sockets connected to the same
|
|
257
|
+
endpoint (see worker pool above).
|
|
258
|
+
|
|
259
|
+
- `omq.sockets` must be the first call in the block. Doing anything else
|
|
260
|
+
before the handshake triggers a timeout error.
|
|
261
|
+
|
|
262
|
+
- With `serialize: true` over tcp/ipc, both ends must use OMQ::Ractor
|
|
263
|
+
(or handle Marshal encoding manually). Use `serialize: false` when
|
|
264
|
+
talking to regular sockets or non-Ruby peers.
|
data/lib/omq/ractor.rb
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require "omq"
|
|
5
|
+
|
|
6
|
+
module OMQ
|
|
7
|
+
# Bridges OMQ sockets into a Ruby Ractor for true parallel processing.
|
|
8
|
+
#
|
|
9
|
+
# Sockets stay in the main Ractor (Async context). Bridge fibers/threads
|
|
10
|
+
# shuttle messages to/from a worker Ractor. Per-connection serialization
|
|
11
|
+
# converts between Ruby objects and ZMQ byte frames transparently:
|
|
12
|
+
# inproc uses Ractor.make_shareable, ipc/tcp use Marshal.
|
|
13
|
+
#
|
|
14
|
+
# @example Simple pipeline
|
|
15
|
+
# Async do
|
|
16
|
+
# pull = OMQ::PULL.new
|
|
17
|
+
# pull.bind("tcp://127.0.0.1:5555")
|
|
18
|
+
# push = OMQ::PUSH.new
|
|
19
|
+
# push.connect("tcp://127.0.0.1:5556")
|
|
20
|
+
#
|
|
21
|
+
# worker = OMQ::Ractor.new(pull, push) do |omq|
|
|
22
|
+
# pull_p, push_p = omq.sockets
|
|
23
|
+
# loop do
|
|
24
|
+
# msg = pull_p.receive
|
|
25
|
+
# push_p << transform(msg)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# worker.join
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
class Ractor
|
|
33
|
+
|
|
34
|
+
HANDSHAKE_TIMEOUT = 0.1
|
|
35
|
+
|
|
36
|
+
# Socket types that use topic/group-based routing.
|
|
37
|
+
# These get topic-aware connection wrappers that preserve
|
|
38
|
+
# the first frame (topic/group) as a plain string for matching.
|
|
39
|
+
TOPIC_SOCKET_TYPES = %i[PUB SUB XPUB XSUB RADIO DISH].freeze
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# -- Connection wrappers -------------------------------------------
|
|
43
|
+
|
|
44
|
+
# Mixed into all connection wrappers so is_a? checks against
|
|
45
|
+
# the wrapped class (e.g. DirectPipe) still work.
|
|
46
|
+
#
|
|
47
|
+
module TransparentDelegator
|
|
48
|
+
def is_a?(klass)
|
|
49
|
+
super || __getobj__.is_a?(klass)
|
|
50
|
+
end
|
|
51
|
+
alias_method :kind_of?, :is_a?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Shared cache for Marshal.dump so fan-out serializes once.
|
|
56
|
+
# The send pump is single-threaded, so identity check suffices.
|
|
57
|
+
#
|
|
58
|
+
class SerializeCache
|
|
59
|
+
def initialize
|
|
60
|
+
@last = nil
|
|
61
|
+
@bytes = nil
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def marshal(obj)
|
|
65
|
+
return @bytes if obj.equal?(@last)
|
|
66
|
+
@last = obj
|
|
67
|
+
@bytes = Marshal.dump(obj).freeze
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Wraps a tcp/ipc Connection with transparent Marshal serialization.
|
|
73
|
+
# Serializes/deserializes the entire parts array.
|
|
74
|
+
#
|
|
75
|
+
class MarshalConnection < SimpleDelegator
|
|
76
|
+
include TransparentDelegator
|
|
77
|
+
|
|
78
|
+
def initialize(conn, cache)
|
|
79
|
+
super(conn)
|
|
80
|
+
@cache = cache
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def send_message(parts)
|
|
84
|
+
super([@cache.marshal(parts)])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_message(parts)
|
|
88
|
+
super([@cache.marshal(parts)])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def receive_message
|
|
92
|
+
Marshal.load(super.first)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Wraps an inproc DirectPipe with Ractor.make_shareable.
|
|
98
|
+
#
|
|
99
|
+
class ShareableConnection < SimpleDelegator
|
|
100
|
+
include TransparentDelegator
|
|
101
|
+
|
|
102
|
+
def send_message(obj)
|
|
103
|
+
super(::Ractor.make_shareable(obj))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Topic-aware Marshal wrapper for PUB/XPUB/RADIO (send side).
|
|
109
|
+
# Preserves parts[0] (topic/group) as a plain string for
|
|
110
|
+
# subscription matching; serializes parts[1..] (payload).
|
|
111
|
+
#
|
|
112
|
+
class TopicMarshalConnection < SimpleDelegator
|
|
113
|
+
include TransparentDelegator
|
|
114
|
+
|
|
115
|
+
def initialize(conn, cache)
|
|
116
|
+
super(conn)
|
|
117
|
+
@cache = cache
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def send_message(parts)
|
|
121
|
+
super([parts[0], @cache.marshal(parts[1..])])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def write_message(parts)
|
|
125
|
+
super([parts[0], @cache.marshal(parts[1..])])
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def receive_message
|
|
129
|
+
parts = super
|
|
130
|
+
[parts[0], *Marshal.load(parts[1])]
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Topic-aware shareable wrapper for inproc PUB/SUB.
|
|
136
|
+
#
|
|
137
|
+
class TopicShareableConnection < SimpleDelegator
|
|
138
|
+
include TransparentDelegator
|
|
139
|
+
|
|
140
|
+
def send_message(parts)
|
|
141
|
+
super(::Ractor.make_shareable(parts))
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# -- SocketProxy ---------------------------------------------------
|
|
147
|
+
|
|
148
|
+
# Raised by SocketProxy#receive after the socket has been closed.
|
|
149
|
+
# The first receive after closure returns nil; subsequent calls raise.
|
|
150
|
+
#
|
|
151
|
+
class SocketClosedError < IOError; end
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class SocketProxy
|
|
155
|
+
def initialize(input_port, output_port, topic_type)
|
|
156
|
+
@in = input_port
|
|
157
|
+
@out = output_port
|
|
158
|
+
@topic_type = topic_type
|
|
159
|
+
@closed = false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Receives the next message from this socket.
|
|
163
|
+
# Returns nil once when the socket closes, then raises
|
|
164
|
+
# SocketClosedError on subsequent calls.
|
|
165
|
+
#
|
|
166
|
+
# @return [Object, nil] deserialized message, or nil on close
|
|
167
|
+
#
|
|
168
|
+
def receive
|
|
169
|
+
raise ::Ractor::ClosedError, "not readable" unless @in
|
|
170
|
+
raise SocketClosedError, "socket closed" if @closed
|
|
171
|
+
msg = @in.receive
|
|
172
|
+
if msg.nil?
|
|
173
|
+
@closed = true
|
|
174
|
+
return nil
|
|
175
|
+
end
|
|
176
|
+
@topic_type ? msg.last : msg
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Receives the next message with its topic (PUB/SUB, RADIO/DISH).
|
|
180
|
+
#
|
|
181
|
+
# @return [Array(String, Object), nil] [topic, payload], or nil on close
|
|
182
|
+
#
|
|
183
|
+
def receive_with_topic
|
|
184
|
+
raise ::Ractor::ClosedError, "not readable" unless @in
|
|
185
|
+
raise SocketClosedError, "socket closed" if @closed
|
|
186
|
+
msg = @in.receive
|
|
187
|
+
if msg.nil?
|
|
188
|
+
@closed = true
|
|
189
|
+
return nil
|
|
190
|
+
end
|
|
191
|
+
[msg.first, msg.last]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Sends a message through this socket.
|
|
195
|
+
# For topic-based sockets, wraps as ["", obj] (all subscribers).
|
|
196
|
+
#
|
|
197
|
+
# @param msg [Object] message
|
|
198
|
+
# @return [self]
|
|
199
|
+
#
|
|
200
|
+
def <<(msg)
|
|
201
|
+
raise ::Ractor::ClosedError, "not writable" unless @out
|
|
202
|
+
if @topic_type
|
|
203
|
+
@out.send(["".b.freeze, msg])
|
|
204
|
+
else
|
|
205
|
+
@out.send(msg)
|
|
206
|
+
end
|
|
207
|
+
self
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Publishes a message with an explicit topic (PUB/SUB, RADIO/DISH).
|
|
211
|
+
#
|
|
212
|
+
# @param msg [Object] payload
|
|
213
|
+
# @param topic [String] topic string for subscription matching
|
|
214
|
+
# @return [self]
|
|
215
|
+
#
|
|
216
|
+
def publish(msg, topic:)
|
|
217
|
+
raise ::Ractor::ClosedError, "not writable" unless @out
|
|
218
|
+
@out.send([topic.b.freeze, msg])
|
|
219
|
+
self
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Returns the input port for use with Ractor.select.
|
|
223
|
+
#
|
|
224
|
+
# @return [Ractor::Port]
|
|
225
|
+
#
|
|
226
|
+
def to_port
|
|
227
|
+
raise ::Ractor::ClosedError, "not readable" unless @in
|
|
228
|
+
@in
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# -- Context -------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
# Frozen, shareable context passed to the worker Ractor.
|
|
236
|
+
# The user calls #sockets to trigger the setup handshake.
|
|
237
|
+
#
|
|
238
|
+
class Context
|
|
239
|
+
def initialize(setup_port, output_ports, socket_configs)
|
|
240
|
+
@setup_port = setup_port
|
|
241
|
+
@output_ports = output_ports
|
|
242
|
+
@socket_configs = socket_configs
|
|
243
|
+
::Ractor.make_shareable(self)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Performs the setup handshake and returns SocketProxy objects.
|
|
247
|
+
#
|
|
248
|
+
# @return [Array<SocketProxy>]
|
|
249
|
+
#
|
|
250
|
+
def sockets
|
|
251
|
+
input_ports = @socket_configs.map { |cfg| cfg[:readable] ? ::Ractor::Port.new : nil }
|
|
252
|
+
|
|
253
|
+
@setup_port.send(input_ports)
|
|
254
|
+
|
|
255
|
+
@socket_configs.each_with_index.map do |cfg, i|
|
|
256
|
+
SocketProxy.new(input_ports[i], @output_ports[i], cfg[:topic_type])
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# -- Constructor ---------------------------------------------------
|
|
263
|
+
|
|
264
|
+
# Creates a new OMQ::Ractor that bridges the given sockets into a worker Ractor.
|
|
265
|
+
#
|
|
266
|
+
# @param sockets [Array<Socket>] sockets to bridge
|
|
267
|
+
# @param serialize [Boolean] whether to auto-serialize per connection (default: true)
|
|
268
|
+
# @yield [Context] block executes inside the worker Ractor;
|
|
269
|
+
# must call omq.sockets immediately
|
|
270
|
+
#
|
|
271
|
+
def initialize(*sockets, serialize: true, &block)
|
|
272
|
+
raise ArgumentError, "no sockets given" if sockets.empty?
|
|
273
|
+
raise ArgumentError, "no block given" unless block
|
|
274
|
+
|
|
275
|
+
@sockets = sockets
|
|
276
|
+
@serialize = serialize
|
|
277
|
+
|
|
278
|
+
# Categorize sockets
|
|
279
|
+
socket_configs = sockets.map do |s|
|
|
280
|
+
type_sym = s.class.name.split("::").last.to_sym
|
|
281
|
+
topic_type = TOPIC_SOCKET_TYPES.include?(type_sym)
|
|
282
|
+
{ readable: s.is_a?(Readable), writable: s.is_a?(Writable),
|
|
283
|
+
serialize: serialize, topic_type: topic_type }
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Main Ractor creates output ports (one per writable socket)
|
|
287
|
+
@output_ports = socket_configs.map { |cfg| cfg[:writable] ? ::Ractor::Port.new : nil }
|
|
288
|
+
output_ports = @output_ports
|
|
289
|
+
|
|
290
|
+
# Setup port for the handshake (main-owned, main receives)
|
|
291
|
+
setup_port = ::Ractor::Port.new
|
|
292
|
+
|
|
293
|
+
# Build frozen context for the worker
|
|
294
|
+
frozen_configs = ::Ractor.make_shareable(socket_configs)
|
|
295
|
+
frozen_outputs = ::Ractor.make_shareable(output_ports)
|
|
296
|
+
ctx = Context.new(setup_port, frozen_outputs, frozen_configs)
|
|
297
|
+
|
|
298
|
+
# Install connection wrappers for per-connection serialization
|
|
299
|
+
install_connection_wrappers(socket_configs) if serialize
|
|
300
|
+
|
|
301
|
+
# Start the worker Ractor
|
|
302
|
+
@ractor = ::Ractor.new(ctx, &block)
|
|
303
|
+
|
|
304
|
+
# Wait for the handshake with timeout
|
|
305
|
+
@input_ports = await_handshake(setup_port)
|
|
306
|
+
input_ports = @input_ports
|
|
307
|
+
|
|
308
|
+
# Start bridges on the correct task.
|
|
309
|
+
# Inside Async: spawn under current task.
|
|
310
|
+
# Outside Async: dispatch to the IO thread via Reactor.run.
|
|
311
|
+
@input_tasks = []
|
|
312
|
+
@output_threads = []
|
|
313
|
+
@output_pipes = []
|
|
314
|
+
if Async::Task.current?
|
|
315
|
+
@parent_task = Async::Task.current
|
|
316
|
+
start_input_bridges(input_ports, socket_configs)
|
|
317
|
+
start_output_bridges(output_ports, socket_configs)
|
|
318
|
+
else
|
|
319
|
+
@parent_task = Reactor.root_task
|
|
320
|
+
Reactor.run do
|
|
321
|
+
start_input_bridges(input_ports, socket_configs)
|
|
322
|
+
start_output_bridges(output_ports, socket_configs)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# Waits for the worker Ractor to finish naturally.
|
|
329
|
+
# The worker must return from its block on its own.
|
|
330
|
+
#
|
|
331
|
+
def join
|
|
332
|
+
await_ractor { @ractor.join }
|
|
333
|
+
ensure
|
|
334
|
+
cleanup_bridges
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# Returns the worker Ractor's return value.
|
|
339
|
+
# The worker must return from its block on its own.
|
|
340
|
+
#
|
|
341
|
+
def value
|
|
342
|
+
await_ractor { @ractor.value }
|
|
343
|
+
ensure
|
|
344
|
+
cleanup_bridges
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# Signals the worker to stop, then waits for it to finish.
|
|
349
|
+
# Sends nil through all input ports, causing proxy.receive
|
|
350
|
+
# to return nil (first time) or raise SocketClosedError.
|
|
351
|
+
#
|
|
352
|
+
def close
|
|
353
|
+
@input_ports.each { |p| p&.send(nil) rescue nil }
|
|
354
|
+
await_ractor { @ractor.join } rescue nil
|
|
355
|
+
cleanup_bridges
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
private
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# Waits for the worker to call omq.sockets (handshake).
|
|
363
|
+
# Times out after HANDSHAKE_TIMEOUT seconds.
|
|
364
|
+
#
|
|
365
|
+
def await_handshake(setup_port)
|
|
366
|
+
rd, wr = IO.pipe
|
|
367
|
+
input_ports = nil
|
|
368
|
+
Thread.new do
|
|
369
|
+
input_ports = setup_port.receive
|
|
370
|
+
ensure
|
|
371
|
+
wr.close
|
|
372
|
+
end
|
|
373
|
+
unless rd.wait_readable(HANDSHAKE_TIMEOUT)
|
|
374
|
+
rd.close
|
|
375
|
+
@ractor.close rescue nil
|
|
376
|
+
raise ArgumentError, "worker Ractor must call omq.sockets before doing anything else"
|
|
377
|
+
end
|
|
378
|
+
rd.close
|
|
379
|
+
input_ports
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# Runs a block in a Thread, returning the result via an
|
|
384
|
+
# IO.pipe so the Async reactor stays responsive.
|
|
385
|
+
#
|
|
386
|
+
def await_ractor
|
|
387
|
+
rd, wr = IO.pipe
|
|
388
|
+
result = nil
|
|
389
|
+
error = nil
|
|
390
|
+
Thread.new do
|
|
391
|
+
result = yield
|
|
392
|
+
rescue => e
|
|
393
|
+
error = e
|
|
394
|
+
ensure
|
|
395
|
+
wr.close
|
|
396
|
+
end
|
|
397
|
+
rd.wait_readable
|
|
398
|
+
rd.close
|
|
399
|
+
raise error if error
|
|
400
|
+
result
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def install_connection_wrappers(socket_configs)
|
|
405
|
+
@sockets.each_with_index do |socket, i|
|
|
406
|
+
cache = SerializeCache.new
|
|
407
|
+
topic_type = socket_configs[i][:topic_type]
|
|
408
|
+
engine = socket.instance_variable_get(:@engine)
|
|
409
|
+
|
|
410
|
+
engine.connection_wrapper = ->(conn) do
|
|
411
|
+
inproc = conn.is_a?(Transport::Inproc::DirectPipe)
|
|
412
|
+
if topic_type
|
|
413
|
+
inproc ? TopicShareableConnection.new(conn) : TopicMarshalConnection.new(conn, cache)
|
|
414
|
+
else
|
|
415
|
+
inproc ? ShareableConnection.new(conn) : MarshalConnection.new(conn, cache)
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# Input bridges: socket -> Ractor (Async fibers).
|
|
423
|
+
#
|
|
424
|
+
def start_input_bridges(input_ports, socket_configs)
|
|
425
|
+
@sockets.each_with_index do |socket, i|
|
|
426
|
+
port = input_ports[i]
|
|
427
|
+
next unless port
|
|
428
|
+
|
|
429
|
+
do_serialize = socket_configs[i][:serialize]
|
|
430
|
+
topic_type = socket_configs[i][:topic_type]
|
|
431
|
+
|
|
432
|
+
@input_tasks << @parent_task.async(transient: true, annotation: "ractor input bridge") do
|
|
433
|
+
loop do
|
|
434
|
+
msg = socket.receive
|
|
435
|
+
if do_serialize && !topic_type
|
|
436
|
+
msg = msg.first
|
|
437
|
+
end
|
|
438
|
+
port.send(msg)
|
|
439
|
+
rescue IOError, Async::Stop
|
|
440
|
+
port.send(nil) rescue nil
|
|
441
|
+
break
|
|
442
|
+
rescue ::Ractor::ClosedError
|
|
443
|
+
break
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# Output bridges: Ractor -> socket.
|
|
451
|
+
#
|
|
452
|
+
# A Thread reads from the Ractor port (blocking, can't run in Async)
|
|
453
|
+
# and pushes messages to a Thread::Queue. An Async task waits on an
|
|
454
|
+
# IO.pipe signal and drains the queue directly into the engine --
|
|
455
|
+
# avoiding the Reactor.run synchronization round-trip per message.
|
|
456
|
+
#
|
|
457
|
+
def start_output_bridges(output_ports, socket_configs)
|
|
458
|
+
@sockets.each_with_index do |socket, i|
|
|
459
|
+
port = output_ports[i]
|
|
460
|
+
next unless port
|
|
461
|
+
|
|
462
|
+
do_serialize = socket_configs[i][:serialize]
|
|
463
|
+
topic_type = socket_configs[i][:topic_type]
|
|
464
|
+
engine = socket.instance_variable_get(:@engine)
|
|
465
|
+
queue = Thread::Queue.new
|
|
466
|
+
rd, wr = IO.pipe
|
|
467
|
+
@output_pipes << rd << wr
|
|
468
|
+
|
|
469
|
+
# Thread: port.receive -> queue + pipe signal
|
|
470
|
+
@output_threads << Thread.new do
|
|
471
|
+
loop do
|
|
472
|
+
msg = port.receive
|
|
473
|
+
break if msg.equal?(SHUTDOWN)
|
|
474
|
+
queue << msg
|
|
475
|
+
wr.write_nonblock("x") rescue nil
|
|
476
|
+
rescue ::Ractor::ClosedError
|
|
477
|
+
break
|
|
478
|
+
end
|
|
479
|
+
wr.close rescue nil
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Async task: wait on pipe, drain queue, enqueue to engine
|
|
483
|
+
@input_tasks << @parent_task.async(transient: true, annotation: "ractor output bridge") do
|
|
484
|
+
loop do
|
|
485
|
+
rd.wait_readable
|
|
486
|
+
rd.read_nonblock(4096) rescue nil
|
|
487
|
+
|
|
488
|
+
while (msg = queue.pop(true) rescue nil)
|
|
489
|
+
if do_serialize
|
|
490
|
+
parts = topic_type && msg.is_a?(Array) ? msg : [msg]
|
|
491
|
+
engine.enqueue_send(parts)
|
|
492
|
+
else
|
|
493
|
+
parts = socket.__send__(:freeze_message, msg)
|
|
494
|
+
engine.enqueue_send(parts)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
rescue IOError, Async::Stop
|
|
498
|
+
break
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
SHUTDOWN = :__omq_ractor_shutdown__
|
|
506
|
+
|
|
507
|
+
def cleanup_bridges
|
|
508
|
+
@input_tasks.each { |t| t.stop rescue nil }
|
|
509
|
+
# Unblock output bridge Threads waiting on port.receive
|
|
510
|
+
# (port.close does NOT unblock a waiting receive)
|
|
511
|
+
@output_ports.each { |p| p&.send(SHUTDOWN) rescue nil }
|
|
512
|
+
@output_pipes.each { |io| io.close rescue nil }
|
|
513
|
+
@output_threads.each { |t| t.join(1) }
|
|
514
|
+
end
|
|
515
|
+
end
|
|
516
|
+
end
|
data/lib/omq-ractor.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omq-ractor
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Patrik Wenger
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: omq
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.11'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.11'
|
|
26
|
+
email:
|
|
27
|
+
- paddor@gmail.com
|
|
28
|
+
executables: []
|
|
29
|
+
extensions: []
|
|
30
|
+
extra_rdoc_files: []
|
|
31
|
+
files:
|
|
32
|
+
- LICENSE
|
|
33
|
+
- README.md
|
|
34
|
+
- lib/omq-ractor.rb
|
|
35
|
+
- lib/omq/ractor.rb
|
|
36
|
+
homepage: https://github.com/paddor/omq-ractor
|
|
37
|
+
licenses:
|
|
38
|
+
- ISC
|
|
39
|
+
metadata: {}
|
|
40
|
+
rdoc_options: []
|
|
41
|
+
require_paths:
|
|
42
|
+
- lib
|
|
43
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '4.0'
|
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '0'
|
|
53
|
+
requirements: []
|
|
54
|
+
rubygems_version: 4.0.6
|
|
55
|
+
specification_version: 4
|
|
56
|
+
summary: Bridge OMQ sockets into Ruby Ractors for true parallel processing
|
|
57
|
+
test_files: []
|