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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/README.md +264 -0
  4. data/lib/omq/ractor.rb +516 -0
  5. data/lib/omq-ractor.rb +3 -0
  6. 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
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "omq/ractor"
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: []