omq 0.4.2 → 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: 0dc74658712882f8d0eb8f58b0c9a37c959d50c87116677d23be39904f7002e7
4
- data.tar.gz: 1ba17796cac6b1cd594ebbee874428e0675153d461d168e0f69f07abab608ef8
3
+ metadata.gz: 76cecd3650f9fb73d699a027570421f589452a40cf6bbf207af02afb2bc6df08
4
+ data.tar.gz: 0252e01bacd5278defe46ed9ab4750b3e33ca71e928beb13f1092c23a7c2ce62
5
5
  SHA512:
6
- metadata.gz: 3628a755a53010690ae407ac790eb83ef7592ac5cac34bddff8a62782c20cb6092c3dd300ae71a2dfff324eaf8457ba319305b6892ca259f8990ce63267c74e0
7
- data.tar.gz: 76502cad1a484cf8224285fbf4259e9ba8e5daa396caa42ac3716b50ada2e72a4c31b8d256208ab7410f5e48727216fee526efbf6166e3a1a32bb7bc1701cacb
6
+ metadata.gz: de2508cbe4e22c6dc93bdeab4626fed0c5280cf74eaaf4c4f886f566719a3c732f5db9b766c201f7e9d6eb7bb00ee4ed2d2392eec232abe88c7847148b889fc5
7
+ data.tar.gz: 65e96cef70697abe90588e5e35f4563e55ef183bb0be44276c6cb62ec007f6accb4e482a08c14d89b15ec3f542d75c78bacc87c6174ca6f4e69b588ddb540730
data/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
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
+
25
+ ## 0.5.0 — 2026-03-28
26
+
27
+ ### Added
28
+
29
+ - **Draft socket types** (RFCs 41, 48, 49, 51, 52):
30
+ - `CLIENT`/`SERVER` — thread-safe REQ/REP without envelope, 4-byte routing IDs
31
+ - `RADIO`/`DISH` — group-based pub/sub with exact match, JOIN/LEAVE commands.
32
+ `radio.publish(group, body)`, `radio.send(body, group:)`, `radio << [group, body]`
33
+ - `SCATTER`/`GATHER` — thread-safe PUSH/PULL
34
+ - `PEER` — bidirectional multi-peer with 4-byte routing IDs
35
+ - `CHANNEL` — thread-safe PAIR
36
+ - All draft types enforce single-frame messages (no multipart)
37
+ - Reconnect-after-restart tests for all 10 socket type pairings
38
+
39
+ ### Fixed
40
+
41
+ - **PUSH/SCATTER silently wrote to dead peers** — write-only sockets had
42
+ no recv pump to detect peer disconnection. Writes succeeded because the
43
+ kernel send buffer absorbed the data, preventing reconnect from
44
+ triggering. Added background monitor task per connection.
45
+ - **PAIR/CHANNEL stale send pump after reconnect** — old send pump kept
46
+ its captured connection reference and raced with the new send pump,
47
+ sending to the dead connection. Now stopped in `connection_removed`.
48
+
3
49
  ## 0.4.2 — 2026-03-27
4
50
 
5
51
  ### Fixed
data/README.md CHANGED
@@ -18,7 +18,7 @@ Pure Ruby implementation of the [ZMTP 3.1](https://rfc.zeromq.org/spec/23/) wire
18
18
  ## Highlights
19
19
 
20
20
  - **Pure Ruby** — no C extensions, no FFI, no libzmq/libczmq dependency
21
- - **All socket types** — req/rep, pub/sub, push/pull, dealer/router, xpub/xsub, pair
21
+ - **All socket types** — req/rep, pub/sub, push/pull, dealer/router, xpub/xsub, pair + draft types (client/server, radio/dish, scatter/gather, peer, channel)
22
22
  - **Async-native** — built on [Async](https://github.com/socketry/async) fibers, also works with plain threads
23
23
  - **Ruby-idiomatic API** — messages as `Array<String>`, errors as exceptions, timeouts as `IO::TimeoutError`
24
24
  - **All transports** — tcp, ipc, inproc
@@ -112,6 +112,11 @@ end
112
112
  | Pipeline | `PUSH`, `PULL` | unidirectional |
113
113
  | Routing | `DEALER`, `ROUTER` | bidirectional |
114
114
  | Exclusive pair | `PAIR` | bidirectional |
115
+ | Client/Server | `CLIENT`, `SERVER` | bidirectional |
116
+ | Group messaging | `RADIO`, `DISH` | unidirectional |
117
+ | Pipeline (draft) | `SCATTER`, `GATHER` | unidirectional |
118
+ | Peer-to-peer | `PEER` | bidirectional |
119
+ | Channel (draft) | `CHANNEL` | bidirectional |
115
120
 
116
121
  All classes live under `OMQ::`. For the purists, `ØMQ` is an alias:
117
122
 
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class CHANNEL < Socket
5
+ include ZMTP::Readable
6
+ include ZMTP::Writable
7
+ include ZMTP::SingleFrame
8
+
9
+ def initialize(endpoints = nil, linger: 0)
10
+ _init_engine(:CHANNEL, linger: linger)
11
+ _attach(endpoints, default: :connect)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class CLIENT < Socket
5
+ include ZMTP::Readable
6
+ include ZMTP::Writable
7
+ include ZMTP::SingleFrame
8
+
9
+ def initialize(endpoints = nil, linger: 0)
10
+ _init_engine(:CLIENT, linger: linger)
11
+ _attach(endpoints, default: :connect)
12
+ end
13
+ end
14
+
15
+ class SERVER < Socket
16
+ include ZMTP::Readable
17
+ include ZMTP::Writable
18
+ include ZMTP::SingleFrame
19
+
20
+ def initialize(endpoints = nil, linger: 0)
21
+ _init_engine(:SERVER, linger: linger)
22
+ _attach(endpoints, default: :bind)
23
+ end
24
+
25
+ # Sends a message to a specific peer by routing ID.
26
+ #
27
+ # @param routing_id [String] 4-byte routing ID
28
+ # @param message [String] message body
29
+ # @return [self]
30
+ #
31
+ def send_to(routing_id, message)
32
+ parts = [routing_id.b.freeze, message.b.freeze]
33
+ with_timeout(@options.write_timeout) { @engine.enqueue_send(parts) }
34
+ self
35
+ end
36
+ end
37
+ end
data/lib/omq/peer.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class PEER < Socket
5
+ include ZMTP::Readable
6
+ include ZMTP::Writable
7
+ include ZMTP::SingleFrame
8
+
9
+ def initialize(endpoints = nil, linger: 0)
10
+ _init_engine(:PEER, linger: linger)
11
+ _attach(endpoints, default: :connect)
12
+ end
13
+
14
+ # Sends a message to a specific peer by routing ID.
15
+ #
16
+ # @param routing_id [String] 4-byte routing ID
17
+ # @param message [String] message body
18
+ # @return [self]
19
+ #
20
+ def send_to(routing_id, message)
21
+ parts = [routing_id.b.freeze, message.b.freeze]
22
+ with_timeout(@options.write_timeout) { @engine.enqueue_send(parts) }
23
+ self
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class RADIO < Socket
5
+ include ZMTP::Writable
6
+
7
+ def initialize(endpoints = nil, linger: 0)
8
+ _init_engine(:RADIO, linger: linger)
9
+ _attach(endpoints, default: :bind)
10
+ end
11
+
12
+ # Publishes a message to a group.
13
+ #
14
+ # @param group [String] group name
15
+ # @param body [String] message body
16
+ # @return [self]
17
+ #
18
+ def publish(group, body)
19
+ with_timeout(@options.write_timeout) do
20
+ @engine.enqueue_send([group.b.freeze, body.b.freeze])
21
+ end
22
+ self
23
+ end
24
+
25
+ # Sends a message to a group.
26
+ #
27
+ # @param message [String] message body (requires group: kwarg)
28
+ # @param group [String] group name
29
+ # @return [self]
30
+ #
31
+ def send(message, group: nil)
32
+ raise ArgumentError, "RADIO requires a group (use group: kwarg, publish, or << [group, body])" unless group
33
+ publish(group, message)
34
+ end
35
+
36
+ # Sends a message to a group via [group, body] array.
37
+ #
38
+ # @param message [Array<String>] [group, body]
39
+ # @return [self]
40
+ #
41
+ def <<(message)
42
+ raise ArgumentError, "RADIO requires [group, body] array" unless message.is_a?(Array) && message.size == 2
43
+ publish(message[0], message[1])
44
+ end
45
+ end
46
+
47
+ class DISH < Socket
48
+ include ZMTP::Readable
49
+
50
+ def initialize(endpoints = nil, linger: 0, group: nil)
51
+ _init_engine(:DISH, linger: linger)
52
+ _attach(endpoints, default: :connect)
53
+ join(group) if group
54
+ end
55
+
56
+ # Joins a group.
57
+ #
58
+ # @param group [String]
59
+ # @return [void]
60
+ #
61
+ def join(group)
62
+ @engine.routing.join(group)
63
+ end
64
+
65
+ # Leaves a group.
66
+ #
67
+ # @param group [String]
68
+ # @return [void]
69
+ #
70
+ def leave(group)
71
+ @engine.routing.leave(group)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class SCATTER < Socket
5
+ include ZMTP::Writable
6
+ include ZMTP::SingleFrame
7
+
8
+ def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil)
9
+ _init_engine(:SCATTER, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout)
10
+ _attach(endpoints, default: :connect)
11
+ end
12
+ end
13
+
14
+ class GATHER < Socket
15
+ include ZMTP::Readable
16
+ include ZMTP::SingleFrame
17
+
18
+ def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil)
19
+ _init_engine(:GATHER, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout)
20
+ _attach(endpoints, default: :bind)
21
+ end
22
+ end
23
+ end
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.4.2"
4
+ VERSION = "0.5.1"
5
5
  end
@@ -101,6 +101,24 @@ module OMQ
101
101
  new("CANCEL", prefix.b)
102
102
  end
103
103
 
104
+ # Builds a JOIN command (RADIO/DISH group subscription).
105
+ #
106
+ # @param group [String] group name
107
+ # @return [Command]
108
+ #
109
+ def self.join(group)
110
+ new("JOIN", group.b)
111
+ end
112
+
113
+ # Builds a LEAVE command (RADIO/DISH group unsubscription).
114
+ #
115
+ # @param group [String] group name
116
+ # @return [Command]
117
+ #
118
+ def self.leave(group)
119
+ new("LEAVE", group.b)
120
+ end
121
+
104
122
  # Builds a PING command.
105
123
  #
106
124
  # @param ttl [Numeric] time-to-live in seconds (sent as deciseconds)
@@ -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
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # CHANNEL socket routing: exclusive 1-to-1 bidirectional.
7
+ #
8
+ class Channel
9
+
10
+ # @param engine [Engine]
11
+ #
12
+ def initialize(engine)
13
+ @engine = engine
14
+ @connection = nil
15
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
16
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
17
+ @tasks = []
18
+ end
19
+
20
+ # @return [Async::LimitedQueue]
21
+ #
22
+ attr_reader :recv_queue, :send_queue
23
+
24
+ # @param connection [Connection]
25
+ # @raise [RuntimeError] if a connection already exists
26
+ #
27
+ def connection_added(connection)
28
+ raise "CHANNEL allows only one peer" if @connection
29
+ @connection = connection
30
+ task = @engine.start_recv_pump(connection, @recv_queue)
31
+ @tasks << task if task
32
+ start_send_pump(connection)
33
+ end
34
+
35
+ # @param connection [Connection]
36
+ #
37
+ def connection_removed(connection)
38
+ if @connection == connection
39
+ @connection = nil
40
+ @send_pump&.stop
41
+ @send_pump = nil
42
+ end
43
+ end
44
+
45
+ # @param parts [Array<String>]
46
+ #
47
+ def enqueue(parts)
48
+ @send_queue.enqueue(parts)
49
+ end
50
+
51
+ #
52
+ def stop
53
+ @tasks.each(&:stop)
54
+ @tasks.clear
55
+ end
56
+
57
+ private
58
+
59
+ def start_send_pump(conn)
60
+ @send_pump = Reactor.spawn_pump do
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
67
+ rescue *ZMTP::CONNECTION_LOST
68
+ @engine.connection_lost(conn)
69
+ end
70
+ @tasks << @send_pump
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # CLIENT socket routing: round-robin send, fair-queue receive.
7
+ #
8
+ # Same as DEALER — no envelope manipulation.
9
+ #
10
+ class Client
11
+ include RoundRobin
12
+
13
+ # @param engine [Engine]
14
+ #
15
+ def initialize(engine)
16
+ @engine = engine
17
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
18
+ @tasks = []
19
+ init_round_robin(engine)
20
+ end
21
+
22
+ # @return [Async::LimitedQueue]
23
+ #
24
+ attr_reader :recv_queue, :send_queue
25
+
26
+ # @param connection [Connection]
27
+ #
28
+ def connection_added(connection)
29
+ @connections << connection
30
+ signal_connection_available
31
+ task = @engine.start_recv_pump(connection, @recv_queue)
32
+ @tasks << task if task
33
+ start_send_pump unless @send_pump_started
34
+ end
35
+
36
+ # @param connection [Connection]
37
+ #
38
+ def connection_removed(connection)
39
+ @connections.delete(connection)
40
+ end
41
+
42
+ # @param parts [Array<String>]
43
+ #
44
+ def enqueue(parts)
45
+ @send_queue.enqueue(parts)
46
+ end
47
+
48
+ #
49
+ def stop
50
+ @tasks.each(&:stop)
51
+ @tasks.clear
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # DISH socket routing: group-based receive from RADIO peers.
7
+ #
8
+ # Sends JOIN/LEAVE commands to connected RADIO peers.
9
+ # Receives two-frame messages (group + body) from RADIO.
10
+ #
11
+ class Dish
12
+
13
+ # @param engine [Engine]
14
+ #
15
+ def initialize(engine)
16
+ @engine = engine
17
+ @connections = []
18
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
19
+ @groups = Set.new
20
+ @tasks = []
21
+ end
22
+
23
+ # @return [Async::LimitedQueue]
24
+ #
25
+ attr_reader :recv_queue
26
+
27
+ # @param connection [Connection]
28
+ #
29
+ def connection_added(connection)
30
+ @connections << connection
31
+ # Send existing group memberships to new peer
32
+ @groups.each do |group|
33
+ connection.send_command(Codec::Command.join(group))
34
+ end
35
+ task = @engine.start_recv_pump(connection, @recv_queue)
36
+ @tasks << task if task
37
+ end
38
+
39
+ # @param connection [Connection]
40
+ #
41
+ def connection_removed(connection)
42
+ @connections.delete(connection)
43
+ end
44
+
45
+ # DISH is read-only.
46
+ #
47
+ def enqueue(_parts)
48
+ raise "DISH sockets cannot send"
49
+ end
50
+
51
+ # Joins a group.
52
+ #
53
+ # @param group [String]
54
+ #
55
+ def join(group)
56
+ @groups << group
57
+ @connections.each do |conn|
58
+ conn.send_command(Codec::Command.join(group))
59
+ end
60
+ end
61
+
62
+ # Leaves a group.
63
+ #
64
+ # @param group [String]
65
+ #
66
+ def leave(group)
67
+ @groups.delete(group)
68
+ @connections.each do |conn|
69
+ conn.send_command(Codec::Command.leave(group))
70
+ end
71
+ end
72
+
73
+ def stop
74
+ @tasks.each(&:stop)
75
+ @tasks.clear
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end