omq 0.12.0 → 0.13.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,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # Per-connection recv queue aggregator.
6
+ #
7
+ # Maintains one bounded queue per connected peer. #dequeue
8
+ # returns the next available message from any peer in fair
9
+ # round-robin order, blocking until one arrives.
10
+ #
11
+ # Recv pumps do not enqueue directly — they write through a
12
+ # SignalingQueue wrapper, which also wakes a blocked #dequeue.
13
+ #
14
+ class FairQueue
15
+ def initialize
16
+ @queues = [] # ordered list of per-connection inner queues
17
+ @mapping = {} # connection => inner queue
18
+ @cycle = @queues.cycle # live reference — sees adds/removes
19
+ @condition = Async::Condition.new
20
+ @pending = 0 # signals received before #dequeue waits
21
+ @closed = false
22
+ end
23
+
24
+ # Registers a per-connection queue. Called when a connection is added.
25
+ #
26
+ # @param conn [Connection]
27
+ # @param q [Async::LimitedQueue]
28
+ #
29
+ def add_queue(conn, q)
30
+ @mapping[conn] = q
31
+ @queues << q
32
+ end
33
+
34
+ # Removes the per-connection queue for a disconnected peer.
35
+ #
36
+ # If the queue is empty it is removed immediately. If it still has
37
+ # pending messages it is kept in @queues so the application can drain
38
+ # them via #dequeue; it will be cleaned up lazily by try_dequeue once
39
+ # it is empty.
40
+ #
41
+ # @param conn [Connection]
42
+ #
43
+ def remove_queue(conn)
44
+ q = @mapping.delete(conn)
45
+ return unless q
46
+ @queues.delete(q) if q.empty?
47
+ # Non-empty orphaned queues stay in @queues until drained
48
+ end
49
+
50
+ # Wakes a blocked #dequeue. Called by SignalingQueue after each enqueue.
51
+ #
52
+ def signal
53
+ @pending += 1
54
+ @condition.signal
55
+ end
56
+
57
+ # Returns the next message from any per-connection queue, in fair
58
+ # round-robin order. Blocks until a message is available.
59
+ #
60
+ # Pass +timeout: 0+ for a non-blocking poll (returns nil immediately
61
+ # if no messages are available).
62
+ #
63
+ # @param timeout [Numeric, nil] 0 = non-blocking, nil = block forever
64
+ # @return [Array<String>, nil]
65
+ #
66
+ def dequeue(timeout: nil)
67
+ return try_dequeue if timeout == 0
68
+
69
+ loop do
70
+ return nil if @closed && @queues.all?(&:empty?)
71
+
72
+ msg = try_dequeue
73
+ return msg if msg
74
+
75
+ if @pending > 0
76
+ @pending -= 1
77
+ next
78
+ end
79
+
80
+ @condition.wait
81
+ end
82
+ end
83
+
84
+ # Injects a nil sentinel to unblock a waiting #dequeue.
85
+ # Called by Engine on close or fatal error.
86
+ #
87
+ def push(nil_sentinel)
88
+ @closed = true
89
+ @condition.signal
90
+ end
91
+
92
+ # @return [Boolean]
93
+ #
94
+ def empty?
95
+ @queues.all?(&:empty?)
96
+ end
97
+
98
+ private
99
+
100
+ # Tries each per-connection queue once in round-robin order.
101
+ # Returns the first message found, or nil if all are empty.
102
+ # Lazily removes empty orphaned queues (disconnected peers that have
103
+ # been fully drained).
104
+ #
105
+ def try_dequeue
106
+ @queues.size.times do
107
+ q = begin
108
+ @cycle.next
109
+ rescue StopIteration
110
+ @cycle = @queues.cycle
111
+ break
112
+ end
113
+ msg = q.dequeue(timeout: 0)
114
+ return msg if msg
115
+ if q.empty? && !@mapping.value?(q)
116
+ @queues.delete(q)
117
+ break
118
+ end
119
+ end
120
+ nil
121
+ end
122
+ end
123
+
124
+
125
+ # Wraps a per-connection bounded queue so that each #enqueue also
126
+ # signals the FairQueue to wake a blocked #dequeue.
127
+ #
128
+ class SignalingQueue
129
+ def initialize(inner, fair_queue)
130
+ @inner = inner
131
+ @fair = fair_queue
132
+ end
133
+
134
+ def enqueue(msg)
135
+ @inner.enqueue(msg)
136
+ @fair.signal
137
+ end
138
+
139
+ def dequeue(timeout: nil) = @inner.dequeue(timeout: timeout)
140
+ def empty? = @inner.empty?
141
+ def push(item) = @inner.push(item)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # Mixin that adds per-connection recv queue setup for fair-queued sockets.
6
+ #
7
+ # Including classes must have @engine, @recv_queue (FairQueue), and @tasks.
8
+ #
9
+ module FairRecv
10
+ private
11
+
12
+ # Creates a per-connection recv queue, registers it with @recv_queue,
13
+ # and starts a recv pump for the connection. Called from #connection_added.
14
+ #
15
+ # @param conn [Connection]
16
+ # @yield [msg] optional per-message transform
17
+ #
18
+ def add_fair_recv_connection(conn, &transform)
19
+ conn_q = Routing.build_queue(@engine.options.recv_hwm, :block)
20
+ signaling = SignalingQueue.new(conn_q, @recv_queue)
21
+ @recv_queue.add_queue(conn, conn_q)
22
+ task = @engine.start_recv_pump(conn, signaling, &transform)
23
+ @tasks << task if task
24
+ end
25
+ end
26
+ end
27
+ end
@@ -5,7 +5,13 @@ module OMQ
5
5
  # Mixin for routing strategies that fan-out to subscribers.
6
6
  #
7
7
  # Manages per-connection subscription sets, subscription command
8
- # listeners, and a send pump that delivers to all matching peers.
8
+ # listeners, and per-connection send queues/pumps that deliver
9
+ # to each matching peer independently.
10
+ #
11
+ # HWM is enforced per subscriber: each connection gets its own
12
+ # bounded send queue. DropQueues (for :drop_newest/:drop_oldest)
13
+ # silently drop messages for a slow subscriber without affecting
14
+ # others. LimitedQueues (for :block) block the publisher.
9
15
  #
10
16
  # Including classes must call `init_fan_out(engine)` from
11
17
  # their #initialize.
@@ -13,24 +19,26 @@ module OMQ
13
19
  module FanOut
14
20
  attr_reader :subscriber_joined
15
21
 
16
- # @return [Boolean] true when the send pump is idle (not sending a batch)
17
- def send_pump_idle? = @send_pump_idle
22
+ # True when all per-connection send queues are empty.
23
+ # Used by Engine#drain_send_queues during linger.
24
+ #
25
+ def send_queues_drained?
26
+ @conn_queues.values.all?(&:empty?)
27
+ end
18
28
 
19
29
  private
20
30
 
21
31
  def init_fan_out(engine)
22
- @connections = []
32
+ @connections = Set.new
23
33
  @subscriptions = {} # connection => Set of prefixes
24
- @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
25
- @on_mute = engine.options.on_mute
26
- @send_pump_started = false
27
- @send_pump_idle = true
34
+ @conn_queues = {} # connection => per-connection send queue
35
+ @conn_send_tasks = {} # connection => send pump task
28
36
  @conflate = engine.options.conflate
29
37
  @subscriber_joined = Async::Promise.new
30
- @written = Set.new
31
38
  @latest = {} if @conflate
32
39
  end
33
40
 
41
+
34
42
  # @return [Boolean] whether the connection is subscribed to the topic
35
43
  #
36
44
  def subscribed?(conn, topic)
@@ -61,77 +69,50 @@ module OMQ
61
69
  @subscriptions[conn]&.delete(prefix)
62
70
  end
63
71
 
64
- # Returns true if the connection is muted (recv queue full) and
65
- # the on_mute strategy says to drop rather than block.
72
+
73
+ # Creates a per-connection send queue and starts its send pump.
74
+ # Call from #connection_added.
66
75
  #
67
- def muted?(conn)
68
- return false if @on_mute == :block
69
- q = conn.direct_recv_queue if conn.respond_to?(:direct_recv_queue)
70
- q&.respond_to?(:limited?) && q.limited?
76
+ # @param conn [Connection]
77
+ #
78
+ def add_fan_out_send_connection(conn)
79
+ q = Routing.build_queue(@engine.options.send_hwm, :block)
80
+ @conn_queues[conn] = q
81
+ start_conn_send_pump(conn, q)
71
82
  end
72
83
 
73
84
 
74
- def start_send_pump
75
- @send_pump_started = true
76
- @tasks << @engine.spawn_pump_task(annotation: "send pump") do
77
- loop do
78
- @send_pump_idle = true
79
- batch = [@send_queue.dequeue]
80
- @send_pump_idle = false
81
- Routing.drain_send_queue(@send_queue, batch)
82
-
83
- @written.clear
84
-
85
- if @conflate
86
- # Keep only the last matching message per connection.
87
- @latest.clear
88
- batch.each do |parts|
89
- topic = parts.first || EMPTY_BINARY
90
- @connections.each do |conn|
91
- next unless subscribed?(conn, topic)
92
- @latest[conn] = parts
93
- end
94
- end
95
- @latest.each do |conn, parts|
96
- next if muted?(conn)
97
- begin
98
- conn.write_message(parts)
99
- @written << conn
100
- rescue *CONNECTION_LOST
101
- end
102
- end
103
- else
104
- batch.each do |parts|
105
- topic = parts.first || EMPTY_BINARY
106
- wire_bytes = nil
107
-
108
- @connections.each do |conn|
109
- next unless subscribed?(conn, topic)
110
- next if muted?(conn)
111
- begin
112
- if conn.respond_to?(:curve?) && conn.curve?
113
- conn.write_message(parts)
114
- elsif conn.respond_to?(:write_wire)
115
- wire_bytes ||= Protocol::ZMTP::Codec::Frame.encode_message(parts)
116
- conn.write_wire(wire_bytes)
117
- else
118
- conn.write_message(parts)
119
- end
120
- @written << conn
121
- rescue *CONNECTION_LOST
122
- end
123
- end
124
- end
125
- end
85
+ # Stops the per-connection send pump and removes the queue.
86
+ # Call from #connection_removed.
87
+ #
88
+ # @param conn [Connection]
89
+ #
90
+ def remove_fan_out_send_connection(conn)
91
+ @conn_queues.delete(conn)
92
+ @conn_send_tasks.delete(conn)&.stop
93
+ end
126
94
 
127
- @written.each do |conn|
128
- conn.flush
129
- rescue *CONNECTION_LOST
130
- end
131
- end
95
+
96
+ # Fans a message out to every connected peer's send queue.
97
+ # Subscription filtering happens in the per-connection send pump so
98
+ # that late-arriving subscriptions (e.g. inproc connect-before-subscribe)
99
+ # are respected: a message enqueued before the async subscription listener
100
+ # has processed SUBSCRIBE commands will still be delivered correctly.
101
+ #
102
+ # Per-connection queues use :block (Async::LimitedQueue) for
103
+ # backpressure: when a subscriber's queue is full, the publisher
104
+ # yields until the send pump drains it. This matches the old
105
+ # shared-queue behavior and keeps the publisher fiber-friendly.
106
+ #
107
+ # @param parts [Array<String>]
108
+ #
109
+ def fan_out_enqueue(parts)
110
+ @connections.each do |conn|
111
+ @conn_queues[conn]&.enqueue(parts)
132
112
  end
133
113
  end
134
114
 
115
+
135
116
  def start_subscription_listener(conn)
136
117
  @tasks << @engine.spawn_pump_task(annotation: "subscription listener") do
137
118
  loop do
@@ -147,6 +128,66 @@ module OMQ
147
128
  @engine.connection_lost(conn)
148
129
  end
149
130
  end
131
+
132
+
133
+ # Starts a dedicated send pump for one subscriber connection.
134
+ # Uses write_wire (pre-encoded bytes) for non-CURVE TCP connections
135
+ # to avoid re-encoding the same message N times during fan-out.
136
+ # In conflate mode, drains the batch and keeps only the latest
137
+ # message per topic before writing.
138
+ #
139
+ # @param conn [Connection]
140
+ # @param q [Async::LimitedQueue, DropQueue]
141
+ #
142
+ def start_conn_send_pump(conn, q)
143
+ use_wire = conn.respond_to?(:write_wire) && !(conn.respond_to?(:curve?) && conn.curve?)
144
+ task = @conflate ? start_conn_send_pump_conflate(conn, q) : start_conn_send_pump_normal(conn, q, use_wire)
145
+ @conn_send_tasks[conn] = task
146
+ @tasks << task
147
+ end
148
+
149
+ def start_conn_send_pump_normal(conn, q, use_wire)
150
+ @engine.spawn_pump_task(annotation: "send pump") do
151
+ loop do
152
+ batch = [q.dequeue]
153
+ Routing.drain_send_queue(q, batch)
154
+ conn.flush if write_matching_batch(conn, batch, use_wire)
155
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST
156
+ @engine.connection_lost(conn)
157
+ break
158
+ end
159
+ end
160
+ end
161
+
162
+ def write_matching_batch(conn, batch, use_wire)
163
+ sent = false
164
+ batch.each do |parts|
165
+ next unless subscribed?(conn, parts.first || EMPTY_BINARY)
166
+ use_wire ? conn.write_wire(Protocol::ZMTP::Codec::Frame.encode_message(parts)) : conn.write_message(parts)
167
+ sent = true
168
+ end
169
+ sent
170
+ end
171
+
172
+
173
+ def start_conn_send_pump_conflate(conn, q)
174
+ @engine.spawn_pump_task(annotation: "send pump") do
175
+ loop do
176
+ batch = [q.dequeue]
177
+ Routing.drain_send_queue(q, batch)
178
+ # Keep only the latest message that matches the subscription.
179
+ latest = batch.reverse.find { |parts| subscribed?(conn, parts.first || EMPTY_BINARY) }
180
+ next unless latest
181
+ begin
182
+ conn.write_message(latest)
183
+ conn.flush
184
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST
185
+ @engine.connection_lost(conn)
186
+ break
187
+ end
188
+ end
189
+ end
190
+ end
150
191
  end
151
192
  end
152
193
  end
@@ -4,25 +4,28 @@ module OMQ
4
4
  module Routing
5
5
  # PAIR socket routing: exclusive 1-to-1 bidirectional.
6
6
  #
7
- # Only one peer connection is allowed. Messages flow through
8
- # internal send/recv queues backed by Async::LimitedQueue.
7
+ # Only one peer connection is allowed. Send and recv queues are
8
+ # created per-connection (and destroyed on disconnection) so
9
+ # HWM is consistent with multi-peer socket types.
9
10
  #
10
11
  class Pair
12
+ include FairRecv
11
13
 
12
14
  # @param engine [Engine]
13
15
  #
14
16
  def initialize(engine)
15
- @engine = engine
16
- @connection = nil
17
- @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
18
- @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
17
+ @engine = engine
18
+ @connection = nil
19
+ @recv_queue = FairQueue.new
20
+ @send_queue = nil # created per-connection
21
+ @staging_queue = Routing.build_queue(@engine.options.send_hwm, :block)
22
+ @send_pump = nil
19
23
  @tasks = []
20
- @send_pump_idle = true
21
24
  end
22
25
 
23
- # @return [Async::LimitedQueue]
26
+ # @return [FairQueue]
24
27
  #
25
- attr_reader :recv_queue, :send_queue
28
+ attr_reader :recv_queue
26
29
 
27
30
  # @param connection [Connection]
28
31
  # @raise [RuntimeError] if a connection already exists
@@ -30,9 +33,16 @@ module OMQ
30
33
  def connection_added(connection)
31
34
  raise "PAIR allows only one peer" if @connection
32
35
  @connection = connection
33
- task = @engine.start_recv_pump(connection, @recv_queue)
34
- @tasks << task if task
35
- start_send_pump(connection) unless connection.is_a?(Transport::Inproc::DirectPipe)
36
+
37
+ add_fair_recv_connection(connection)
38
+
39
+ unless connection.is_a?(Transport::Inproc::DirectPipe)
40
+ @send_queue = Routing.build_queue(@engine.options.send_hwm, :block)
41
+ while (msg = @staging_queue.dequeue(timeout: 0))
42
+ @send_queue.enqueue(msg)
43
+ end
44
+ start_send_pump(connection)
45
+ end
36
46
  end
37
47
 
38
48
  # @param connection [Connection]
@@ -40,6 +50,8 @@ module OMQ
40
50
  def connection_removed(connection)
41
51
  if @connection == connection
42
52
  @connection = nil
53
+ @recv_queue.remove_queue(connection)
54
+ @send_queue = nil
43
55
  @send_pump&.stop
44
56
  @send_pump = nil
45
57
  end
@@ -51,8 +63,10 @@ module OMQ
51
63
  conn = @connection
52
64
  if conn.is_a?(Transport::Inproc::DirectPipe) && conn.direct_recv_queue
53
65
  conn.send_message(parts)
54
- else
66
+ elsif @send_queue
55
67
  @send_queue.enqueue(parts)
68
+ else
69
+ @staging_queue.enqueue(parts)
56
70
  end
57
71
  end
58
72
 
@@ -62,22 +76,27 @@ module OMQ
62
76
  @tasks.clear
63
77
  end
64
78
 
65
- def send_pump_idle? = @send_pump_idle
79
+ # True when the staging and send queues are empty.
80
+ #
81
+ def send_queues_drained?
82
+ @staging_queue.empty? && (@send_queue.nil? || @send_queue.empty?)
83
+ end
66
84
 
67
85
  private
68
86
 
69
87
  def start_send_pump(conn)
70
88
  @send_pump = @engine.spawn_pump_task(annotation: "send pump") do
71
89
  loop do
72
- @send_pump_idle = true
73
90
  batch = [@send_queue.dequeue]
74
- @send_pump_idle = false
75
91
  Routing.drain_send_queue(@send_queue, batch)
76
- batch.each { |parts| conn.write_message(parts) }
77
- conn.flush
92
+ begin
93
+ batch.each { |parts| conn.write_message(parts) }
94
+ conn.flush
95
+ rescue Protocol::ZMTP::Error, *CONNECTION_LOST
96
+ @engine.connection_lost(conn)
97
+ break
98
+ end
78
99
  end
79
- rescue *CONNECTION_LOST
80
- @engine.connection_lost(conn)
81
100
  end
82
101
  @tasks << @send_pump
83
102
  end
@@ -5,7 +5,8 @@ module OMQ
5
5
  # PUB socket routing: fan-out to all subscribers.
6
6
  #
7
7
  # Listens for SUBSCRIBE/CANCEL commands from peers.
8
- # Drops messages if a subscriber's connection write fails.
8
+ # Each subscriber gets its own bounded send queue; slow subscribers
9
+ # are muted via the socket's on_mute strategy (drop by default).
9
10
  #
10
11
  class Pub
11
12
  include FanOut
@@ -18,10 +19,6 @@ module OMQ
18
19
  init_fan_out(engine)
19
20
  end
20
21
 
21
- # @return [Async::LimitedQueue]
22
- #
23
- attr_reader :send_queue
24
-
25
22
  # PUB is write-only.
26
23
  #
27
24
  def recv_queue
@@ -34,7 +31,7 @@ module OMQ
34
31
  @connections << connection
35
32
  @subscriptions[connection] = Set.new
36
33
  start_subscription_listener(connection)
37
- start_send_pump unless @send_pump_started
34
+ add_fan_out_send_connection(connection)
38
35
  end
39
36
 
40
37
  # @param connection [Connection]
@@ -42,12 +39,13 @@ module OMQ
42
39
  def connection_removed(connection)
43
40
  @connections.delete(connection)
44
41
  @subscriptions.delete(connection)
42
+ remove_fan_out_send_connection(connection)
45
43
  end
46
44
 
47
45
  # @param parts [Array<String>]
48
46
  #
49
47
  def enqueue(parts)
50
- @send_queue.enqueue(parts)
48
+ fan_out_enqueue(parts)
51
49
  end
52
50
 
53
51
  #
@@ -5,28 +5,29 @@ module OMQ
5
5
  # PULL socket routing: fair-queue receive from PUSH peers.
6
6
  #
7
7
  class Pull
8
+ include FairRecv
8
9
  # @param engine [Engine]
9
10
  #
10
11
  def initialize(engine)
11
12
  @engine = engine
12
- @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
13
+ @recv_queue = FairQueue.new
13
14
  @tasks = []
14
15
  end
15
16
 
16
- # @return [Async::LimitedQueue]
17
+ # @return [FairQueue]
17
18
  #
18
19
  attr_reader :recv_queue
19
20
 
20
21
  # @param connection [Connection]
21
22
  #
22
23
  def connection_added(connection)
23
- task = @engine.start_recv_pump(connection, @recv_queue)
24
- @tasks << task if task
24
+ add_fair_recv_connection(connection)
25
25
  end
26
26
 
27
27
  # @param connection [Connection]
28
28
  #
29
29
  def connection_removed(connection)
30
+ @recv_queue.remove_queue(connection)
30
31
  # recv pump stops on EOFError
31
32
  end
32
33
 
@@ -16,11 +16,6 @@ module OMQ
16
16
  end
17
17
 
18
18
 
19
- # @return [Async::LimitedQueue]
20
- #
21
- attr_reader :send_queue
22
-
23
-
24
19
  # PUSH is write-only.
25
20
  #
26
21
  def recv_queue
@@ -32,9 +27,7 @@ module OMQ
32
27
  #
33
28
  def connection_added(connection)
34
29
  @connections << connection
35
- signal_connection_available
36
- update_direct_pipe
37
- start_send_pump unless @send_pump_started
30
+ add_round_robin_send_connection(connection)
38
31
  start_reaper(connection)
39
32
  end
40
33
 
@@ -43,7 +36,7 @@ module OMQ
43
36
  #
44
37
  def connection_removed(connection)
45
38
  @connections.delete(connection)
46
- update_direct_pipe
39
+ remove_round_robin_send_connection(connection)
47
40
  end
48
41
 
49
42
 
@@ -54,7 +47,7 @@ module OMQ
54
47
  end
55
48
 
56
49
 
57
- # Stops all background tasks (send pump, reapers).
50
+ # Stops all background tasks (send pumps, reapers).
58
51
  #
59
52
  def stop
60
53
  @tasks.each(&:stop)
@@ -76,8 +69,6 @@ module OMQ
76
69
  @engine.connection_lost(conn)
77
70
  end
78
71
  end
79
-
80
-
81
72
  end
82
73
  end
83
74
  end