omq-draft 0.1.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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OMQ
6
+ module Routing
7
+ # PEER socket routing: bidirectional multi-peer with auto-generated
8
+ # 4-byte routing IDs.
9
+ #
10
+ # Prepends routing ID on receive. Strips routing ID on send and
11
+ # routes to the identified connection.
12
+ #
13
+ class Peer
14
+ # @param engine [Engine]
15
+ #
16
+ def initialize(engine)
17
+ @engine = engine
18
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
19
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
20
+ @connections_by_routing_id = {}
21
+ @routing_id_by_connection = {}
22
+ @tasks = []
23
+ @send_pump_started = false
24
+ @send_pump_idle = true
25
+ @written = Set.new
26
+ end
27
+
28
+ # @return [Async::LimitedQueue]
29
+ #
30
+ attr_reader :recv_queue, :send_queue
31
+
32
+ # @param connection [Connection]
33
+ #
34
+ def connection_added(connection)
35
+ routing_id = SecureRandom.bytes(4)
36
+ @connections_by_routing_id[routing_id] = connection
37
+ @routing_id_by_connection[connection] = routing_id
38
+
39
+ task = @engine.start_recv_pump(connection, @recv_queue) do |msg|
40
+ [routing_id, *msg]
41
+ end
42
+ @tasks << task if task
43
+
44
+ start_send_pump unless @send_pump_started
45
+ end
46
+
47
+ # @param connection [Connection]
48
+ #
49
+ def connection_removed(connection)
50
+ routing_id = @routing_id_by_connection.delete(connection)
51
+ @connections_by_routing_id.delete(routing_id) if routing_id
52
+ end
53
+
54
+ # @param parts [Array<String>]
55
+ #
56
+ def enqueue(parts)
57
+ @send_queue.enqueue(parts)
58
+ end
59
+
60
+ def stop
61
+ @tasks.each(&:stop)
62
+ @tasks.clear
63
+ end
64
+
65
+ def send_pump_idle? = @send_pump_idle
66
+
67
+ private
68
+
69
+ def start_send_pump
70
+ @send_pump_started = true
71
+ @tasks << @engine.spawn_pump_task(annotation: "send pump") do
72
+ loop do
73
+ @send_pump_idle = true
74
+ batch = [@send_queue.dequeue]
75
+ @send_pump_idle = false
76
+ Routing.drain_send_queue(@send_queue, batch)
77
+
78
+ @written.clear
79
+ batch.each do |parts|
80
+ routing_id = parts.first
81
+ conn = @connections_by_routing_id[routing_id]
82
+ next unless conn # silently drop if peer gone
83
+ begin
84
+ conn.write_message(parts[1..])
85
+ @written << conn
86
+ rescue *CONNECTION_LOST
87
+ # will be cleaned up
88
+ end
89
+ end
90
+
91
+ @written.each do |conn|
92
+ conn.flush
93
+ rescue *CONNECTION_LOST
94
+ # will be cleaned up
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # RADIO socket routing: group-based fan-out to DISH peers.
6
+ #
7
+ # Like PUB/FanOut but with exact group matching and JOIN/LEAVE
8
+ # commands instead of SUBSCRIBE/CANCEL.
9
+ #
10
+ # Messages are sent as two frames on the wire:
11
+ # group (MORE=1) + body (MORE=0)
12
+ #
13
+ class Radio
14
+ # Sentinel used for UDP connections that have no group filter:
15
+ # any group is considered a match.
16
+ #
17
+ ANY_GROUPS = Object.new.tap { |o| o.define_singleton_method(:include?) { |_| true } }.freeze
18
+
19
+ # @param engine [Engine]
20
+ #
21
+ def initialize(engine)
22
+ @engine = engine
23
+ @connections = []
24
+ @groups = {} # connection => Set of joined groups (or ANY_GROUPS for UDP)
25
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
26
+ @on_mute = engine.options.on_mute
27
+ @send_pump_started = false
28
+ @conflate = engine.options.conflate
29
+ @tasks = []
30
+ @written = Set.new
31
+ @latest = {} if @conflate
32
+ end
33
+
34
+ # @return [Async::LimitedQueue]
35
+ #
36
+ attr_reader :send_queue
37
+
38
+ # RADIO is write-only.
39
+ #
40
+ def recv_queue
41
+ raise "RADIO sockets cannot receive"
42
+ end
43
+
44
+ # @param connection [Connection]
45
+ #
46
+ def connection_added(connection)
47
+ @connections << connection
48
+ if connection.respond_to?(:read_frame)
49
+ @groups[connection] = Set.new
50
+ start_group_listener(connection)
51
+ else
52
+ @groups[connection] = ANY_GROUPS # UDP: fan-out to all groups
53
+ end
54
+ start_send_pump unless @send_pump_started
55
+ end
56
+
57
+ # @param connection [Connection]
58
+ #
59
+ def connection_removed(connection)
60
+ @connections.delete(connection)
61
+ @groups.delete(connection)
62
+ end
63
+
64
+ # Enqueues a message for sending.
65
+ #
66
+ # @param parts [Array<String>] [group, body]
67
+ #
68
+ def enqueue(parts)
69
+ @send_queue.enqueue(parts)
70
+ end
71
+
72
+ def stop
73
+ @tasks.each(&:stop)
74
+ @tasks.clear
75
+ end
76
+
77
+ private
78
+
79
+ def muted?(conn)
80
+ return false if @on_mute == :block
81
+ q = conn.direct_recv_queue if conn.respond_to?(:direct_recv_queue)
82
+ q&.respond_to?(:limited?) && q.limited?
83
+ end
84
+
85
+ def start_send_pump
86
+ @send_pump_started = true
87
+ @tasks << @engine.spawn_pump_task(annotation: "send pump") do
88
+ loop do
89
+ @send_pump_idle = true
90
+ batch = [@send_queue.dequeue]
91
+ @send_pump_idle = false
92
+ Routing.drain_send_queue(@send_queue, batch)
93
+
94
+ @written.clear
95
+
96
+ if @conflate
97
+ # Keep only the last matching message per connection.
98
+ @latest.clear
99
+ batch.each do |parts|
100
+ group = parts[0]
101
+ body = parts[1] || EMPTY_BINARY
102
+ @connections.each do |conn|
103
+ next unless @groups[conn]&.include?(group)
104
+ @latest[conn] = [group, body]
105
+ end
106
+ end
107
+ @latest.each do |conn, msg|
108
+ next if muted?(conn)
109
+ begin
110
+ conn.write_message(msg)
111
+ @written << conn
112
+ rescue *CONNECTION_LOST
113
+ end
114
+ end
115
+ else
116
+ batch.each do |parts|
117
+ group = parts[0]
118
+ body = parts[1] || EMPTY_BINARY
119
+ msg = [group, body]
120
+ wire_bytes = nil
121
+
122
+ @connections.each do |conn|
123
+ next unless @groups[conn]&.include?(group)
124
+ next if muted?(conn)
125
+ begin
126
+ if conn.respond_to?(:curve?) && conn.curve?
127
+ conn.write_message(msg)
128
+ elsif conn.respond_to?(:write_wire)
129
+ wire_bytes ||= Protocol::ZMTP::Codec::Frame.encode_message(msg)
130
+ conn.write_wire(wire_bytes)
131
+ else
132
+ conn.write_message(msg)
133
+ end
134
+ @written << conn
135
+ rescue *CONNECTION_LOST
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ @written.each do |conn|
142
+ conn.flush
143
+ rescue *CONNECTION_LOST
144
+ end
145
+ end
146
+ end
147
+ end
148
+
149
+
150
+ def start_group_listener(conn)
151
+ @tasks << @engine.spawn_pump_task(annotation: "group listener") do
152
+ loop do
153
+ frame = conn.read_frame
154
+ next unless frame.command?
155
+ cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
156
+ case cmd.name
157
+ when "JOIN" then @groups[conn]&.add(cmd.data)
158
+ when "LEAVE" then @groups[conn]&.delete(cmd.data)
159
+ end
160
+ end
161
+ rescue *CONNECTION_LOST
162
+ @engine.connection_lost(conn)
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # SCATTER socket routing: round-robin send to GATHER peers.
6
+ #
7
+ class Scatter
8
+ include RoundRobin
9
+
10
+ # @param engine [Engine]
11
+ #
12
+ def initialize(engine)
13
+ @engine = engine
14
+ @tasks = []
15
+ init_round_robin(engine)
16
+ end
17
+
18
+
19
+ # @return [Async::LimitedQueue]
20
+ #
21
+ attr_reader :send_queue
22
+
23
+
24
+ # SCATTER is write-only.
25
+ #
26
+ def recv_queue
27
+ raise "SCATTER sockets cannot receive"
28
+ end
29
+
30
+
31
+ # @param connection [Connection]
32
+ #
33
+ def connection_added(connection)
34
+ @connections << connection
35
+ signal_connection_available
36
+ update_direct_pipe
37
+ start_send_pump unless @send_pump_started
38
+ start_reaper(connection)
39
+ end
40
+
41
+
42
+ # @param connection [Connection]
43
+ #
44
+ def connection_removed(connection)
45
+ @connections.delete(connection)
46
+ update_direct_pipe
47
+ end
48
+
49
+
50
+ # @param parts [Array<String>]
51
+ #
52
+ def enqueue(parts)
53
+ enqueue_round_robin(parts)
54
+ end
55
+
56
+
57
+ # Stops all background tasks (send pump, reapers).
58
+ #
59
+ def stop
60
+ @tasks.each(&:stop)
61
+ @tasks.clear
62
+ end
63
+
64
+ private
65
+
66
+
67
+ # Detects peer disconnection on write-only sockets by
68
+ # blocking on a receive that only returns on disconnect.
69
+ #
70
+ # @param conn [Connection]
71
+ #
72
+ def start_reaper(conn)
73
+ return if conn.is_a?(Transport::Inproc::DirectPipe)
74
+ @tasks << @engine.spawn_pump_task(annotation: "reaper") do
75
+ conn.receive_message
76
+ rescue *CONNECTION_LOST
77
+ @engine.connection_lost(conn)
78
+ end
79
+ end
80
+
81
+
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OMQ
6
+ module Routing
7
+ # SERVER socket routing: identity-based routing with auto-generated
8
+ # 4-byte routing IDs.
9
+ #
10
+ # Prepends routing ID on receive. Strips routing ID on send and
11
+ # routes to the identified connection.
12
+ #
13
+ class Server
14
+ # @param engine [Engine]
15
+ #
16
+ def initialize(engine)
17
+ @engine = engine
18
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
19
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
20
+ @connections_by_routing_id = {}
21
+ @routing_id_by_connection = {}
22
+ @tasks = []
23
+ @send_pump_started = false
24
+ @send_pump_idle = true
25
+ @written = Set.new
26
+ end
27
+
28
+ # @return [Async::LimitedQueue]
29
+ #
30
+ attr_reader :recv_queue, :send_queue
31
+
32
+ # @param connection [Connection]
33
+ #
34
+ def connection_added(connection)
35
+ routing_id = SecureRandom.bytes(4)
36
+ @connections_by_routing_id[routing_id] = connection
37
+ @routing_id_by_connection[connection] = routing_id
38
+
39
+ task = @engine.start_recv_pump(connection, @recv_queue) do |msg|
40
+ [routing_id, *msg]
41
+ end
42
+ @tasks << task if task
43
+
44
+ start_send_pump unless @send_pump_started
45
+ end
46
+
47
+ # @param connection [Connection]
48
+ #
49
+ def connection_removed(connection)
50
+ routing_id = @routing_id_by_connection.delete(connection)
51
+ @connections_by_routing_id.delete(routing_id) if routing_id
52
+ end
53
+
54
+ # @param parts [Array<String>]
55
+ #
56
+ def enqueue(parts)
57
+ @send_queue.enqueue(parts)
58
+ end
59
+
60
+ def stop
61
+ @tasks.each(&:stop)
62
+ @tasks.clear
63
+ end
64
+
65
+ def send_pump_idle? = @send_pump_idle
66
+
67
+ private
68
+
69
+ def start_send_pump
70
+ @send_pump_started = true
71
+ @tasks << @engine.spawn_pump_task(annotation: "send pump") do
72
+ loop do
73
+ @send_pump_idle = true
74
+ batch = [@send_queue.dequeue]
75
+ @send_pump_idle = false
76
+ Routing.drain_send_queue(@send_queue, batch)
77
+
78
+ @written.clear
79
+ batch.each do |parts|
80
+ routing_id = parts.first
81
+ conn = @connections_by_routing_id[routing_id]
82
+ next unless conn # silently drop if peer gone
83
+ begin
84
+ conn.write_message(parts[1..])
85
+ @written << conn
86
+ rescue *CONNECTION_LOST
87
+ # will be cleaned up
88
+ end
89
+ end
90
+
91
+ @written.each do |conn|
92
+ conn.flush
93
+ rescue *CONNECTION_LOST
94
+ # will be cleaned up
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class SCATTER < Socket
5
+ include Writable
6
+ include SingleFrame
7
+
8
+ def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil, backend: nil)
9
+ _init_engine(:SCATTER, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout, backend: backend)
10
+ _attach(endpoints, default: :connect)
11
+ end
12
+ end
13
+
14
+ class GATHER < Socket
15
+ include Readable
16
+ include SingleFrame
17
+
18
+ def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil, backend: nil)
19
+ _init_engine(:GATHER, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout, backend: backend)
20
+ _attach(endpoints, default: :bind)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ # Mixin that rejects multipart messages.
5
+ #
6
+ # All draft socket types (CLIENT, SERVER, RADIO, DISH, SCATTER,
7
+ # GATHER, PEER, CHANNEL) require single-frame messages for
8
+ # thread-safe atomic operations.
9
+ #
10
+ module SingleFrame
11
+ def send(message)
12
+ if message.is_a?(Array) && message.size > 1
13
+ raise ArgumentError, "#{self.class} does not support multipart messages"
14
+ end
15
+ super
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,214 @@
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
+ MAX_DATAGRAM_SIZE = 65507
19
+
20
+ class << self
21
+ # Binds a UDP server for a DISH socket.
22
+ #
23
+ # @param endpoint [String] e.g. "udp://*:5555" or "udp://127.0.0.1:5555"
24
+ # @param engine [Engine]
25
+ # @return [Listener]
26
+ #
27
+ def bind(endpoint, engine)
28
+ host, port = parse_endpoint(endpoint)
29
+ host = "0.0.0.0" if host == "*"
30
+ socket = UDPSocket.new
31
+ socket.bind(host, port)
32
+ Listener.new(endpoint, socket, engine)
33
+ end
34
+
35
+ # Connects a UDP sender for a RADIO socket.
36
+ #
37
+ # @param endpoint [String] e.g. "udp://127.0.0.1:5555"
38
+ # @param engine [Engine]
39
+ # @return [void]
40
+ #
41
+ def connect(endpoint, engine)
42
+ host, port = parse_endpoint(endpoint)
43
+ socket = UDPSocket.new
44
+ conn = RadioConnection.new(socket, host, port)
45
+ engine.connection_ready(conn, endpoint: endpoint)
46
+ end
47
+
48
+ # @param endpoint [String]
49
+ # @return [Array(String, Integer)]
50
+ #
51
+ def parse_endpoint(endpoint)
52
+ uri = URI.parse(endpoint)
53
+ [uri.hostname, uri.port]
54
+ end
55
+
56
+ # Encodes a group + body into a UDP datagram.
57
+ #
58
+ # @param group [String]
59
+ # @param body [String]
60
+ # @return [String] binary datagram
61
+ #
62
+ def encode_datagram(group, body)
63
+ g = group.b
64
+ b = body.b
65
+ [0x01, g.bytesize].pack("CC") + g + b
66
+ end
67
+
68
+ # Decodes a UDP datagram into [group, body].
69
+ #
70
+ # @param data [String] raw datagram bytes
71
+ # @return [Array(String, String), nil] nil if malformed
72
+ #
73
+ def decode_datagram(data)
74
+ return nil if data.bytesize < 2
75
+ return nil unless data.getbyte(0) & 0x01 == 0x01
76
+ gs = data.getbyte(1)
77
+ return nil if data.bytesize < 2 + gs
78
+ group = data.byteslice(2, gs)
79
+ body = data.byteslice(2 + gs, data.bytesize - 2 - gs)
80
+ [group, body]
81
+ end
82
+ end
83
+
84
+ # Outgoing UDP connection for RADIO sockets.
85
+ #
86
+ # Intentionally does not implement #read_frame — this signals
87
+ # Routing::Radio to skip the group listener and use ANY_GROUPS.
88
+ #
89
+ class RadioConnection
90
+ # @param socket [UDPSocket]
91
+ # @param host [String]
92
+ # @param port [Integer]
93
+ #
94
+ def initialize(socket, host, port)
95
+ @socket = socket
96
+ @host = host
97
+ @port = port
98
+ end
99
+
100
+ # Encodes and sends a datagram.
101
+ #
102
+ # @param parts [Array<String>] [group, body]
103
+ #
104
+ def write_message(parts)
105
+ group, body = parts
106
+ datagram = UDP.encode_datagram(group.to_s, body.to_s)
107
+ @socket.send(datagram, 0, @host, @port)
108
+ rescue Errno::ECONNREFUSED, Errno::ENETUNREACH
109
+ # UDP fire-and-forget — drop silently
110
+ end
111
+
112
+ alias send_message write_message
113
+
114
+ def flush; end
115
+ def curve?; false; end
116
+
117
+ def close
118
+ @socket.close rescue nil
119
+ end
120
+ end
121
+
122
+ # Incoming UDP connection for DISH sockets.
123
+ #
124
+ # Tracks joined groups locally. JOIN/LEAVE commands from the
125
+ # DISH routing strategy are intercepted via #send_command and
126
+ # never transmitted on the wire.
127
+ #
128
+ class DishConnection
129
+ # @param socket [UDPSocket] bound socket
130
+ #
131
+ def initialize(socket)
132
+ @socket = socket
133
+ @groups = Set.new
134
+ end
135
+
136
+ # Receives one matching datagram, blocking until available.
137
+ #
138
+ # Async-safe: rescues IO::WaitReadable and yields to the
139
+ # fiber scheduler via #wait_readable.
140
+ #
141
+ # @return [Array<String>] [group, body], both binary-frozen
142
+ #
143
+ def receive_message
144
+ loop do
145
+ data, = @socket.recvfrom_nonblock(MAX_DATAGRAM_SIZE)
146
+ parts = UDP.decode_datagram(data)
147
+ next unless parts
148
+ group, body = parts
149
+ next unless @groups.include?(group.b)
150
+ return [group.b.freeze, body.b.freeze]
151
+ rescue IO::WaitReadable
152
+ @socket.wait_readable
153
+ retry
154
+ end
155
+ end
156
+
157
+ # Handles JOIN/LEAVE commands locally; nothing is sent on the wire.
158
+ #
159
+ # @param cmd [Protocol::ZMTP::Codec::Command]
160
+ #
161
+ def send_command(cmd)
162
+ case cmd.name
163
+ when "JOIN" then @groups << cmd.data.b.freeze
164
+ when "LEAVE" then @groups.delete(cmd.data.b)
165
+ end
166
+ end
167
+
168
+ def curve?; false; end
169
+
170
+ def close
171
+ @socket.close rescue nil
172
+ end
173
+ end
174
+
175
+ # Bound UDP listener for DISH sockets.
176
+ #
177
+ # Unlike TCP/IPC listeners, there is no accept loop — a single
178
+ # DishConnection is registered directly with the engine, bypassing
179
+ # the ZMTP handshake path.
180
+ #
181
+ class Listener
182
+ # @return [String] bound endpoint
183
+ #
184
+ attr_reader :endpoint
185
+
186
+ # @param endpoint [String]
187
+ # @param socket [UDPSocket]
188
+ # @param engine [Engine]
189
+ #
190
+ def initialize(endpoint, socket, engine)
191
+ @endpoint = endpoint
192
+ @socket = socket
193
+ @engine = engine
194
+ end
195
+
196
+ # Registers a DishConnection with the engine.
197
+ # The on_accepted block is intentionally ignored — no ZMTP handshake.
198
+ #
199
+ # @param parent_task [Async::Task] (unused)
200
+ #
201
+ def start_accept_loops(parent_task, &_on_accepted)
202
+ conn = DishConnection.new(@socket)
203
+ @engine.connection_ready(conn, endpoint: @endpoint)
204
+ end
205
+
206
+ # Stops the listener.
207
+ #
208
+ def stop
209
+ @socket.close rescue nil
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end