omq 0.21.0 → 0.22.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: f24ac91fe456168b4d369f63506cf1d3d2b48488637ce767363aa17bb542d2b3
4
- data.tar.gz: ca1b98ab4083ad90f7f483bc9df937656ae9862c9fd07800fc46315543e5fe75
3
+ metadata.gz: f58b9e0c8c49bcdfb8dd80ec5eda6a6b6e5b09f04a2a7851001449449ba448fd
4
+ data.tar.gz: 0f6cbd451adc8b1d1cde0771d6535c5959f09e4b3f0442164b0544964755795e
5
5
  SHA512:
6
- metadata.gz: 16f20fa600ebd66228589da4e03efc809a616d6a74fbe3460a8ced6811349fb86903e43a440708e1e78f102af711d6f9ea3ebf9cbc0e7fb4aef9591db5061e39
7
- data.tar.gz: 89ff3da1bb5caa2223d1e323a389cacc4f270fed719902dbd0721c56da8ff5c0a1cbf1e6eebc59c8386f470c433c69dfb20462d46f84c9a2fa848b3e1364dfd0
6
+ metadata.gz: d7d13f97eeacf998f1e0137bdbc5dd4a471195ff628fd7da97720a019e30c2a9c200fab761c38943bbbada056bf81880e6ca2dd87d3936f150f0fa22566cebe5
7
+ data.tar.gz: f97931ecb4893ecd0155c37ea18793c9414bd57de6918496ef7af18bad714720aa2e146531788956265d6f85bc3e18f6dd403f71df8683913d322c94a6dd1bdb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,67 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.22.1 — 2026-04-16
4
+
5
+ ### Changed
6
+
7
+ - **Reuse batch arrays in send pumps.** All send pumps (RoundRobin,
8
+ Pair, ConnSendPump, FanOut, FanOut-conflate) now pre-allocate a
9
+ single batch array and clear it between cycles instead of
10
+ allocating a fresh `[msg]` per dequeue.
11
+
12
+ - **`Routing.dequeue_batch`** consolidates the blocking-dequeue +
13
+ non-blocking-sweep pattern that was duplicated across four call
14
+ sites into one method. `dequeue_batch_capped` does the same for
15
+ the byte/message-capped RoundRobin variant.
16
+
17
+ - **REP envelope stored as `[conn, envelope]`** instead of a Hash,
18
+ and reply assembly uses `<<` + `concat` instead of double splat.
19
+
20
+ - **Heartbeat drops redundant `context: "".b`** — the default is
21
+ now `EMPTY_BINARY` in protocol-zmtp.
22
+
23
+ - **Bench harness accepts `OMQ_BENCH_SIZES`, `OMQ_BENCH_TRANSPORTS`,
24
+ and `OMQ_BENCH_PEERS`** env vars to scope runs without editing
25
+ code.
26
+
27
+ ## 0.22.0 — 2026-04-15
28
+
29
+ ### Fixed
30
+
31
+ - **PUB/SUB interop with ZMTP 3.0 peers** (libzmq, JeroMQ, pyzmq,
32
+ NetMQ). OMQ previously sent `SUBSCRIBE`/`CANCEL` as ZMTP 3.1
33
+ command frames unconditionally; 3.0 peers expect message-form
34
+ (`\x01`/`\x00` + prefix data frames) and silently dropped them.
35
+ `Routing::Sub` and `Routing::XSub` now dispatch on
36
+ `conn.peer_minor`: command-form to ZMTP 3.1+ peers,
37
+ message-form to ZMTP 3.0 peers. `FanOut`'s subscription listener
38
+ already accepts both forms via `Protocol::ZMTP::Codec::Subscription.parse`,
39
+ so PUB/XPUB now also accept legacy message-form subscriptions
40
+ from 3.0 peers. Verified against JeroMQ in all six role/direction
41
+ combinations.
42
+ - **ZMTP/2.0 peers are now dropped loudly** during handshake
43
+ instead of hanging `read_exactly` forever. The underlying
44
+ `Greeting.read_from` helper in `protocol-zmtp` sniffs the
45
+ revision byte after 11 bytes and raises; the engine's existing
46
+ handshake-failure path closes the connection.
47
+ - **`Inproc::DirectPipe#read_frame`** now returns a data `Frame`
48
+ for non-command queue entries instead of silently dropping
49
+ them. Previously the fast-path `read_frame` only handled
50
+ `[:command, cmd]`-tagged items, so a message-form subscription
51
+ arriving on an inproc pipe was lost. Fallout from the PUB/SUB
52
+ fix above — without it the inproc tests for that path hung.
53
+
54
+ ### Added
55
+
56
+ - **ZMTP 3.0 / 3.1 compat tests** (`test/omq/zmtp_30_compat_test.rb`).
57
+ Hand-crafted raw TCP peer fakes cover: OMQ SUB → 3.0 PUB (message-form),
58
+ OMQ SUB → 3.1 PUB (command-form), OMQ XSUB → 3.0 PUB, OMQ XSUB → 3.1 PUB,
59
+ and OMQ PUB accepting message-form SUBSCRIBE from a 3.0 SUB peer.
60
+ - **`Inproc::DirectPipe#peer_major` / `#peer_minor`** — hard-coded to
61
+ 3/1 since both ends of an inproc pipe are OMQ. Lets the routing
62
+ layer dispatch uniformly on `conn.peer_minor` without special-casing
63
+ the transport.
64
+
3
65
  ## 0.21.0 — 2026-04-15
4
66
 
5
67
  ### Changed
@@ -24,7 +24,7 @@ module OMQ
24
24
  tasks << parent.async(transient: true, annotation: "heartbeat") do
25
25
  loop do
26
26
  sleep interval
27
- conn.send_command(Protocol::ZMTP::Codec::Command.ping(ttl: ttl, context: "".b))
27
+ conn.send_command(Protocol::ZMTP::Codec::Command.ping(ttl: ttl))
28
28
  if conn.heartbeat_expired?(timeout)
29
29
  conn.close
30
30
  break
@@ -18,9 +18,10 @@ module OMQ
18
18
  #
19
19
  def self.start(engine, conn, q, tasks)
20
20
  task = engine.spawn_conn_pump_task(conn, annotation: "send pump") do
21
+ batch = []
22
+
21
23
  loop do
22
- batch = [q.dequeue]
23
- Routing.drain_send_queue(q, batch)
24
+ Routing.dequeue_batch(q, batch)
24
25
 
25
26
  if batch.size == 1
26
27
  conn.write_message batch.first
@@ -30,7 +31,11 @@ module OMQ
30
31
 
31
32
  conn.flush
32
33
 
33
- batch.each { |parts| engine.emit_verbose_msg_sent(conn, parts) }
34
+ batch.each do |parts|
35
+ engine.emit_verbose_msg_sent(conn, parts)
36
+ end
37
+
38
+ batch.clear
34
39
  end
35
40
  end
36
41
 
@@ -133,15 +133,14 @@ module OMQ
133
133
  @tasks << @engine.spawn_conn_pump_task(conn, annotation: "subscription listener") do
134
134
  loop do
135
135
  frame = conn.read_frame
136
- next unless frame.command?
137
136
 
138
- cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
139
-
140
- case cmd.name
141
- when "SUBSCRIBE"
142
- on_subscribe(conn, cmd.data)
143
- when "CANCEL"
144
- on_cancel(conn, cmd.data)
137
+ case Protocol::ZMTP::Codec::Subscription.parse(frame)
138
+ in [:subscribe, prefix]
139
+ on_subscribe(conn, prefix)
140
+ in [:cancel, prefix]
141
+ on_cancel(conn, prefix)
142
+ else
143
+ next
145
144
  end
146
145
  end
147
146
  end
@@ -182,14 +181,16 @@ module OMQ
182
181
  #
183
182
  def start_conn_send_pump_normal(conn, q, use_wire)
184
183
  @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
184
+ batch = []
185
+
185
186
  loop do
186
- batch = [q.dequeue]
187
- Routing.drain_send_queue(q, batch)
187
+ Routing.dequeue_batch(q, batch)
188
188
 
189
189
  if write_matching_batch(conn, batch, use_wire)
190
190
  conn.flush
191
191
  batch.each { |parts| @engine.emit_verbose_msg_sent(conn, parts) }
192
192
  end
193
+ batch.clear
193
194
  end
194
195
  end
195
196
  end
@@ -228,14 +229,17 @@ module OMQ
228
229
  #
229
230
  def start_conn_send_pump_conflate(conn, q)
230
231
  @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
232
+ batch = []
233
+
231
234
  loop do
232
- batch = [q.dequeue]
233
- Routing.drain_send_queue(q, batch)
235
+ Routing.dequeue_batch(q, batch)
234
236
 
235
237
  # Keep only the latest message that matches the subscription.
236
238
  latest = batch.reverse.find do |parts|
237
239
  subscribed?(conn, parts.first || EMPTY_BINARY)
238
240
  end
241
+
242
+ batch.clear
239
243
  next unless latest
240
244
 
241
245
  conn.write_message(latest)
@@ -106,9 +106,10 @@ module OMQ
106
106
 
107
107
  def start_send_pump(conn)
108
108
  @send_pump = @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
109
+ batch = []
110
+
109
111
  loop do
110
- batch = [@send_queue.dequeue]
111
- Routing.drain_send_queue(@send_queue, batch)
112
+ Routing.dequeue_batch(@send_queue, batch)
112
113
 
113
114
  if batch.size == 1
114
115
  conn.write_message(batch.first)
@@ -120,6 +121,7 @@ module OMQ
120
121
  batch.each do |parts|
121
122
  @engine.emit_verbose_msg_sent(conn, parts)
122
123
  end
124
+ batch.clear
123
125
  end
124
126
  end
125
127
 
@@ -55,7 +55,7 @@ module OMQ
55
55
  envelope = msg[0, delimiter]
56
56
  body = msg[(delimiter + 1)..] || []
57
57
 
58
- @pending_replies << { conn: connection, envelope: envelope }
58
+ @pending_replies << [connection, envelope]
59
59
  body
60
60
  end
61
61
  @tasks << task if task
@@ -69,7 +69,7 @@ module OMQ
69
69
  # @param connection [Connection]
70
70
  #
71
71
  def connection_removed(connection)
72
- @pending_replies.reject! { |r| r[:conn] == connection }
72
+ @pending_replies.reject! { |r| r[0] == connection }
73
73
  @conn_queues.delete(connection)
74
74
  @conn_send_tasks.delete(connection)&.stop
75
75
  end
@@ -83,8 +83,12 @@ module OMQ
83
83
  def enqueue(parts)
84
84
  reply_info = @pending_replies.shift
85
85
  return unless reply_info
86
- conn = reply_info[:conn]
87
- @conn_queues[conn]&.enqueue([*reply_info[:envelope], EMPTY_FRAME, *parts])
86
+
87
+ conn, envelope = reply_info
88
+ msg = envelope
89
+ msg << EMPTY_FRAME
90
+ msg.concat(parts)
91
+ @conn_queues[conn]&.enqueue(msg)
88
92
  end
89
93
 
90
94
 
@@ -93,7 +93,7 @@ module OMQ
93
93
  # REQ prepends empty delimiter frame on the wire.
94
94
  #
95
95
  def transform_send(parts)
96
- [EMPTY_BINARY, *parts]
96
+ parts.dup.unshift(EMPTY_BINARY)
97
97
  end
98
98
 
99
99
  end
@@ -133,9 +133,10 @@ module OMQ
133
133
  #
134
134
  def start_conn_send_pump(conn)
135
135
  task = @engine.spawn_conn_pump_task(conn, annotation: "send pump") do
136
+ batch = []
137
+
136
138
  loop do
137
- batch = [@send_queue.dequeue]
138
- drain_send_queue_capped(batch)
139
+ dequeue_batch_capped(batch)
139
140
  @in_flight += batch.size
140
141
 
141
142
  begin
@@ -147,6 +148,7 @@ module OMQ
147
148
  batch.each do |parts|
148
149
  @engine.emit_verbose_msg_sent(conn, parts)
149
150
  end
151
+ batch.clear
150
152
 
151
153
  Async::Task.current.yield
152
154
  end
@@ -157,7 +159,8 @@ module OMQ
157
159
  end
158
160
 
159
161
 
160
- def drain_send_queue_capped(batch)
162
+ def dequeue_batch_capped(batch = [])
163
+ batch << @send_queue.dequeue
161
164
  bytes = batch_bytes(batch.first)
162
165
 
163
166
  while batch.size < BATCH_MSG_CAP && bytes < BATCH_BYTE_CAP
@@ -48,7 +48,7 @@ module OMQ
48
48
  @connections << connection
49
49
 
50
50
  @subscriptions.each do |prefix|
51
- connection.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
51
+ send_subscribe(connection, prefix)
52
52
  end
53
53
 
54
54
  task = @engine.start_recv_pump(connection, @recv_queue)
@@ -76,9 +76,7 @@ module OMQ
76
76
  #
77
77
  def subscribe(prefix)
78
78
  @subscriptions << prefix
79
- @connections.each do |conn|
80
- conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
81
- end
79
+ @connections.each { |conn| send_subscribe(conn, prefix) }
82
80
  end
83
81
 
84
82
 
@@ -88,9 +86,7 @@ module OMQ
88
86
  #
89
87
  def unsubscribe(prefix)
90
88
  @subscriptions.delete(prefix)
91
- @connections.each do |conn|
92
- conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
93
- end
89
+ @connections.each { |conn| send_cancel(conn, prefix) }
94
90
  end
95
91
 
96
92
 
@@ -103,6 +99,30 @@ module OMQ
103
99
  @tasks.clear
104
100
  end
105
101
 
102
+
103
+ private
104
+
105
+
106
+ # Sends a SUBSCRIBE to +conn+ using the wire form the peer understands:
107
+ # command-form for ZMTP 3.1+, legacy message-form for ZMTP 3.0.
108
+ #
109
+ def send_subscribe(conn, prefix)
110
+ if conn.peer_minor >= 1
111
+ conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
112
+ else
113
+ conn.send_message([Protocol::ZMTP::Codec::Subscription.body(prefix)])
114
+ end
115
+ end
116
+
117
+
118
+ def send_cancel(conn, prefix)
119
+ if conn.peer_minor >= 1
120
+ conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
121
+ else
122
+ conn.send_message([Protocol::ZMTP::Codec::Subscription.body(prefix, cancel: true)])
123
+ end
124
+ end
125
+
106
126
  end
107
127
  end
108
128
  end
@@ -112,9 +112,19 @@ module OMQ
112
112
 
113
113
  case flag
114
114
  when 0x01
115
- conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
115
+ if conn.peer_minor >= 1
116
+ conn.send_command(Protocol::ZMTP::Codec::Command.subscribe(prefix))
117
+ else
118
+ conn.send_message([frame])
119
+ end
116
120
  when 0x00
117
- conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
121
+ if conn.peer_minor >= 1
122
+ conn.send_command(Protocol::ZMTP::Codec::Command.cancel(prefix))
123
+ else
124
+ conn.send_message([frame])
125
+ end
126
+ else
127
+ next
118
128
  end
119
129
  end
120
130
  end
data/lib/omq/routing.rb CHANGED
@@ -57,7 +57,8 @@ module OMQ
57
57
 
58
58
 
59
59
  # Drains all available messages from +queue+ into +batch+ without
60
- # blocking. Call after the initial blocking dequeue.
60
+ # Blocks for the first message, then sweeps all immediately
61
+ # available messages into +batch+ without blocking.
61
62
  #
62
63
  # No cap is needed: IO::Stream auto-flushes at 64 KB, so the
63
64
  # write buffer hits the wire naturally under sustained load.
@@ -67,7 +68,9 @@ module OMQ
67
68
  # @param batch [Array]
68
69
  # @return [void]
69
70
  #
70
- def self.drain_send_queue(queue, batch)
71
+ def self.dequeue_batch(queue, batch = [])
72
+ batch << queue.dequeue
73
+
71
74
  loop do
72
75
  msg = queue.dequeue(timeout: 0) or break
73
76
  batch << msg
@@ -20,6 +20,20 @@ module OMQ
20
20
  attr_reader :peer_socket_type
21
21
 
22
22
 
23
+ # @return [Integer] always 3 — inproc peers are OMQ
24
+ #
25
+ def peer_major
26
+ 3
27
+ end
28
+
29
+
30
+ # @return [Integer] always 1 — inproc peers are OMQ (ZMTP 3.1)
31
+ #
32
+ def peer_minor
33
+ 1
34
+ end
35
+
36
+
23
37
  # @return [String] peer's identity
24
38
  #
25
39
  attr_reader :peer_identity
@@ -158,19 +172,20 @@ module OMQ
158
172
  end
159
173
 
160
174
 
161
- # Reads one command frame from the internal command queue.
162
- # Used by PUB/XPUB subscription listeners.
175
+ # Reads one frame. Used by PUB/XPUB subscription listeners,
176
+ # which must see both the legacy message-form subscription
177
+ # (ZMTP 3.0) and the command-form (ZMTP 3.1).
163
178
  #
164
179
  # @return [Protocol::ZMTP::Codec::Frame]
165
180
  #
166
181
  def read_frame
167
- loop do
168
- item = @receive_queue.dequeue
169
- raise EOFError, "connection closed" if item.nil?
182
+ item = @receive_queue.dequeue
183
+ raise EOFError, "connection closed" if item.nil?
170
184
 
171
- if item.is_a?(Array) && item.first == :command
172
- return Protocol::ZMTP::Codec::Frame.new(item[1].to_body, command: true)
173
- end
185
+ if item.is_a?(Array) && item.first == :command
186
+ Protocol::ZMTP::Codec::Frame.new(item[1].to_body, command: true)
187
+ else
188
+ Protocol::ZMTP::Codec::Frame.new(item.first || "".b)
174
189
  end
175
190
  end
176
191
 
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.21.0"
4
+ VERSION = "0.22.1"
5
5
  end
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.21.0
4
+ version: 0.22.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.6'
18
+ version: '0.8'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.6'
25
+ version: '0.8'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: async
28
28
  requirement: !ruby/object:Gem::Requirement