omq 0.5.0 → 0.5.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 37d1cbdb4834bd8c3171e5317403066d6cc84f454ab4f3c076655019480634ad
4
- data.tar.gz: 95e1712a3c993aad845d499c5b2462c42ab6720aa4c7f4134aab05fd4f972ea5
3
+ metadata.gz: 76cecd3650f9fb73d699a027570421f589452a40cf6bbf207af02afb2bc6df08
4
+ data.tar.gz: 0252e01bacd5278defe46ed9ab4750b3e33ca71e928beb13f1092c23a7c2ce62
5
5
  SHA512:
6
- metadata.gz: 4e8412637f3eb2adce4451866217018e2947ab0e1ff1324664ff9946d658115ab042d3c429811d0d7c269704c54bb7c6ba2902570a8e882a2e5349b9504665c7
7
- data.tar.gz: fc54cbabda462a9a50a9135f9c7f4da202229a768c74f60fff8cfe56fccd6ad7bdfd68a90e4e4525e091b8d0b71640692997596978b696596a33d572227bdbd3
6
+ metadata.gz: de2508cbe4e22c6dc93bdeab4626fed0c5280cf74eaaf4c4f886f566719a3c732f5db9b766c201f7e9d6eb7bb00ee4ed2d2392eec232abe88c7847148b889fc5
7
+ data.tar.gz: 65e96cef70697abe90588e5e35f4563e55ef183bb0be44276c6cb62ec007f6accb4e482a08c14d89b15ec3f542d75c78bacc87c6174ca6f4e69b588ddb540730
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1 — 2026-03-28
4
+
5
+ ### Improved
6
+
7
+ - **3–4x throughput under burst load** — send pumps now batch writes
8
+ before flushing. `Connection#write_message` buffers without flushing;
9
+ `Connection#flush` triggers the syscall. Pumps drain all queued messages
10
+ per cycle, reducing flush count from `N_msgs × N_conns` to `N_conns`
11
+ per batch. PUB/SUB TCP with 10 subscribers: 2.3k → 9.2k msg/s (**4x**).
12
+ PUSH/PULL TCP: 24k → 83k msg/s (**3.4x**). Zero overhead under light
13
+ load (batch of 1 = same path as before).
14
+
15
+ - **Simplified Reactor IO thread** — replaced `Thread::Queue` + `IO.pipe`
16
+ wake signal with a single `Async::Queue`. `Thread::Queue#pop` is
17
+ fiber-scheduler-aware in Ruby 4.0, so the pipe pair was unnecessary.
18
+
19
+ ### Fixed
20
+
21
+ - **`router_mandatory` SocketError raised in send pump** — the error
22
+ killed the pump fiber instead of reaching the caller. Now checked
23
+ synchronously in `enqueue` before queuing.
24
+
3
25
  ## 0.5.0 — 2026-03-28
4
26
 
5
27
  ### Added
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.5.0"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -77,21 +77,36 @@ module OMQ
77
77
  end
78
78
  end
79
79
 
80
- # Sends a multi-frame message.
80
+ # Sends a multi-frame message (write + flush).
81
81
  #
82
82
  # @param parts [Array<String>] message frames
83
83
  # @return [void]
84
84
  #
85
85
  def send_message(parts)
86
86
  @mutex.synchronize do
87
- parts.each_with_index do |part, i|
88
- more = i < parts.size - 1
89
- if @mechanism.encrypted?
90
- @io.write(@mechanism.encrypt(part.b, more: more))
91
- else
92
- @io.write(Codec::Frame.new(part, more: more).to_wire)
93
- end
94
- end
87
+ write_frames(parts)
88
+ @io.flush
89
+ end
90
+ end
91
+
92
+ # Writes a multi-frame message to the buffer without flushing.
93
+ # Call {#flush} after batching writes.
94
+ #
95
+ # @param parts [Array<String>] message frames
96
+ # @return [void]
97
+ #
98
+ def write_message(parts)
99
+ @mutex.synchronize do
100
+ write_frames(parts)
101
+ end
102
+ end
103
+
104
+ # Flushes the write buffer to the underlying IO.
105
+ #
106
+ # @return [void]
107
+ #
108
+ def flush
109
+ @mutex.synchronize do
95
110
  @io.flush
96
111
  end
97
112
  end
@@ -213,6 +228,17 @@ module OMQ
213
228
 
214
229
  private
215
230
 
231
+ def write_frames(parts)
232
+ parts.each_with_index do |part, i|
233
+ more = i < parts.size - 1
234
+ if @mechanism.encrypted?
235
+ @io.write(@mechanism.encrypt(part.b, more: more))
236
+ else
237
+ @io.write(Codec::Frame.new(part, more: more).to_wire)
238
+ end
239
+ end
240
+ end
241
+
216
242
  def touch_heartbeat
217
243
  @last_received_at = monotonic_now if @heartbeat_interval
218
244
  end
@@ -12,11 +12,9 @@ module OMQ
12
12
  # tasks — mirroring libzmq's IO thread architecture.
13
13
  #
14
14
  module Reactor
15
- @work_queue = Thread::Queue.new
15
+ @work_queue = Async::Queue.new
16
16
  @thread = nil
17
17
  @mutex = Mutex.new
18
- @wake_r = nil
19
- @wake_w = nil
20
18
 
21
19
  class << self
22
20
  # Spawns a pump task (recv loop, send loop, accept loop).
@@ -33,7 +31,6 @@ module OMQ
33
31
  handle = PumpHandle.new
34
32
  ensure_started
35
33
  @work_queue.push([:spawn, block, handle])
36
- @wake_w.write_nonblock(".") rescue nil
37
34
  handle
38
35
  end
39
36
  end
@@ -52,7 +49,6 @@ module OMQ
52
49
  result_queue = Thread::Queue.new
53
50
  ensure_started
54
51
  @work_queue.push([:run, block, result_queue])
55
- @wake_w.write_nonblock(".") rescue nil
56
52
  status, value = result_queue.pop
57
53
  raise value if status == :error
58
54
  value
@@ -66,9 +62,8 @@ module OMQ
66
62
  def ensure_started
67
63
  @mutex.synchronize do
68
64
  return if @thread&.alive?
69
- @wake_r, @wake_w = IO.pipe
70
65
  ready = Thread::Queue.new
71
- @thread = Thread.new { run_reactor(ready, @wake_r) }
66
+ @thread = Thread.new { run_reactor(ready) }
72
67
  @thread.name = "omq-io"
73
68
  ready.pop
74
69
  end
@@ -80,42 +75,31 @@ module OMQ
80
75
  #
81
76
  def stop!
82
77
  @work_queue.push([:stop])
83
- @wake_w&.write_nonblock(".") rescue nil
84
78
  @thread&.join(2)
85
79
  @thread = nil
86
- @wake_r&.close rescue nil
87
- @wake_w&.close rescue nil
88
- @wake_r = nil
89
- @wake_w = nil
90
80
  end
91
81
 
92
82
  private
93
83
 
94
- def run_reactor(ready, wake_r)
84
+ def run_reactor(ready)
95
85
  Async do |task|
96
86
  ready.push(true)
97
87
  loop do
98
- # Wait for wakeup signal (non-blocking for Async scheduler)
99
- wake_r.wait_readable
100
- wake_r.read_nonblock(256) rescue nil
101
-
102
- # Drain all pending work items
103
- while (item = @work_queue.pop(true) rescue nil)
104
- case item[0]
105
- when :spawn
106
- _, block, handle = item
107
- async_task = task.async(transient: true, &block)
108
- handle.task = async_task
109
- when :run
110
- _, block, result_queue = item
111
- task.async do
112
- result_queue.push([:ok, block.call])
113
- rescue => e
114
- result_queue.push([:error, e])
115
- end
116
- when :stop
117
- return
88
+ item = @work_queue.dequeue
89
+ case item[0]
90
+ when :spawn
91
+ _, block, handle = item
92
+ async_task = task.async(transient: true, &block)
93
+ handle.task = async_task
94
+ when :run
95
+ _, block, result_queue = item
96
+ task.async do
97
+ result_queue.push([:ok, block.call])
98
+ rescue => e
99
+ result_queue.push([:error, e])
118
100
  end
101
+ when :stop
102
+ return
119
103
  end
120
104
  end
121
105
  end
@@ -58,7 +58,12 @@ module OMQ
58
58
 
59
59
  def start_send_pump(conn)
60
60
  @send_pump = Reactor.spawn_pump do
61
- loop { conn.send_message(@send_queue.dequeue) }
61
+ loop do
62
+ batch = [@send_queue.dequeue]
63
+ Routing.drain_send_queue(@send_queue, batch)
64
+ batch.each { |parts| conn.write_message(parts) }
65
+ conn.flush
66
+ end
62
67
  rescue *ZMTP::CONNECTION_LOST
63
68
  @engine.connection_lost(conn)
64
69
  end
@@ -54,16 +54,28 @@ module OMQ
54
54
  @send_pump_started = true
55
55
  @tasks << Reactor.spawn_pump do
56
56
  loop do
57
- parts = @send_queue.dequeue
58
- topic = parts.first || "".b
59
- @connections.each do |conn|
60
- next unless subscribed?(conn, topic)
61
- begin
62
- conn.send_message(parts)
63
- rescue *ZMTP::CONNECTION_LOST
64
- # connection dead — will be cleaned up
57
+ batch = [@send_queue.dequeue]
58
+ Routing.drain_send_queue(@send_queue, batch)
59
+
60
+ written = Set.new
61
+ batch.each do |parts|
62
+ topic = parts.first || "".b
63
+ @connections.each do |conn|
64
+ next unless subscribed?(conn, topic)
65
+ begin
66
+ conn.write_message(parts)
67
+ written << conn
68
+ rescue *ZMTP::CONNECTION_LOST
69
+ # connection dead — will be cleaned up
70
+ end
65
71
  end
66
72
  end
73
+
74
+ written.each do |conn|
75
+ conn.flush
76
+ rescue *ZMTP::CONNECTION_LOST
77
+ # connection dead — will be cleaned up
78
+ end
67
79
  end
68
80
  end
69
81
  end
@@ -61,7 +61,12 @@ module OMQ
61
61
 
62
62
  def start_send_pump(conn)
63
63
  @send_pump = Reactor.spawn_pump do
64
- loop { conn.send_message(@send_queue.dequeue) }
64
+ loop do
65
+ batch = [@send_queue.dequeue]
66
+ Routing.drain_send_queue(@send_queue, batch)
67
+ batch.each { |parts| conn.write_message(parts) }
68
+ conn.flush
69
+ end
65
70
  rescue *ZMTP::CONNECTION_LOST
66
71
  @engine.connection_lost(conn)
67
72
  end
@@ -63,11 +63,27 @@ module OMQ
63
63
  @send_pump_started = true
64
64
  @tasks << Reactor.spawn_pump do
65
65
  loop do
66
- parts = @send_queue.dequeue
67
- routing_id = parts.first
68
- conn = @connections_by_routing_id[routing_id]
69
- next unless conn # silently drop if peer gone
70
- conn.send_message(parts[1..])
66
+ batch = [@send_queue.dequeue]
67
+ Routing.drain_send_queue(@send_queue, batch)
68
+
69
+ written = Set.new
70
+ batch.each do |parts|
71
+ routing_id = parts.first
72
+ conn = @connections_by_routing_id[routing_id]
73
+ next unless conn # silently drop if peer gone
74
+ begin
75
+ conn.write_message(parts[1..])
76
+ written << conn
77
+ rescue *ZMTP::CONNECTION_LOST
78
+ # will be cleaned up
79
+ end
80
+ end
81
+
82
+ written.each do |conn|
83
+ conn.flush
84
+ rescue *ZMTP::CONNECTION_LOST
85
+ # will be cleaned up
86
+ end
71
87
  end
72
88
  end
73
89
  end
@@ -69,18 +69,30 @@ module OMQ
69
69
  @send_pump_started = true
70
70
  @tasks << Reactor.spawn_pump do
71
71
  loop do
72
- parts = @send_queue.dequeue
73
- group = parts[0]
74
- body = parts[1] || "".b
75
- @connections.each do |conn|
76
- next unless @groups[conn]&.include?(group)
77
- begin
78
- # Wire format: group frame (MORE) + body frame
79
- conn.send_message([group, body])
80
- rescue *ZMTP::CONNECTION_LOST
81
- # connection dead — will be cleaned up
72
+ batch = [@send_queue.dequeue]
73
+ Routing.drain_send_queue(@send_queue, batch)
74
+
75
+ written = Set.new
76
+ batch.each do |parts|
77
+ group = parts[0]
78
+ body = parts[1] || "".b
79
+ @connections.each do |conn|
80
+ next unless @groups[conn]&.include?(group)
81
+ begin
82
+ # Wire format: group frame (MORE) + body frame
83
+ conn.write_message([group, body])
84
+ written << conn
85
+ rescue *ZMTP::CONNECTION_LOST
86
+ # connection dead — will be cleaned up
87
+ end
82
88
  end
83
89
  end
90
+
91
+ written.each do |conn|
92
+ conn.flush
93
+ rescue *ZMTP::CONNECTION_LOST
94
+ # connection dead — will be cleaned up
95
+ end
84
96
  end
85
97
  end
86
98
  end
@@ -68,13 +68,28 @@ module OMQ
68
68
  @send_pump_started = true
69
69
  @tasks << Reactor.spawn_pump do
70
70
  loop do
71
- parts = @send_queue.dequeue
72
- reply_info = @pending_replies.shift
73
- next unless reply_info
74
- reply_info[:conn].send_message([*reply_info[:envelope], "".b, *parts])
71
+ batch = [@send_queue.dequeue]
72
+ Routing.drain_send_queue(@send_queue, batch)
73
+
74
+ written = Set.new
75
+ batch.each do |parts|
76
+ reply_info = @pending_replies.shift
77
+ next unless reply_info
78
+ conn = reply_info[:conn]
79
+ begin
80
+ conn.write_message([*reply_info[:envelope], "".b, *parts])
81
+ written << conn
82
+ rescue *ZMTP::CONNECTION_LOST
83
+ # connection lost mid-write
84
+ end
85
+ end
86
+
87
+ written.each do |conn|
88
+ conn.flush
89
+ rescue *ZMTP::CONNECTION_LOST
90
+ # connection lost mid-flush
91
+ end
75
92
  end
76
- rescue *ZMTP::CONNECTION_LOST
77
- # connection lost mid-write
78
93
  end
79
94
  end
80
95
  end
@@ -55,8 +55,14 @@ module OMQ
55
55
  @send_pump_started = true
56
56
  @tasks << Reactor.spawn_pump do
57
57
  loop do
58
- parts = @send_queue.dequeue
59
- send_with_retry(parts)
58
+ batch = [@send_queue.dequeue]
59
+ Routing.drain_send_queue(@send_queue, batch)
60
+
61
+ if batch.size == 1
62
+ send_with_retry(batch[0])
63
+ else
64
+ send_batch(batch)
65
+ end
60
66
  end
61
67
  end
62
68
  end
@@ -68,6 +74,35 @@ module OMQ
68
74
  @engine.connection_lost(conn)
69
75
  retry
70
76
  end
77
+
78
+ def send_batch(batch)
79
+ written = Set.new
80
+ batch.each_with_index do |parts, i|
81
+ conn = next_connection
82
+ begin
83
+ conn.write_message(transform_send(parts))
84
+ written << conn
85
+ rescue *ZMTP::CONNECTION_LOST
86
+ @engine.connection_lost(conn)
87
+ # Flush what we've written so far
88
+ written.each do |c|
89
+ c.flush
90
+ rescue *ZMTP::CONNECTION_LOST
91
+ # will be cleaned up
92
+ end
93
+ written.clear
94
+ # Fall back to send_with_retry for this and remaining
95
+ send_with_retry(parts)
96
+ batch[(i + 1)..].each { |p| send_with_retry(p) }
97
+ return
98
+ end
99
+ end
100
+ written.each do |conn|
101
+ conn.flush
102
+ rescue *ZMTP::CONNECTION_LOST
103
+ # will be cleaned up
104
+ end
105
+ end
71
106
  end
72
107
  end
73
108
  end
@@ -52,6 +52,12 @@ module OMQ
52
52
  # @param parts [Array<String>]
53
53
  #
54
54
  def enqueue(parts)
55
+ if @engine.options.router_mandatory?
56
+ identity = parts.first
57
+ unless @connections_by_identity[identity]
58
+ raise SocketError, "no route to identity #{identity.inspect}"
59
+ end
60
+ end
55
61
  @send_queue.enqueue(parts)
56
62
  end
57
63
 
@@ -66,19 +72,27 @@ module OMQ
66
72
  @send_pump_started = true
67
73
  @tasks << Reactor.spawn_pump do
68
74
  loop do
69
- parts = @send_queue.dequeue
70
- identity = parts.first
71
- conn = @connections_by_identity[identity]
75
+ batch = [@send_queue.dequeue]
76
+ Routing.drain_send_queue(@send_queue, batch)
72
77
 
73
- unless conn
74
- if @engine.options.router_mandatory?
75
- raise SocketError, "no route to identity #{identity.inspect}"
78
+ written = Set.new
79
+ batch.each do |parts|
80
+ identity = parts.first
81
+ conn = @connections_by_identity[identity]
82
+ next unless conn # silently drop (peer may have disconnected)
83
+ begin
84
+ conn.write_message(parts[1..])
85
+ written << conn
86
+ rescue *ZMTP::CONNECTION_LOST
87
+ # will be cleaned up
76
88
  end
77
- next # silently drop
78
89
  end
79
90
 
80
- # Send everything after the identity frame
81
- conn.send_message(parts[1..])
91
+ written.each do |conn|
92
+ conn.flush
93
+ rescue *ZMTP::CONNECTION_LOST
94
+ # will be cleaned up
95
+ end
82
96
  end
83
97
  end
84
98
  end
@@ -63,11 +63,27 @@ module OMQ
63
63
  @send_pump_started = true
64
64
  @tasks << Reactor.spawn_pump do
65
65
  loop do
66
- parts = @send_queue.dequeue
67
- routing_id = parts.first
68
- conn = @connections_by_routing_id[routing_id]
69
- next unless conn # silently drop if peer gone
70
- conn.send_message(parts[1..])
66
+ batch = [@send_queue.dequeue]
67
+ Routing.drain_send_queue(@send_queue, batch)
68
+
69
+ written = Set.new
70
+ batch.each do |parts|
71
+ routing_id = parts.first
72
+ conn = @connections_by_routing_id[routing_id]
73
+ next unless conn # silently drop if peer gone
74
+ begin
75
+ conn.write_message(parts[1..])
76
+ written << conn
77
+ rescue *ZMTP::CONNECTION_LOST
78
+ # will be cleaned up
79
+ end
80
+ end
81
+
82
+ written.each do |conn|
83
+ conn.flush
84
+ rescue *ZMTP::CONNECTION_LOST
85
+ # will be cleaned up
86
+ end
71
87
  end
72
88
  end
73
89
  end
@@ -12,6 +12,25 @@ module OMQ
12
12
  # the socket's send/recv queues.
13
13
  #
14
14
  module Routing
15
+ # Maximum messages to drain from the send queue per flush cycle.
16
+ MAX_SEND_BATCH = 64
17
+
18
+ # Drains up to +max+ additional messages from +queue+ into +batch+
19
+ # without blocking. Call after the initial blocking dequeue.
20
+ #
21
+ # @param queue [Async::LimitedQueue]
22
+ # @param batch [Array]
23
+ # @param max [Integer]
24
+ # @return [void]
25
+ #
26
+ def self.drain_send_queue(queue, batch, max = MAX_SEND_BATCH)
27
+ while batch.size < max
28
+ msg = queue.dequeue(timeout: 0)
29
+ break unless msg
30
+ batch << msg
31
+ end
32
+ end
33
+
15
34
  # Returns the routing strategy class for a socket type.
16
35
  #
17
36
  # @param socket_type [Symbol] e.g. :PAIR, :REQ
@@ -262,6 +262,14 @@ module OMQ
262
262
  end
263
263
  end
264
264
 
265
+ alias write_message send_message
266
+
267
+ # No-op — inproc has no IO buffer to flush.
268
+ #
269
+ # @return [void]
270
+ #
271
+ def flush = nil
272
+
265
273
  # Receives a multi-frame message.
266
274
  #
267
275
  # @return [Array<String>]
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.5.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger