omq 0.22.1 → 0.23.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +115 -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 +11 -3
  8. data/lib/omq/engine/heartbeat.rb +2 -3
  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 +3 -4
  12. data/lib/omq/engine/socket_lifecycle.rb +26 -9
  13. data/lib/omq/engine.rb +196 -85
  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/routing/channel.rb +110 -0
  19. data/lib/omq/routing/client.rb +70 -0
  20. data/lib/omq/routing/conn_send_pump.rb +4 -7
  21. data/lib/omq/routing/dealer.rb +1 -13
  22. data/lib/omq/routing/dish.rb +94 -0
  23. data/lib/omq/routing/fan_out.rb +5 -9
  24. data/lib/omq/routing/gather.rb +60 -0
  25. data/lib/omq/routing/pair.rb +3 -22
  26. data/lib/omq/routing/peer.rb +95 -0
  27. data/lib/omq/routing/pub.rb +0 -11
  28. data/lib/omq/routing/pull.rb +1 -13
  29. data/lib/omq/routing/push.rb +1 -10
  30. data/lib/omq/routing/radio.rb +187 -0
  31. data/lib/omq/routing/rep.rb +3 -17
  32. data/lib/omq/routing/req.rb +4 -16
  33. data/lib/omq/routing/round_robin.rb +11 -15
  34. data/lib/omq/routing/router.rb +3 -17
  35. data/lib/omq/routing/scatter.rb +77 -0
  36. data/lib/omq/routing/server.rb +90 -0
  37. data/lib/omq/routing/sub.rb +1 -13
  38. data/lib/omq/routing/xpub.rb +0 -11
  39. data/lib/omq/routing/xsub.rb +6 -23
  40. data/lib/omq/scatter_gather.rb +56 -0
  41. data/lib/omq/socket.rb +8 -23
  42. data/lib/omq/transport/inproc/direct_pipe.rb +17 -15
  43. data/lib/omq/transport/inproc.rb +11 -3
  44. data/lib/omq/transport/ipc.rb +41 -13
  45. data/lib/omq/transport/tcp.rb +59 -23
  46. data/lib/omq/transport/udp.rb +281 -0
  47. data/lib/omq/version.rb +1 -1
  48. data/lib/omq.rb +9 -64
  49. metadata +16 -2
  50. data/lib/omq/monitor_event.rb +0 -16
@@ -0,0 +1,95 @@
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
+ # @return [Async::LimitedQueue]
15
+ #
16
+ attr_reader :recv_queue
17
+
18
+
19
+ # @return [Hash{String => Connection}] routing_id → connection
20
+ #
21
+ attr_reader :connections_by_routing_id
22
+
23
+
24
+ # @param engine [Engine]
25
+ #
26
+ def initialize(engine)
27
+ @engine = engine
28
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
29
+ @connections_by_routing_id = {}
30
+ @routing_id_by_connection = {}
31
+ @conn_queues = {}
32
+ end
33
+
34
+
35
+ # Dequeues the next received message. Blocks until one is available.
36
+ #
37
+ # @return [Array<String>, nil]
38
+ #
39
+ def dequeue_recv
40
+ @recv_queue.dequeue
41
+ end
42
+
43
+
44
+ # Wakes a blocked {#dequeue_recv} with a nil sentinel.
45
+ #
46
+ # @return [void]
47
+ #
48
+ def unblock_recv
49
+ @recv_queue.enqueue(nil)
50
+ end
51
+
52
+
53
+ # @param connection [Connection]
54
+ #
55
+ def connection_added(connection)
56
+ routing_id = SecureRandom.bytes(4)
57
+ @connections_by_routing_id[routing_id] = connection
58
+ @routing_id_by_connection[connection] = routing_id
59
+
60
+ @engine.start_recv_pump(connection, @recv_queue) { |msg| [routing_id, *msg] }
61
+
62
+ q = Routing.build_queue(@engine.options.send_hwm, :block)
63
+ @conn_queues[connection] = q
64
+ ConnSendPump.start(@engine, connection, q)
65
+ end
66
+
67
+
68
+ # @param connection [Connection]
69
+ #
70
+ def connection_removed(connection)
71
+ routing_id = @routing_id_by_connection.delete(connection)
72
+ @connections_by_routing_id.delete(routing_id) if routing_id
73
+ @conn_queues.delete(connection)
74
+ end
75
+
76
+
77
+ # @param parts [Array<String>]
78
+ #
79
+ def enqueue(parts)
80
+ routing_id = parts.first
81
+ conn = @connections_by_routing_id[routing_id]
82
+ return unless conn
83
+ @conn_queues[conn]&.enqueue(parts[1..])
84
+ end
85
+
86
+
87
+ # True when all per-connection send queues are empty.
88
+ #
89
+ def send_queues_drained?
90
+ @conn_queues.values.all?(&:empty?)
91
+ end
92
+
93
+ end
94
+ end
95
+ end
@@ -15,7 +15,6 @@ module OMQ
15
15
  #
16
16
  def initialize(engine)
17
17
  @engine = engine
18
- @tasks = []
19
18
  init_fan_out(engine)
20
19
  end
21
20
 
@@ -62,16 +61,6 @@ module OMQ
62
61
  fan_out_enqueue(parts)
63
62
  end
64
63
 
65
-
66
- # Stops all background tasks.
67
- #
68
- # @return [void]
69
- #
70
- def stop
71
- @tasks.each(&:stop)
72
- @tasks.clear
73
- end
74
-
75
64
  end
76
65
  end
77
66
  end
@@ -10,7 +10,6 @@ module OMQ
10
10
  def initialize(engine)
11
11
  @engine = engine
12
12
  @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
13
- @tasks = []
14
13
  end
15
14
 
16
15
 
@@ -42,8 +41,7 @@ module OMQ
42
41
  # @param connection [Connection]
43
42
  #
44
43
  def connection_added(connection)
45
- task = @engine.start_recv_pump(connection, @recv_queue)
46
- @tasks << task if task
44
+ @engine.start_recv_pump(connection, @recv_queue)
47
45
  end
48
46
 
49
47
 
@@ -60,16 +58,6 @@ module OMQ
60
58
  raise "PULL sockets cannot send"
61
59
  end
62
60
 
63
-
64
- # Stops all background tasks.
65
- #
66
- # @return [void]
67
- #
68
- def stop
69
- @tasks.each(&:stop)
70
- @tasks.clear
71
- end
72
-
73
61
  end
74
62
  end
75
63
  end
@@ -12,7 +12,6 @@ module OMQ
12
12
  #
13
13
  def initialize(engine)
14
14
  @engine = engine
15
- @tasks = []
16
15
  init_round_robin(engine)
17
16
  end
18
17
 
@@ -57,14 +56,6 @@ module OMQ
57
56
  end
58
57
 
59
58
 
60
- # Stops all background tasks (send pumps, reapers).
61
- #
62
- def stop
63
- @tasks.each(&:stop)
64
- @tasks.clear
65
- end
66
-
67
-
68
59
  private
69
60
 
70
61
 
@@ -74,7 +65,7 @@ module OMQ
74
65
  #
75
66
  def start_reaper(conn)
76
67
  return if conn.is_a?(Transport::Inproc::DirectPipe)
77
- @tasks << @engine.spawn_conn_pump_task(conn, annotation: "reaper") do
68
+ @engine.spawn_conn_pump_task(conn, annotation: "reaper") do
78
69
  conn.receive_message # blocks until peer disconnects; then exits
79
70
  end
80
71
  end
@@ -0,0 +1,187 @@
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
+
20
+ # @return [Async::LimitedQueue]
21
+ #
22
+ attr_reader :send_queue
23
+
24
+
25
+ # @param engine [Engine]
26
+ #
27
+ def initialize(engine)
28
+ @engine = engine
29
+ @connections = []
30
+ @groups = {} # connection => Set of joined groups (or ANY_GROUPS for UDP)
31
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
32
+ @on_mute = engine.options.on_mute
33
+ @send_pump_started = false
34
+ @conflate = engine.options.conflate
35
+ @written = Set.new
36
+ @latest = {} if @conflate
37
+ end
38
+
39
+
40
+ # RADIO is write-only.
41
+ #
42
+ def recv_queue
43
+ raise "RADIO sockets cannot receive"
44
+ end
45
+
46
+
47
+ # No-op; RADIO has no recv queue to unblock.
48
+ #
49
+ def unblock_recv
50
+ end
51
+
52
+
53
+ # @param connection [Connection]
54
+ #
55
+ def connection_added(connection)
56
+ @connections << connection
57
+ if connection.respond_to?(:read_frame)
58
+ @groups[connection] = Set.new
59
+ start_group_listener(connection)
60
+ else
61
+ @groups[connection] = ANY_GROUPS # UDP: fan-out to all groups
62
+ end
63
+ start_send_pump unless @send_pump_started
64
+ end
65
+
66
+
67
+ # @param connection [Connection]
68
+ #
69
+ def connection_removed(connection)
70
+ @connections.delete(connection)
71
+ @groups.delete(connection)
72
+ end
73
+
74
+
75
+ # Enqueues a message for sending.
76
+ #
77
+ # @param parts [Array<String>] [group, body]
78
+ #
79
+ def enqueue(parts)
80
+ @send_queue.enqueue(parts)
81
+ end
82
+
83
+
84
+ # True when the send queue is empty.
85
+ #
86
+ def send_queues_drained?
87
+ @send_queue.empty?
88
+ end
89
+
90
+
91
+ private
92
+
93
+
94
+ def muted?(conn)
95
+ return false if @on_mute == :block
96
+ q = conn.direct_recv_queue if conn.respond_to?(:direct_recv_queue)
97
+ q&.respond_to?(:limited?) && q.limited?
98
+ end
99
+
100
+
101
+ def start_send_pump
102
+ @send_pump_started = true
103
+ @engine.spawn_pump_task(annotation: "send pump", parent: @engine.barrier) do
104
+ batch = []
105
+
106
+ loop do
107
+ @send_pump_idle = true
108
+ Routing.dequeue_batch(@send_queue, batch)
109
+ @send_pump_idle = false
110
+
111
+ @written.clear
112
+
113
+ if @conflate
114
+ # Keep only the last matching message per connection.
115
+ @latest.clear
116
+ batch.each do |parts|
117
+ group = parts[0]
118
+ body = parts[1] || EMPTY_BINARY
119
+ @connections.each do |conn|
120
+ next unless @groups[conn]&.include?(group)
121
+ @latest[conn] = [group, body]
122
+ end
123
+ end
124
+ @latest.each do |conn, msg|
125
+ next if muted?(conn)
126
+ begin
127
+ conn.write_message(msg)
128
+ @written << conn
129
+ rescue *CONNECTION_LOST
130
+ end
131
+ end
132
+ else
133
+ batch.each do |parts|
134
+ group = parts[0]
135
+ body = parts[1] || EMPTY_BINARY
136
+ msg = [group, body]
137
+ wire_bytes = nil
138
+
139
+ @connections.each do |conn|
140
+ next unless @groups[conn]&.include?(group)
141
+ next if muted?(conn)
142
+ begin
143
+ if conn.respond_to?(:curve?) && conn.curve?
144
+ conn.write_message(msg)
145
+ elsif conn.respond_to?(:write_wire)
146
+ wire_bytes ||= Protocol::ZMTP::Codec::Frame.encode_message(msg)
147
+ conn.write_wire(wire_bytes)
148
+ else
149
+ conn.write_message(msg)
150
+ end
151
+ @written << conn
152
+ rescue *CONNECTION_LOST
153
+ end
154
+ end
155
+ end
156
+ end
157
+
158
+ @written.each do |conn|
159
+ conn.flush
160
+ rescue *CONNECTION_LOST
161
+ end
162
+
163
+ batch.clear
164
+ end
165
+ end
166
+ end
167
+
168
+
169
+ def start_group_listener(conn)
170
+ @engine.spawn_conn_pump_task(conn, annotation: "group listener") do
171
+ loop do
172
+ frame = conn.read_frame
173
+ next unless frame.command?
174
+ cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
175
+ case cmd.name
176
+ when "JOIN"
177
+ @groups[conn]&.add(cmd.data)
178
+ when "LEAVE"
179
+ @groups[conn]&.delete(cmd.data)
180
+ end
181
+ end
182
+ end
183
+ end
184
+
185
+ end
186
+ end
187
+ end
@@ -23,9 +23,7 @@ module OMQ
23
23
  @engine = engine
24
24
  @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
25
25
  @pending_replies = []
26
- @conn_queues = {} # connection => per-connection send queue
27
- @conn_send_tasks = {} # connection => send pump task
28
- @tasks = []
26
+ @conn_queues = {}
29
27
  end
30
28
 
31
29
 
@@ -50,7 +48,7 @@ module OMQ
50
48
  # @param connection [Connection]
51
49
  #
52
50
  def connection_added(connection)
53
- task = @engine.start_recv_pump(connection, @recv_queue) do |msg|
51
+ @engine.start_recv_pump(connection, @recv_queue) do |msg|
54
52
  delimiter = msg.index { |p| p.empty? } || msg.size
55
53
  envelope = msg[0, delimiter]
56
54
  body = msg[(delimiter + 1)..] || []
@@ -58,11 +56,10 @@ module OMQ
58
56
  @pending_replies << [connection, envelope]
59
57
  body
60
58
  end
61
- @tasks << task if task
62
59
 
63
60
  q = Routing.build_queue(@engine.options.send_hwm, :block)
64
61
  @conn_queues[connection] = q
65
- @conn_send_tasks[connection] = ConnSendPump.start(@engine, connection, q, @tasks)
62
+ ConnSendPump.start(@engine, connection, q)
66
63
  end
67
64
 
68
65
 
@@ -71,7 +68,6 @@ module OMQ
71
68
  def connection_removed(connection)
72
69
  @pending_replies.reject! { |r| r[0] == connection }
73
70
  @conn_queues.delete(connection)
74
- @conn_send_tasks.delete(connection)&.stop
75
71
  end
76
72
 
77
73
 
@@ -92,16 +88,6 @@ module OMQ
92
88
  end
93
89
 
94
90
 
95
- # Stops all background tasks.
96
- #
97
- # @return [void]
98
- #
99
- def stop
100
- @tasks.each(&:stop)
101
- @tasks.clear
102
- end
103
-
104
-
105
91
  # @return [Boolean] true when all per-connection send queues are empty
106
92
  #
107
93
  def send_queues_drained?
@@ -21,10 +21,9 @@ module OMQ
21
21
  # @param engine [Engine]
22
22
  #
23
23
  def initialize(engine)
24
- @engine = engine
25
- @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
26
- @tasks = []
27
- @state = :ready # :ready or :waiting_reply
24
+ @engine = engine
25
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
26
+ @state = :ready # :ready or :waiting_reply
28
27
  init_round_robin(engine)
29
28
  end
30
29
 
@@ -50,12 +49,11 @@ module OMQ
50
49
  # @param connection [Connection]
51
50
  #
52
51
  def connection_added(connection)
53
- task = @engine.start_recv_pump(connection, @recv_queue) do |msg|
52
+ @engine.start_recv_pump(connection, @recv_queue) do |msg|
54
53
  @state = :ready
55
54
  msg.first&.empty? ? msg[1..] : msg
56
55
  end
57
56
 
58
- @tasks << task if task
59
57
  add_round_robin_send_connection(connection)
60
58
  end
61
59
 
@@ -77,16 +75,6 @@ module OMQ
77
75
  end
78
76
 
79
77
 
80
- # Stops all background tasks.
81
- #
82
- # @return [void]
83
- #
84
- def stop
85
- @tasks.each(&:stop)
86
- @tasks.clear
87
- end
88
-
89
-
90
78
  private
91
79
 
92
80
 
@@ -12,7 +12,7 @@ module OMQ
12
12
  # which is strictly better than libzmq's strict per-pipe round-robin
13
13
  # for PUSH-style patterns.
14
14
  #
15
- # See DESIGN.md "Per-socket HWM (not per-connection)" for the
15
+ # See doc/DESIGN.md "Per-socket HWM (not per-connection)" for the
16
16
  # full reasoning.
17
17
  #
18
18
  # Including classes must call `init_round_robin(engine)` from
@@ -38,11 +38,10 @@ module OMQ
38
38
  # @param engine [Engine]
39
39
  #
40
40
  def init_round_robin(engine)
41
- @connections = []
42
- @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
43
- @direct_pipe = nil
44
- @conn_send_tasks = {} # conn => send pump task
45
- @in_flight = 0 # messages dequeued but not yet written
41
+ @connections = []
42
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
43
+ @direct_pipe = nil
44
+ @in_flight = 0 # messages dequeued but not yet written
46
45
  end
47
46
 
48
47
 
@@ -58,16 +57,16 @@ module OMQ
58
57
  end
59
58
 
60
59
 
61
- # Removes the connection and stops its send pump. Any message
62
- # the pump had already dequeued but not yet written is dropped --
63
- # matching libzmq's behavior on `pipe_terminated`. PUSH has no
64
- # cross-peer ordering guarantee, so this is safe.
60
+ # Removes the connection. Any message the pump had already
61
+ # dequeued but not yet written is dropped — matching libzmq's
62
+ # behavior on `pipe_terminated`. PUSH has no cross-peer ordering
63
+ # guarantee, so this is safe. The pump itself is torn down by
64
+ # the per-connection lifecycle barrier.
65
65
  #
66
66
  # @param conn [Connection]
67
67
  #
68
68
  def remove_round_robin_send_connection(conn)
69
69
  update_direct_pipe
70
- @conn_send_tasks.delete(conn)
71
70
  end
72
71
 
73
72
 
@@ -132,7 +131,7 @@ module OMQ
132
131
  # @param conn [Connection]
133
132
  #
134
133
  def start_conn_send_pump(conn)
135
- task = @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
134
+ @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
136
135
  batch = []
137
136
 
138
137
  loop do
@@ -153,9 +152,6 @@ module OMQ
153
152
  Async::Task.current.yield
154
153
  end
155
154
  end
156
-
157
- @conn_send_tasks[conn] = task
158
- @tasks << task
159
155
  end
160
156
 
161
157
 
@@ -23,9 +23,7 @@ module OMQ
23
23
  @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
24
24
  @connections_by_identity = {}
25
25
  @identity_by_connection = {}
26
- @conn_queues = {} # connection => per-connection send queue
27
- @conn_send_tasks = {} # connection => send pump task
28
- @tasks = []
26
+ @conn_queues = {}
29
27
  end
30
28
 
31
29
 
@@ -55,12 +53,11 @@ module OMQ
55
53
  @connections_by_identity[identity] = connection
56
54
  @identity_by_connection[connection] = identity
57
55
 
58
- task = @engine.start_recv_pump(connection, @recv_queue) { |msg| [identity, *msg] }
59
- @tasks << task if task
56
+ @engine.start_recv_pump(connection, @recv_queue) { |msg| [identity, *msg] }
60
57
 
61
58
  q = Routing.build_queue(@engine.options.send_hwm, :block)
62
59
  @conn_queues[connection] = q
63
- @conn_send_tasks[connection] = ConnSendPump.start(@engine, connection, q, @tasks)
60
+ ConnSendPump.start(@engine, connection, q)
64
61
  end
65
62
 
66
63
 
@@ -70,7 +67,6 @@ module OMQ
70
67
  identity = @identity_by_connection.delete(connection)
71
68
  @connections_by_identity.delete(identity) if identity
72
69
  @conn_queues.delete(connection)
73
- @conn_send_tasks.delete(connection)&.stop
74
70
  end
75
71
 
76
72
 
@@ -91,16 +87,6 @@ module OMQ
91
87
  end
92
88
 
93
89
 
94
- # Stops all background tasks.
95
- #
96
- # @return [void]
97
- #
98
- def stop
99
- @tasks.each(&:stop)
100
- @tasks.clear
101
- end
102
-
103
-
104
90
  # @return [Boolean] true when all per-connection send queues are empty
105
91
  #
106
92
  def send_queues_drained?
@@ -0,0 +1,77 @@
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
+ init_round_robin(engine)
15
+ end
16
+
17
+
18
+ # SCATTER is write-only.
19
+ #
20
+ def recv_queue
21
+ raise "SCATTER sockets cannot receive"
22
+ end
23
+
24
+
25
+ def dequeue_recv
26
+ raise "SCATTER sockets cannot receive"
27
+ end
28
+
29
+
30
+ # No-op; SCATTER has no recv queue to unblock.
31
+ #
32
+ def unblock_recv
33
+ end
34
+
35
+
36
+ # @param connection [Connection]
37
+ #
38
+ def connection_added(connection)
39
+ @connections << connection
40
+ add_round_robin_send_connection(connection)
41
+ start_reaper(connection)
42
+ end
43
+
44
+
45
+ # @param connection [Connection]
46
+ #
47
+ def connection_removed(connection)
48
+ @connections.delete(connection)
49
+ remove_round_robin_send_connection(connection)
50
+ end
51
+
52
+
53
+ # @param parts [Array<String>]
54
+ #
55
+ def enqueue(parts)
56
+ enqueue_round_robin(parts)
57
+ end
58
+
59
+
60
+ private
61
+
62
+
63
+ # Detects peer disconnection on write-only sockets by
64
+ # blocking on a receive that only returns on disconnect.
65
+ #
66
+ # @param conn [Connection]
67
+ #
68
+ def start_reaper(conn)
69
+ return if conn.is_a?(Transport::Inproc::DirectPipe)
70
+ @engine.spawn_conn_pump_task(conn, annotation: "reaper") do
71
+ conn.receive_message # blocks until peer disconnects; then exits
72
+ end
73
+ end
74
+
75
+ end
76
+ end
77
+ end