omq 0.22.1 → 0.24.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -0
  3. data/README.md +17 -21
  4. data/lib/omq/channel.rb +35 -0
  5. data/lib/omq/client_server.rb +72 -0
  6. data/lib/omq/constants.rb +68 -0
  7. data/lib/omq/engine/connection_lifecycle.rb +22 -8
  8. data/lib/omq/engine/heartbeat.rb +3 -4
  9. data/lib/omq/engine/maintenance.rb +4 -5
  10. data/lib/omq/engine/reconnect.rb +12 -11
  11. data/lib/omq/engine/recv_pump.rb +10 -10
  12. data/lib/omq/engine/socket_lifecycle.rb +26 -9
  13. data/lib/omq/engine.rb +202 -90
  14. data/lib/omq/peer.rb +49 -0
  15. data/lib/omq/pub_sub.rb +2 -2
  16. data/lib/omq/radio_dish.rb +122 -0
  17. data/lib/omq/reactor.rb +14 -5
  18. data/lib/omq/readable.rb +5 -1
  19. data/lib/omq/routing/channel.rb +110 -0
  20. data/lib/omq/routing/client.rb +70 -0
  21. data/lib/omq/routing/conn_send_pump.rb +5 -8
  22. data/lib/omq/routing/dealer.rb +3 -15
  23. data/lib/omq/routing/dish.rb +94 -0
  24. data/lib/omq/routing/fan_out.rb +12 -16
  25. data/lib/omq/routing/gather.rb +60 -0
  26. data/lib/omq/routing/pair.rb +7 -26
  27. data/lib/omq/routing/peer.rb +95 -0
  28. data/lib/omq/routing/pub.rb +2 -13
  29. data/lib/omq/routing/pull.rb +3 -15
  30. data/lib/omq/routing/push.rb +4 -13
  31. data/lib/omq/routing/radio.rb +187 -0
  32. data/lib/omq/routing/rep.rb +5 -19
  33. data/lib/omq/routing/req.rb +6 -18
  34. data/lib/omq/routing/round_robin.rb +15 -19
  35. data/lib/omq/routing/router.rb +5 -19
  36. data/lib/omq/routing/scatter.rb +76 -0
  37. data/lib/omq/routing/server.rb +90 -0
  38. data/lib/omq/routing/sub.rb +3 -15
  39. data/lib/omq/routing/xpub.rb +2 -13
  40. data/lib/omq/routing/xsub.rb +8 -25
  41. data/lib/omq/scatter_gather.rb +56 -0
  42. data/lib/omq/socket.rb +8 -23
  43. data/lib/omq/transport/inproc/{direct_pipe.rb → pipe.rb} +26 -24
  44. data/lib/omq/transport/inproc.rb +22 -14
  45. data/lib/omq/transport/ipc.rb +41 -13
  46. data/lib/omq/transport/tcp.rb +59 -23
  47. data/lib/omq/transport/udp.rb +281 -0
  48. data/lib/omq/version.rb +1 -1
  49. data/lib/omq/writable.rb +11 -42
  50. data/lib/omq.rb +9 -64
  51. metadata +17 -3
  52. data/lib/omq/monitor_event.rb +0 -16
@@ -0,0 +1,281 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+ require "set"
6
+
7
+ module OMQ
8
+ module Transport
9
+ # UDP transport for RADIO/DISH sockets.
10
+ #
11
+ # Connectionless, datagram-based. No ZMTP handshake.
12
+ # DISH binds and receives; RADIO connects and sends.
13
+ #
14
+ # Wire format per datagram:
15
+ # flags (1 byte = 0x01) | group_size (1 byte) | group (n bytes) | body
16
+ #
17
+ module UDP
18
+ Engine.transports["udp"] = self
19
+
20
+
21
+ MAX_DATAGRAM_SIZE = 65507
22
+
23
+ class << self
24
+ # Creates a bound UDP listener for a DISH socket.
25
+ #
26
+ # @param endpoint [String] e.g. "udp://*:5555" or "udp://127.0.0.1:5555"
27
+ # @param engine [Engine]
28
+ # @return [Listener]
29
+ #
30
+ def listener(endpoint, engine, **)
31
+ host, port = parse_endpoint(endpoint)
32
+ host = "0.0.0.0" if host == "*"
33
+ socket = UDPSocket.new
34
+ socket.bind(host, port)
35
+ Listener.new(endpoint, socket, engine)
36
+ end
37
+
38
+
39
+ # Creates a UDP dialer for a RADIO socket.
40
+ #
41
+ # @param endpoint [String] e.g. "udp://127.0.0.1:5555"
42
+ # @param engine [Engine]
43
+ # @return [Dialer]
44
+ #
45
+ def dialer(endpoint, engine, **)
46
+ Dialer.new(endpoint, engine)
47
+ end
48
+
49
+
50
+ # @param endpoint [String]
51
+ # @return [Array(String, Integer)]
52
+ #
53
+ def parse_endpoint(endpoint)
54
+ uri = URI.parse(endpoint)
55
+ [uri.hostname, uri.port]
56
+ end
57
+
58
+
59
+ # Encodes a group + body into a UDP datagram.
60
+ #
61
+ # @param group [String]
62
+ # @param body [String]
63
+ # @return [String] binary datagram
64
+ #
65
+ def encode_datagram(group, body)
66
+ g = group.b
67
+ b = body.b
68
+ [0x01, g.bytesize].pack("CC") + g + b
69
+ end
70
+
71
+
72
+ # Decodes a UDP datagram into [group, body].
73
+ #
74
+ # @param data [String] raw datagram bytes
75
+ # @return [Array(String, String), nil] nil if malformed
76
+ #
77
+ def decode_datagram(data)
78
+ return nil if data.bytesize < 2
79
+ return nil unless data.getbyte(0) & 0x01 == 0x01
80
+ gs = data.getbyte(1)
81
+ return nil if data.bytesize < 2 + gs
82
+ group = data.byteslice(2, gs)
83
+ body = data.byteslice(2 + gs, data.bytesize - 2 - gs)
84
+ [group, body]
85
+ end
86
+ end
87
+
88
+
89
+ # A UDP dialer — registers a single RadioConnection with the engine.
90
+ #
91
+ # UDP is connectionless; #connect is a one-shot registration.
92
+ # The reconnect loop will break out after the first successful call.
93
+ #
94
+ class Dialer
95
+ # @return [String] the endpoint this dialer sends to
96
+ #
97
+ attr_reader :endpoint
98
+
99
+
100
+ # @param endpoint [String]
101
+ # @param engine [Engine]
102
+ #
103
+ def initialize(endpoint, engine)
104
+ @endpoint = endpoint
105
+ @engine = engine
106
+ end
107
+
108
+
109
+ # Registers a RadioConnection with the engine.
110
+ #
111
+ # @return [void]
112
+ #
113
+ def connect
114
+ host, port = UDP.parse_endpoint(@endpoint)
115
+ socket = UDPSocket.new
116
+ conn = RadioConnection.new(socket, host, port)
117
+ @engine.connection_ready(conn, endpoint: @endpoint)
118
+ end
119
+
120
+ end
121
+
122
+
123
+ # Outgoing UDP connection for RADIO sockets.
124
+ #
125
+ # Intentionally does not implement #read_frame — this signals
126
+ # Routing::Radio to skip the group listener and use ANY_GROUPS.
127
+ #
128
+ class RadioConnection
129
+ # @param socket [UDPSocket]
130
+ # @param host [String]
131
+ # @param port [Integer]
132
+ #
133
+ def initialize(socket, host, port)
134
+ @socket = socket
135
+ @host = host
136
+ @port = port
137
+ end
138
+
139
+
140
+ # Encodes and sends a datagram.
141
+ #
142
+ # @param parts [Array<String>] [group, body]
143
+ #
144
+ def write_message(parts)
145
+ group, body = parts
146
+ datagram = UDP.encode_datagram(group.to_s, body.to_s)
147
+ @socket.send(datagram, 0, @host, @port)
148
+ rescue Errno::ECONNREFUSED, Errno::ENETUNREACH
149
+ # UDP fire-and-forget — drop silently
150
+ end
151
+
152
+ alias send_message write_message
153
+
154
+ # No-op; UDP datagrams are sent immediately.
155
+ def flush
156
+ end
157
+
158
+
159
+ # Whether this connection uses CURVE encryption.
160
+ #
161
+ # @return [Boolean] always false
162
+ def curve?
163
+ false
164
+ end
165
+
166
+
167
+ # Closes the underlying UDP socket.
168
+ def close
169
+ @socket.close rescue nil
170
+ end
171
+ end
172
+
173
+
174
+ # Incoming UDP connection for DISH sockets.
175
+ #
176
+ # Tracks joined groups locally. JOIN/LEAVE commands from the
177
+ # DISH routing strategy are intercepted via #send_command and
178
+ # never transmitted on the wire.
179
+ #
180
+ class DishConnection
181
+ # @param socket [UDPSocket] bound socket
182
+ #
183
+ def initialize(socket)
184
+ @socket = socket
185
+ @groups = Set.new
186
+ end
187
+
188
+
189
+ # Receives one matching datagram, blocking until available.
190
+ #
191
+ # Async-safe: rescues IO::WaitReadable and yields to the
192
+ # fiber scheduler via #wait_readable.
193
+ #
194
+ # @return [Array<String>] [group, body], both binary-frozen
195
+ #
196
+ def receive_message
197
+ loop do
198
+ data, = @socket.recvfrom_nonblock(MAX_DATAGRAM_SIZE)
199
+ parts = UDP.decode_datagram(data)
200
+ next unless parts
201
+ group, body = parts
202
+ next unless @groups.include?(group.b)
203
+ return [group, body]
204
+ rescue IO::WaitReadable
205
+ @socket.wait_readable
206
+ retry
207
+ end
208
+ end
209
+
210
+
211
+ # Handles JOIN/LEAVE commands locally; nothing is sent on the wire.
212
+ #
213
+ # @param cmd [Protocol::ZMTP::Codec::Command]
214
+ #
215
+ def send_command(cmd)
216
+ case cmd.name
217
+ when "JOIN"
218
+ @groups << cmd.data.b.freeze
219
+ when "LEAVE"
220
+ @groups.delete(cmd.data.b)
221
+ end
222
+ end
223
+
224
+
225
+ # Whether this connection uses CURVE encryption.
226
+ #
227
+ # @return [Boolean] always false
228
+ def curve?
229
+ false
230
+ end
231
+
232
+
233
+ # Closes the underlying UDP socket.
234
+ def close
235
+ @socket.close rescue nil
236
+ end
237
+ end
238
+
239
+
240
+ # Bound UDP listener for DISH sockets.
241
+ #
242
+ # Unlike TCP/IPC listeners, there is no accept loop — a single
243
+ # DishConnection is registered directly with the engine, bypassing
244
+ # the ZMTP handshake path.
245
+ #
246
+ class Listener
247
+ # @return [String] bound endpoint
248
+ #
249
+ attr_reader :endpoint
250
+
251
+ # @param endpoint [String]
252
+ # @param socket [UDPSocket]
253
+ # @param engine [Engine]
254
+ #
255
+ def initialize(endpoint, socket, engine)
256
+ @endpoint = endpoint
257
+ @socket = socket
258
+ @engine = engine
259
+ end
260
+
261
+
262
+ # Registers a DishConnection with the engine.
263
+ # The on_accepted block is intentionally ignored — no ZMTP handshake.
264
+ #
265
+ # @param parent_task [Async::Task] (unused)
266
+ #
267
+ def start_accept_loops(parent_task, &_on_accepted)
268
+ conn = DishConnection.new(@socket)
269
+ @engine.connection_ready(conn, endpoint: @endpoint)
270
+ end
271
+
272
+
273
+ # Stops the listener.
274
+ #
275
+ def stop
276
+ @socket.close rescue nil
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
data/lib/omq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OMQ
4
- VERSION = "0.22.1"
4
+ VERSION = "0.24.0"
5
5
  end
data/lib/omq/writable.rb CHANGED
@@ -9,19 +9,25 @@ module OMQ
9
9
  include QueueWritable
10
10
 
11
11
 
12
- EMPTY_PART = "".b.freeze
13
-
14
-
15
12
  # Sends a message.
16
13
  #
14
+ # Caller owns the message parts. Don't mutate them after sending — especially
15
+ # with inproc transport or PUB fan-out, where a single reference can be shared
16
+ # across peers and read later by the send pump.
17
+ #
17
18
  # @param message [String, Array<String>] message parts
18
19
  # @return [self]
19
20
  # @raise [IO::TimeoutError] if write_timeout exceeded
20
21
  #
21
22
  def send(message)
22
- parts = freeze_message(message)
23
+ parts = message.is_a?(Array) ? message : [message]
24
+ raise ArgumentError, "message has no parts" if parts.empty?
23
25
 
24
- Reactor.run timeout: @options.write_timeout do |task|
26
+ if @engine.on_io_thread?
27
+ Reactor.run(timeout: @options.write_timeout) { @engine.enqueue_send(parts) }
28
+ elsif (timeout = @options.write_timeout)
29
+ Async::Task.current.with_timeout(timeout, IO::TimeoutError) { @engine.enqueue_send(parts) }
30
+ else
25
31
  @engine.enqueue_send(parts)
26
32
  end
27
33
 
@@ -48,42 +54,5 @@ module OMQ
48
54
  true
49
55
  end
50
56
 
51
-
52
- private
53
-
54
-
55
- # Converts a message into a frozen array of frozen binary strings.
56
- #
57
- # @param message [String, Array<String>]
58
- # @return [Array<String>] frozen array of frozen binary strings
59
- #
60
- def freeze_message(message)
61
- parts = message.is_a?(Array) ? message : [message]
62
- raise ArgumentError, "message has no parts" if parts.empty?
63
-
64
- all_ready = parts.all? { |p| p.is_a?(String) && p.frozen? && p.encoding == Encoding::BINARY }
65
-
66
- # Already a frozen array of frozen binary strings → return as-is.
67
- return parts if all_ready && parts.frozen?
68
-
69
- # Items are ready; just freeze the outer array.
70
- return parts.freeze if all_ready
71
-
72
- # Items need conversion. Mutate in place when we can.
73
- if parts.frozen?
74
- parts.map { |p| frozen_binary(p) }.freeze
75
- else
76
- parts.map! { |p| frozen_binary(p) }.freeze
77
- end
78
- end
79
-
80
-
81
- def frozen_binary(obj)
82
- return EMPTY_PART if obj.nil?
83
- s = obj.to_s
84
- return s if s.frozen? && s.encoding == Encoding::BINARY
85
- s.b.freeze
86
- end
87
-
88
57
  end
89
58
  end
data/lib/omq.rb CHANGED
@@ -1,74 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # OMQ — pure Ruby ZeroMQ (ZMTP 3.1).
4
- #
5
- # Socket types live directly under OMQ:: for a clean API:
6
- # OMQ::PUSH, OMQ::PULL, OMQ::PUB, OMQ::SUB, etc.
7
- #
8
-
9
3
  require "protocol/zmtp"
10
4
  require "io/stream"
11
5
 
12
- require_relative "omq/version"
13
- require_relative "omq/monitor_event"
14
-
15
- module OMQ
16
- # When OMQ_DEBUG is set, silent rescue clauses print the exception
17
- # to stderr so transport/engine bugs surface immediately.
18
- DEBUG = !!ENV["OMQ_DEBUG"]
19
-
20
-
21
- # Raised when an internal pump task crashes unexpectedly.
22
- # The socket is no longer usable; the original error is available via #cause.
23
- #
24
- class SocketDeadError < RuntimeError
25
- end
26
-
27
-
28
- # Errors raised when a peer disconnects or resets the connection.
29
- # Not frozen at load time — transport plugins append to this before
30
- # the first bind/connect, which freezes both arrays.
31
- CONNECTION_LOST = [
32
- EOFError,
33
- IOError,
34
- Errno::EPIPE,
35
- Errno::ECONNRESET,
36
- Errno::ECONNABORTED,
37
- Errno::ENOTCONN,
38
- IO::Stream::ConnectionResetError,
39
- ]
40
-
41
-
42
- # Errors raised when a peer cannot be reached.
43
- CONNECTION_FAILED = [
44
- Errno::ECONNREFUSED,
45
- Errno::ENOENT,
46
- Errno::ETIMEDOUT,
47
- Errno::EHOSTUNREACH,
48
- Errno::ENETUNREACH,
49
- Errno::EPROTOTYPE, # IPC: existing socket file is SOCK_DGRAM, not SOCK_STREAM
50
- Socket::ResolutionError,
51
- ]
52
-
53
-
54
- # Freezes module-level state so OMQ sockets can be used inside Ractors.
55
- # Call this once before spawning any Ractors that create OMQ sockets.
56
- #
57
- def self.freeze_for_ractors!
58
- CONNECTION_LOST.freeze
59
- CONNECTION_FAILED.freeze
60
- Engine.transports.freeze
61
- Routing.registry.freeze
62
- end
63
- end
64
-
65
-
66
- # Transport
67
- require_relative "omq/transport/inproc"
68
- require_relative "omq/transport/tcp"
69
- require_relative "omq/transport/ipc"
70
6
 
71
7
  # Core
8
+ require_relative "omq/version"
9
+ require_relative "omq/constants"
72
10
  require_relative "omq/reactor"
73
11
  require_relative "omq/options"
74
12
  require_relative "omq/routing"
@@ -86,6 +24,13 @@ require_relative "omq/routing/xsub"
86
24
  require_relative "omq/routing/push"
87
25
  require_relative "omq/routing/pull"
88
26
  require_relative "omq/engine"
27
+
28
+ # Transport
29
+ require_relative "omq/transport/inproc"
30
+ require_relative "omq/transport/tcp"
31
+ require_relative "omq/transport/ipc"
32
+
33
+ # Mixins
89
34
  require_relative "omq/queue_interface"
90
35
  require_relative "omq/readable"
91
36
  require_relative "omq/writable"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.1
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -65,6 +65,9 @@ files:
65
65
  - LICENSE
66
66
  - README.md
67
67
  - lib/omq.rb
68
+ - lib/omq/channel.rb
69
+ - lib/omq/client_server.rb
70
+ - lib/omq/constants.rb
68
71
  - lib/omq/drop_queue.rb
69
72
  - lib/omq/engine.rb
70
73
  - lib/omq/engine/connection_lifecycle.rb
@@ -73,37 +76,48 @@ files:
73
76
  - lib/omq/engine/reconnect.rb
74
77
  - lib/omq/engine/recv_pump.rb
75
78
  - lib/omq/engine/socket_lifecycle.rb
76
- - lib/omq/monitor_event.rb
77
79
  - lib/omq/options.rb
78
80
  - lib/omq/pair.rb
81
+ - lib/omq/peer.rb
79
82
  - lib/omq/pub_sub.rb
80
83
  - lib/omq/push_pull.rb
81
84
  - lib/omq/queue_interface.rb
85
+ - lib/omq/radio_dish.rb
82
86
  - lib/omq/reactor.rb
83
87
  - lib/omq/readable.rb
84
88
  - lib/omq/req_rep.rb
85
89
  - lib/omq/router_dealer.rb
86
90
  - lib/omq/routing.rb
91
+ - lib/omq/routing/channel.rb
92
+ - lib/omq/routing/client.rb
87
93
  - lib/omq/routing/conn_send_pump.rb
88
94
  - lib/omq/routing/dealer.rb
95
+ - lib/omq/routing/dish.rb
89
96
  - lib/omq/routing/fan_out.rb
97
+ - lib/omq/routing/gather.rb
90
98
  - lib/omq/routing/pair.rb
99
+ - lib/omq/routing/peer.rb
91
100
  - lib/omq/routing/pub.rb
92
101
  - lib/omq/routing/pull.rb
93
102
  - lib/omq/routing/push.rb
103
+ - lib/omq/routing/radio.rb
94
104
  - lib/omq/routing/rep.rb
95
105
  - lib/omq/routing/req.rb
96
106
  - lib/omq/routing/round_robin.rb
97
107
  - lib/omq/routing/router.rb
108
+ - lib/omq/routing/scatter.rb
109
+ - lib/omq/routing/server.rb
98
110
  - lib/omq/routing/sub.rb
99
111
  - lib/omq/routing/xpub.rb
100
112
  - lib/omq/routing/xsub.rb
113
+ - lib/omq/scatter_gather.rb
101
114
  - lib/omq/single_frame.rb
102
115
  - lib/omq/socket.rb
103
116
  - lib/omq/transport/inproc.rb
104
- - lib/omq/transport/inproc/direct_pipe.rb
117
+ - lib/omq/transport/inproc/pipe.rb
105
118
  - lib/omq/transport/ipc.rb
106
119
  - lib/omq/transport/tcp.rb
120
+ - lib/omq/transport/udp.rb
107
121
  - lib/omq/version.rb
108
122
  - lib/omq/writable.rb
109
123
  homepage: https://github.com/zeromq/omq
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module OMQ
4
- # Lifecycle event emitted by {Socket#monitor}.
5
- #
6
- # @!attribute [r] type
7
- # @return [Symbol] event type (:listening, :connected, :disconnected, etc.)
8
- # @!attribute [r] endpoint
9
- # @return [String, nil] the endpoint involved
10
- # @!attribute [r] detail
11
- # @return [Hash, nil] extra context (e.g. { error: }, { interval: }, etc.)
12
- #
13
- MonitorEvent = Data.define(:type, :endpoint, :detail) do
14
- def initialize(type:, endpoint: nil, detail: nil) = super
15
- end
16
- end