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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +26 -11
  3. data/lib/omq/ractor.rb +113 -5
  4. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08afc746c52ec650ff7913ac2c58f164b206c547902539949ae2ac1c3e727e45'
4
- data.tar.gz: e3ef4264d010e074fabc0076f3ff8fe5f743e940fd07b389b4ab4f84b8db0e17
3
+ metadata.gz: 4672a215899078075ce99ebd8fdff3b985803dee92e0e17577f9748f2e2a89d0
4
+ data.tar.gz: 7ca1e9d1cc2470901b2bd17c8a1ef8f4db19e08cb118cde74415c350c06e5f04
5
5
  SHA512:
6
- metadata.gz: 9585c7fc0a48b60f5daf61629ba32850945ee8fd07799b6e09e6f4c35810d13950f67a8c1f08ab7ff42c041c93e6225d176755b91a329c120b1dcba6d461f746
7
- data.tar.gz: 8a9295ef54bf9edc729d2e0c47593b8cdf11cc10cfb46bba1f8b004bcd28c89f9b809f3ab9577ef12ef75822773a0db44046a2ed751317278146b3c292ba3194
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
+ [![CI](https://github.com/paddor/omq-ractor/actions/workflows/ci.yml/badge.svg)](https://github.com/paddor/omq-ractor/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/omq-ractor?color=e9573f)](https://rubygems.org/gems/omq-ractor)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%204.0-CC342D?logo=ruby&logoColor=white)](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
- loop do
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
- a, b, out = omq.sockets
74
+ sockets = omq.sockets
75
+ a, b, out = sockets
67
76
 
68
77
  loop do
69
- source, msg = Ractor.select(a.to_port, b.to_port)
70
- out << process(msg)
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
- loop do
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
- loop do
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
- p_out << [msg.first.upcase] # must send frozen string arrays
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; end
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
- def initialize(setup_port, output_ports, socket_configs)
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
- ctx = Context.new(setup_port, frozen_outputs, frozen_configs)
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq-ractor
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger