omq 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +28 -3
  4. data/lib/omq/channel.rb +5 -5
  5. data/lib/omq/client_server.rb +10 -10
  6. data/lib/omq/engine.rb +702 -0
  7. data/lib/omq/options.rb +48 -0
  8. data/lib/omq/pair.rb +4 -4
  9. data/lib/omq/peer.rb +5 -5
  10. data/lib/omq/pub_sub.rb +18 -18
  11. data/lib/omq/push_pull.rb +6 -6
  12. data/lib/omq/queue_interface.rb +73 -0
  13. data/lib/omq/radio_dish.rb +6 -6
  14. data/lib/omq/reactor.rb +128 -0
  15. data/lib/omq/readable.rb +44 -0
  16. data/lib/omq/req_rep.rb +8 -8
  17. data/lib/omq/router_dealer.rb +8 -8
  18. data/lib/omq/routing/channel.rb +83 -0
  19. data/lib/omq/routing/client.rb +56 -0
  20. data/lib/omq/routing/dealer.rb +57 -0
  21. data/lib/omq/routing/dish.rb +78 -0
  22. data/lib/omq/routing/fan_out.rb +140 -0
  23. data/lib/omq/routing/gather.rb +46 -0
  24. data/lib/omq/routing/pair.rb +86 -0
  25. data/lib/omq/routing/peer.rb +101 -0
  26. data/lib/omq/routing/pub.rb +60 -0
  27. data/lib/omq/routing/pull.rb +46 -0
  28. data/lib/omq/routing/push.rb +81 -0
  29. data/lib/omq/routing/radio.rb +150 -0
  30. data/lib/omq/routing/rep.rb +101 -0
  31. data/lib/omq/routing/req.rb +65 -0
  32. data/lib/omq/routing/round_robin.rb +168 -0
  33. data/lib/omq/routing/router.rb +110 -0
  34. data/lib/omq/routing/scatter.rb +82 -0
  35. data/lib/omq/routing/server.rb +101 -0
  36. data/lib/omq/routing/sub.rb +78 -0
  37. data/lib/omq/routing/xpub.rb +72 -0
  38. data/lib/omq/routing/xsub.rb +83 -0
  39. data/lib/omq/routing.rb +66 -0
  40. data/lib/omq/scatter_gather.rb +8 -8
  41. data/lib/omq/single_frame.rb +18 -0
  42. data/lib/omq/socket.rb +32 -11
  43. data/lib/omq/transport/inproc.rb +355 -0
  44. data/lib/omq/transport/ipc.rb +117 -0
  45. data/lib/omq/transport/tcp.rb +111 -0
  46. data/lib/omq/transport/tls.rb +146 -0
  47. data/lib/omq/version.rb +1 -1
  48. data/lib/omq/writable.rb +66 -0
  49. data/lib/omq.rb +64 -4
  50. metadata +34 -33
  51. data/lib/omq/zmtp/engine.rb +0 -551
  52. data/lib/omq/zmtp/options.rb +0 -48
  53. data/lib/omq/zmtp/reactor.rb +0 -131
  54. data/lib/omq/zmtp/readable.rb +0 -29
  55. data/lib/omq/zmtp/routing/channel.rb +0 -81
  56. data/lib/omq/zmtp/routing/client.rb +0 -56
  57. data/lib/omq/zmtp/routing/dealer.rb +0 -57
  58. data/lib/omq/zmtp/routing/dish.rb +0 -80
  59. data/lib/omq/zmtp/routing/fan_out.rb +0 -131
  60. data/lib/omq/zmtp/routing/gather.rb +0 -48
  61. data/lib/omq/zmtp/routing/pair.rb +0 -84
  62. data/lib/omq/zmtp/routing/peer.rb +0 -100
  63. data/lib/omq/zmtp/routing/pub.rb +0 -62
  64. data/lib/omq/zmtp/routing/pull.rb +0 -48
  65. data/lib/omq/zmtp/routing/push.rb +0 -80
  66. data/lib/omq/zmtp/routing/radio.rb +0 -139
  67. data/lib/omq/zmtp/routing/rep.rb +0 -101
  68. data/lib/omq/zmtp/routing/req.rb +0 -65
  69. data/lib/omq/zmtp/routing/round_robin.rb +0 -143
  70. data/lib/omq/zmtp/routing/router.rb +0 -109
  71. data/lib/omq/zmtp/routing/scatter.rb +0 -81
  72. data/lib/omq/zmtp/routing/server.rb +0 -100
  73. data/lib/omq/zmtp/routing/sub.rb +0 -80
  74. data/lib/omq/zmtp/routing/xpub.rb +0 -74
  75. data/lib/omq/zmtp/routing/xsub.rb +0 -86
  76. data/lib/omq/zmtp/routing.rb +0 -65
  77. data/lib/omq/zmtp/single_frame.rb +0 -20
  78. data/lib/omq/zmtp/transport/inproc.rb +0 -359
  79. data/lib/omq/zmtp/transport/ipc.rb +0 -118
  80. data/lib/omq/zmtp/transport/tcp.rb +0 -117
  81. data/lib/omq/zmtp/writable.rb +0 -61
  82. data/lib/omq/zmtp.rb +0 -81
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+
6
+ module OMQ
7
+ module Transport
8
+ # In-process transport.
9
+ #
10
+ # Both peers are Ruby backend sockets in the same process (native
11
+ # ZMQ's inproc registry is separate and unreachable). Messages are
12
+ # transferred as Ruby arrays — no ZMTP framing, no byte
13
+ # serialization. String parts are frozen by Writable#send to
14
+ # prevent shared mutable state without copying.
15
+ #
16
+ module Inproc
17
+ # Socket types that exchange commands (SUBSCRIBE/CANCEL) over inproc.
18
+ #
19
+ COMMAND_TYPES = %i[PUB SUB XPUB XSUB RADIO DISH].freeze
20
+
21
+ # Global registry of bound inproc endpoints.
22
+ #
23
+ @registry = {}
24
+ @mutex = Mutex.new
25
+ @waiters = Hash.new { |h, k| h[k] = [] }
26
+
27
+
28
+ class << self
29
+ # Binds an engine to an inproc endpoint.
30
+ #
31
+ # @param endpoint [String] e.g. "inproc://my-endpoint"
32
+ # @param engine [Engine] the owning engine
33
+ # @return [Listener]
34
+ # @raise [ArgumentError] if endpoint is already bound
35
+ #
36
+ def bind(endpoint, engine)
37
+ @mutex.synchronize do
38
+ raise ArgumentError, "endpoint already bound: #{endpoint}" if @registry.key?(endpoint)
39
+ @registry[endpoint] = engine
40
+
41
+ # Wake any pending connects
42
+ @waiters[endpoint].each { |p| p.resolve(true) }
43
+ @waiters.delete(endpoint)
44
+ end
45
+ Listener.new(endpoint)
46
+ end
47
+
48
+
49
+ # Connects to a bound inproc endpoint.
50
+ #
51
+ # @param endpoint [String] e.g. "inproc://my-endpoint"
52
+ # @param engine [Engine] the connecting engine
53
+ # @return [void]
54
+ #
55
+ def connect(endpoint, engine)
56
+ bound_engine = @mutex.synchronize { @registry[endpoint] }
57
+
58
+ unless bound_engine
59
+ # Endpoint not bound yet. Wait with timeout derived from
60
+ # reconnect_interval. If it doesn't appear, silently return —
61
+ # matching ZMQ 4.x behavior where inproc connect to an
62
+ # unbound endpoint succeeds but messages go nowhere.
63
+ # A background task retries periodically.
64
+ ri = engine.options.reconnect_interval
65
+ timeout = ri.is_a?(Range) ? ri.begin : ri
66
+ promise = Async::Promise.new
67
+ @mutex.synchronize { @waiters[endpoint] << promise }
68
+ unless promise.wait?(timeout: timeout)
69
+ @mutex.synchronize { @waiters[endpoint].delete(promise) }
70
+ start_connect_retry(endpoint, engine)
71
+ return
72
+ end
73
+ bound_engine = @mutex.synchronize { @registry[endpoint] }
74
+ end
75
+
76
+ establish_link(engine, bound_engine, endpoint)
77
+ end
78
+
79
+
80
+ # Removes a bound endpoint from the registry.
81
+ #
82
+ # @param endpoint [String]
83
+ # @return [void]
84
+ #
85
+ def unbind(endpoint)
86
+ @mutex.synchronize { @registry.delete(endpoint) }
87
+ end
88
+
89
+
90
+ # Resets the registry. Used in tests.
91
+ #
92
+ # @return [void]
93
+ #
94
+ def reset!
95
+ @mutex.synchronize do
96
+ @registry.clear
97
+ @waiters.clear
98
+ end
99
+ end
100
+
101
+
102
+ private
103
+
104
+
105
+ # Wires up a client-server inproc pipe pair after validating
106
+ # that the two socket types are compatible.
107
+ #
108
+ # @param client_engine [Engine] the connecting engine
109
+ # @param server_engine [Engine] the bound engine
110
+ # @param endpoint [String] the inproc endpoint name
111
+ #
112
+ def establish_link(client_engine, server_engine, endpoint)
113
+ client_type = client_engine.socket_type
114
+ server_type = server_engine.socket_type
115
+
116
+ unless Protocol::ZMTP::VALID_PEERS[client_type]&.include?(server_type)
117
+ raise Protocol::ZMTP::Error,
118
+ "incompatible socket types: #{client_type} cannot connect to #{server_type}"
119
+ end
120
+
121
+ # Only PUB/SUB-family types exchange commands (SUBSCRIBE/CANCEL)
122
+ # over inproc. All other types use only the direct recv queue
123
+ # bypass for data, so no internal queues are needed.
124
+ needs_commands = COMMAND_TYPES.include?(client_type) ||
125
+ COMMAND_TYPES.include?(server_type)
126
+
127
+ if needs_commands
128
+ a_to_b = Async::Queue.new
129
+ b_to_a = Async::Queue.new
130
+ end
131
+
132
+ client_pipe = DirectPipe.new(
133
+ send_queue: needs_commands ? a_to_b : nil,
134
+ receive_queue: needs_commands ? b_to_a : nil,
135
+ peer_identity: server_engine.options.identity,
136
+ peer_type: server_type.to_s,
137
+ )
138
+ server_pipe = DirectPipe.new(
139
+ send_queue: needs_commands ? b_to_a : nil,
140
+ receive_queue: needs_commands ? a_to_b : nil,
141
+ peer_identity: client_engine.options.identity,
142
+ peer_type: client_type.to_s,
143
+ )
144
+
145
+ client_pipe.peer = server_pipe
146
+ server_pipe.peer = client_pipe
147
+
148
+ client_engine.connection_ready(client_pipe, endpoint: endpoint)
149
+ server_engine.connection_ready(server_pipe, endpoint: endpoint)
150
+ end
151
+
152
+
153
+ # Spawns a background task that periodically retries
154
+ # #establish_link until the endpoint appears in the registry.
155
+ #
156
+ # @param endpoint [String] the inproc endpoint name
157
+ # @param engine [Engine] the connecting engine
158
+ #
159
+ def start_connect_retry(endpoint, engine)
160
+ engine.spawn_inproc_retry(endpoint) do |ivl|
161
+ loop do
162
+ sleep ivl
163
+ bound_engine = @mutex.synchronize { @registry[endpoint] }
164
+ if bound_engine
165
+ establish_link(engine, bound_engine, endpoint)
166
+ break
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+
173
+ # A bound inproc endpoint handle.
174
+ #
175
+ class Listener
176
+ # @return [String] the bound endpoint
177
+ #
178
+ attr_reader :endpoint
179
+
180
+
181
+ # @param endpoint [String]
182
+ #
183
+ def initialize(endpoint)
184
+ @endpoint = endpoint
185
+ end
186
+
187
+
188
+ # Stops the listener by removing it from the registry.
189
+ #
190
+ # @return [void]
191
+ #
192
+ def stop
193
+ Inproc.unbind(@endpoint)
194
+ end
195
+ end
196
+
197
+ # A direct in-process pipe that transfers Ruby arrays through queues.
198
+ #
199
+ # Implements the same interface as Connection so routing strategies
200
+ # can use it transparently.
201
+ #
202
+ # When a routing strategy sets {#direct_recv_queue} on a pipe,
203
+ # {#send_message} enqueues directly into the peer's recv queue,
204
+ # bypassing the intermediate pipe queues and the recv pump task.
205
+ # This reduces inproc from 3 queue hops to 2 (send_queue →
206
+ # recv_queue), eliminating the internal pipe queue in between.
207
+ #
208
+ class DirectPipe
209
+ # @return [String] peer's socket type
210
+ #
211
+ attr_reader :peer_socket_type
212
+
213
+
214
+ # @return [String] peer's identity
215
+ #
216
+ attr_reader :peer_identity
217
+
218
+
219
+ # @return [DirectPipe, nil] the other end of this pipe pair
220
+ #
221
+ attr_accessor :peer
222
+
223
+
224
+ # @return [Async::LimitedQueue, nil] when set, {#send_message}
225
+ # enqueues directly here instead of using the internal queue
226
+ #
227
+ attr_reader :direct_recv_queue
228
+
229
+
230
+ # @return [Proc, nil] optional transform applied before
231
+ # enqueuing into {#direct_recv_queue}
232
+ #
233
+ attr_accessor :direct_recv_transform
234
+
235
+
236
+ # @param send_queue [Async::Queue, nil] outgoing command queue
237
+ # (nil for non-PUB/SUB types that don't exchange commands)
238
+ # @param receive_queue [Async::Queue, nil] incoming command queue
239
+ # @param peer_identity [String]
240
+ # @param peer_type [String]
241
+ #
242
+ def initialize(send_queue: nil, receive_queue: nil, peer_identity:, peer_type:)
243
+ @send_queue = send_queue
244
+ @receive_queue = receive_queue
245
+ @peer_identity = peer_identity || "".b
246
+ @peer_socket_type = peer_type
247
+ @closed = false
248
+ @peer = nil
249
+ @direct_recv_queue = nil
250
+ @direct_recv_transform = nil
251
+ @pending_direct = nil
252
+ end
253
+
254
+
255
+ # Sets the direct recv queue. Drains any messages that were
256
+ # buffered before the queue was available.
257
+ #
258
+ def direct_recv_queue=(queue)
259
+ @direct_recv_queue = queue
260
+ if queue && @pending_direct
261
+ @pending_direct.each do |msg|
262
+ queue.enqueue(msg)
263
+ end
264
+ @pending_direct = nil
265
+ end
266
+ end
267
+
268
+
269
+ # Sends a multi-frame message.
270
+ #
271
+ # When {#direct_recv_queue} is set (inproc fast path), the
272
+ # message is delivered directly to the peer's recv queue,
273
+ # skipping the internal pipe queues and the recv pump.
274
+ #
275
+ # @param parts [Array<String>]
276
+ # @return [void]
277
+ #
278
+ def send_message(parts)
279
+ raise IOError, "closed" if @closed
280
+ if @direct_recv_queue
281
+ msg = @direct_recv_transform ? @direct_recv_transform.call(parts).freeze : parts
282
+ @direct_recv_queue.enqueue(msg)
283
+ elsif @send_queue
284
+ @send_queue.enqueue(parts)
285
+ else
286
+ msg = @direct_recv_transform ? @direct_recv_transform.call(parts).freeze : parts
287
+ (@pending_direct ||= []) << msg
288
+ end
289
+ end
290
+
291
+
292
+ alias write_message send_message
293
+
294
+
295
+ # No-op — inproc has no IO buffer to flush.
296
+ #
297
+ # @return [void]
298
+ #
299
+ def flush = nil
300
+
301
+
302
+ # Receives a multi-frame message.
303
+ #
304
+ # @return [Array<String>]
305
+ # @raise [EOFError] if closed
306
+ #
307
+ def receive_message
308
+ msg = @receive_queue.dequeue
309
+ raise EOFError, "connection closed" if msg.nil?
310
+ msg
311
+ end
312
+
313
+
314
+ # Sends a command via the internal command queue.
315
+ # Only available for PUB/SUB-family pipes.
316
+ #
317
+ # @param command [Protocol::ZMTP::Codec::Command]
318
+ # @return [void]
319
+ #
320
+ def send_command(command)
321
+ raise IOError, "closed" if @closed
322
+ @send_queue.enqueue([:command, command])
323
+ end
324
+
325
+
326
+ # Reads one command frame from the internal command queue.
327
+ # Used by PUB/XPUB subscription listeners.
328
+ #
329
+ # @return [Protocol::ZMTP::Codec::Frame]
330
+ #
331
+ def read_frame
332
+ loop do
333
+ item = @receive_queue.dequeue
334
+ raise EOFError, "connection closed" if item.nil?
335
+ if item.is_a?(Array) && item.first == :command
336
+ cmd = item[1]
337
+ return Protocol::ZMTP::Codec::Frame.new(cmd.to_body, command: true)
338
+ end
339
+ end
340
+ end
341
+
342
+
343
+ # Closes this pipe end.
344
+ #
345
+ # @return [void]
346
+ #
347
+ def close
348
+ return if @closed
349
+ @closed = true
350
+ @send_queue&.enqueue(nil) # close sentinel
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "io/stream"
5
+
6
+ module OMQ
7
+ module Transport
8
+ # IPC transport using Unix domain sockets.
9
+ #
10
+ # Supports both file-based paths and Linux abstract namespace
11
+ # (paths starting with @).
12
+ #
13
+ module IPC
14
+ class << self
15
+ # Binds an IPC server.
16
+ #
17
+ # @param endpoint [String] e.g. "ipc:///tmp/my.sock" or "ipc://@abstract"
18
+ # @param engine [Engine]
19
+ # @return [Listener]
20
+ #
21
+ def bind(endpoint, engine)
22
+ path = parse_path(endpoint)
23
+ sock_path = to_socket_path(path)
24
+
25
+ # Remove stale socket file for file-based paths
26
+ File.delete(sock_path) if !abstract?(path) && File.exist?(sock_path)
27
+
28
+ server = UNIXServer.new(sock_path)
29
+
30
+ Listener.new(endpoint, server, path)
31
+ end
32
+
33
+ # Connects to an IPC endpoint.
34
+ #
35
+ # @param endpoint [String]
36
+ # @param engine [Engine]
37
+ # @return [void]
38
+ #
39
+ def connect(endpoint, engine)
40
+ path = parse_path(endpoint)
41
+ sock_path = to_socket_path(path)
42
+ sock = UNIXSocket.new(sock_path)
43
+ engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
44
+ end
45
+
46
+ private
47
+
48
+ # Extracts path from "ipc://path".
49
+ #
50
+ def parse_path(endpoint)
51
+ endpoint.sub(%r{\Aipc://}, "")
52
+ end
53
+
54
+ # Converts @ prefix to \0 for abstract namespace.
55
+ #
56
+ def to_socket_path(path)
57
+ if abstract?(path)
58
+ "\0#{path[1..]}"
59
+ else
60
+ path
61
+ end
62
+ end
63
+
64
+ # @return [Boolean] true if abstract namespace path
65
+ #
66
+ def abstract?(path)
67
+ path.start_with?("@")
68
+ end
69
+ end
70
+
71
+ # A bound IPC listener.
72
+ #
73
+ class Listener
74
+ # @return [String] the endpoint
75
+ #
76
+ attr_reader :endpoint
77
+
78
+ # @return [UNIXServer] the server socket
79
+ #
80
+ attr_reader :server
81
+
82
+
83
+ # @param endpoint [String] the IPC endpoint URI
84
+ # @param server [UNIXServer]
85
+ # @param path [String] filesystem or abstract namespace path
86
+ #
87
+ def initialize(endpoint, server, path)
88
+ @endpoint = endpoint
89
+ @server = server
90
+ @path = path
91
+ @task = nil
92
+ end
93
+
94
+
95
+ # Registers the accept loop task owned by the engine.
96
+ #
97
+ # @param task [Async::Task]
98
+ #
99
+ def accept_task=(task)
100
+ @task = task
101
+ end
102
+
103
+
104
+ # Stops the listener.
105
+ #
106
+ def stop
107
+ @task&.stop
108
+ @server.close rescue nil
109
+ # Clean up socket file for file-based paths
110
+ unless @path.start_with?("@")
111
+ File.delete(@path) rescue nil
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+ require "io/stream"
6
+
7
+ module OMQ
8
+ module Transport
9
+ # TCP transport using Ruby sockets with Async.
10
+ #
11
+ module TCP
12
+ class << self
13
+ # Binds a TCP server.
14
+ #
15
+ # @param endpoint [String] e.g. "tcp://127.0.0.1:5555" or "tcp://*:0"
16
+ # @param engine [Engine]
17
+ # @return [Listener]
18
+ #
19
+ def bind(endpoint, engine)
20
+ host, port = parse_endpoint(endpoint)
21
+ host = "0.0.0.0" if host == "*"
22
+
23
+ addrs = Addrinfo.getaddrinfo(host, port, nil, :STREAM, nil, ::Socket::AI_PASSIVE)
24
+ raise ::Socket::ResolutionError, "no addresses for #{host}" if addrs.empty?
25
+
26
+ servers = []
27
+ actual_port = nil
28
+
29
+ addrs.each do |addr|
30
+ server = TCPServer.new(addr.ip_address, actual_port || port)
31
+ actual_port ||= server.local_address.ip_port
32
+ servers << server
33
+ end
34
+
35
+ host_part = host.include?(":") ? "[#{host}]" : host
36
+ resolved = "tcp://#{host_part}:#{actual_port}"
37
+ Listener.new(resolved, servers, actual_port)
38
+ end
39
+
40
+ # Connects to a TCP endpoint.
41
+ #
42
+ # @param endpoint [String] e.g. "tcp://127.0.0.1:5555"
43
+ # @param engine [Engine]
44
+ # @return [void]
45
+ #
46
+ def connect(endpoint, engine)
47
+ host, port = parse_endpoint(endpoint)
48
+ sock = TCPSocket.new(host, port)
49
+ engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
50
+ end
51
+
52
+ private
53
+
54
+ # Parses a TCP endpoint URI into host and port.
55
+ #
56
+ # @param endpoint [String]
57
+ # @return [Array(String, Integer)]
58
+ #
59
+ def parse_endpoint(endpoint)
60
+ uri = URI.parse(endpoint)
61
+ [uri.hostname, uri.port]
62
+ end
63
+ end
64
+
65
+ # A bound TCP listener.
66
+ #
67
+ class Listener
68
+ # @return [String] resolved endpoint with actual port
69
+ #
70
+ attr_reader :endpoint
71
+
72
+ # @return [Integer] bound port
73
+ #
74
+ attr_reader :port
75
+
76
+ # @return [Array<TCPServer>] bound server sockets
77
+ #
78
+ attr_reader :servers
79
+
80
+
81
+ # @param endpoint [String] resolved endpoint URI
82
+ # @param servers [Array<TCPServer>]
83
+ # @param port [Integer] bound port number
84
+ #
85
+ def initialize(endpoint, servers, port)
86
+ @endpoint = endpoint
87
+ @servers = servers
88
+ @port = port
89
+ @tasks = []
90
+ end
91
+
92
+
93
+ # Registers accept loop tasks owned by the engine.
94
+ #
95
+ # @param tasks [Array<Async::Task>]
96
+ #
97
+ def accept_tasks=(tasks)
98
+ @tasks = tasks
99
+ end
100
+
101
+
102
+ # Stops the listener.
103
+ #
104
+ def stop
105
+ @tasks.each(&:stop)
106
+ @servers.each { |s| s.close rescue nil }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end