omq 0.4.2 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0dc74658712882f8d0eb8f58b0c9a37c959d50c87116677d23be39904f7002e7
4
- data.tar.gz: 1ba17796cac6b1cd594ebbee874428e0675153d461d168e0f69f07abab608ef8
3
+ metadata.gz: 37d1cbdb4834bd8c3171e5317403066d6cc84f454ab4f3c076655019480634ad
4
+ data.tar.gz: 95e1712a3c993aad845d499c5b2462c42ab6720aa4c7f4134aab05fd4f972ea5
5
5
  SHA512:
6
- metadata.gz: 3628a755a53010690ae407ac790eb83ef7592ac5cac34bddff8a62782c20cb6092c3dd300ae71a2dfff324eaf8457ba319305b6892ca259f8990ce63267c74e0
7
- data.tar.gz: 76502cad1a484cf8224285fbf4259e9ba8e5daa396caa42ac3716b50ada2e72a4c31b8d256208ab7410f5e48727216fee526efbf6166e3a1a32bb7bc1701cacb
6
+ metadata.gz: 4e8412637f3eb2adce4451866217018e2947ab0e1ff1324664ff9946d658115ab042d3c429811d0d7c269704c54bb7c6ba2902570a8e882a2e5349b9504665c7
7
+ data.tar.gz: fc54cbabda462a9a50a9135f9c7f4da202229a768c74f60fff8cfe56fccd6ad7bdfd68a90e4e4525e091b8d0b71640692997596978b696596a33d572227bdbd3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 — 2026-03-28
4
+
5
+ ### Added
6
+
7
+ - **Draft socket types** (RFCs 41, 48, 49, 51, 52):
8
+ - `CLIENT`/`SERVER` — thread-safe REQ/REP without envelope, 4-byte routing IDs
9
+ - `RADIO`/`DISH` — group-based pub/sub with exact match, JOIN/LEAVE commands.
10
+ `radio.publish(group, body)`, `radio.send(body, group:)`, `radio << [group, body]`
11
+ - `SCATTER`/`GATHER` — thread-safe PUSH/PULL
12
+ - `PEER` — bidirectional multi-peer with 4-byte routing IDs
13
+ - `CHANNEL` — thread-safe PAIR
14
+ - All draft types enforce single-frame messages (no multipart)
15
+ - Reconnect-after-restart tests for all 10 socket type pairings
16
+
17
+ ### Fixed
18
+
19
+ - **PUSH/SCATTER silently wrote to dead peers** — write-only sockets had
20
+ no recv pump to detect peer disconnection. Writes succeeded because the
21
+ kernel send buffer absorbed the data, preventing reconnect from
22
+ triggering. Added background monitor task per connection.
23
+ - **PAIR/CHANNEL stale send pump after reconnect** — old send pump kept
24
+ its captured connection reference and raced with the new send pump,
25
+ sending to the dead connection. Now stopped in `connection_removed`.
26
+
3
27
  ## 0.4.2 — 2026-03-27
4
28
 
5
29
  ### 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.0"
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)
@@ -0,0 +1,70 @@
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 { conn.send_message(@send_queue.dequeue) }
62
+ rescue *ZMTP::CONNECTION_LOST
63
+ @engine.connection_lost(conn)
64
+ end
65
+ @tasks << @send_pump
66
+ end
67
+ end
68
+ end
69
+ end
70
+ 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
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # GATHER socket routing: fair-queue receive from SCATTER peers.
7
+ #
8
+ class Gather
9
+ # @param engine [Engine]
10
+ #
11
+ def initialize(engine)
12
+ @engine = engine
13
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
14
+ @tasks = []
15
+ end
16
+
17
+ # @return [Async::LimitedQueue]
18
+ #
19
+ attr_reader :recv_queue
20
+
21
+ # @param connection [Connection]
22
+ #
23
+ def connection_added(connection)
24
+ task = @engine.start_recv_pump(connection, @recv_queue)
25
+ @tasks << task if task
26
+ end
27
+
28
+ # @param connection [Connection]
29
+ #
30
+ def connection_removed(connection)
31
+ # recv pump stops on CONNECTION_LOST
32
+ end
33
+
34
+ # GATHER is read-only.
35
+ #
36
+ def enqueue(_parts)
37
+ raise "GATHER sockets cannot send"
38
+ end
39
+
40
+ #
41
+ def stop
42
+ @tasks.each(&:stop)
43
+ @tasks.clear
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -38,7 +38,11 @@ module OMQ
38
38
  # @param connection [Connection]
39
39
  #
40
40
  def connection_removed(connection)
41
- @connection = nil if @connection == connection
41
+ if @connection == connection
42
+ @connection = nil
43
+ @send_pump&.stop
44
+ @send_pump = nil
45
+ end
42
46
  end
43
47
 
44
48
  # @param parts [Array<String>]
@@ -56,11 +60,12 @@ module OMQ
56
60
  private
57
61
 
58
62
  def start_send_pump(conn)
59
- @tasks << Reactor.spawn_pump do
63
+ @send_pump = Reactor.spawn_pump do
60
64
  loop { conn.send_message(@send_queue.dequeue) }
61
65
  rescue *ZMTP::CONNECTION_LOST
62
66
  @engine.connection_lost(conn)
63
67
  end
68
+ @tasks << @send_pump
64
69
  end
65
70
  end
66
71
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OMQ
6
+ module ZMTP
7
+ module Routing
8
+ # PEER socket routing: bidirectional multi-peer with auto-generated
9
+ # 4-byte routing IDs.
10
+ #
11
+ # Prepends routing ID on receive. Strips routing ID on send and
12
+ # routes to the identified connection.
13
+ #
14
+ class Peer
15
+ # @param engine [Engine]
16
+ #
17
+ def initialize(engine)
18
+ @engine = engine
19
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
20
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
21
+ @connections_by_routing_id = {}
22
+ @tasks = []
23
+ @send_pump_started = false
24
+ end
25
+
26
+ # @return [Async::LimitedQueue]
27
+ #
28
+ attr_reader :recv_queue, :send_queue
29
+
30
+ # @param connection [Connection]
31
+ #
32
+ def connection_added(connection)
33
+ routing_id = SecureRandom.bytes(4)
34
+ @connections_by_routing_id[routing_id] = connection
35
+
36
+ task = @engine.start_recv_pump(connection, @recv_queue,
37
+ transform: ->(msg) { [routing_id, *msg] })
38
+ @tasks << task if task
39
+
40
+ start_send_pump unless @send_pump_started
41
+ end
42
+
43
+ # @param connection [Connection]
44
+ #
45
+ def connection_removed(connection)
46
+ @connections_by_routing_id.reject! { |_, c| c == connection }
47
+ end
48
+
49
+ # @param parts [Array<String>]
50
+ #
51
+ def enqueue(parts)
52
+ @send_queue.enqueue(parts)
53
+ end
54
+
55
+ def stop
56
+ @tasks.each(&:stop)
57
+ @tasks.clear
58
+ end
59
+
60
+ private
61
+
62
+ def start_send_pump
63
+ @send_pump_started = true
64
+ @tasks << Reactor.spawn_pump do
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..])
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -32,6 +32,7 @@ module OMQ
32
32
  @connections << connection
33
33
  signal_connection_available
34
34
  start_send_pump unless @send_pump_started
35
+ start_monitor(connection)
35
36
  end
36
37
 
37
38
  # @param connection [Connection]
@@ -51,6 +52,21 @@ module OMQ
51
52
  @tasks.each(&:stop)
52
53
  @tasks.clear
53
54
  end
55
+
56
+ private
57
+
58
+ # Monitors a connection for disconnection.
59
+ # Write-only sockets have no recv pump, so without this monitor
60
+ # a dead peer is only detected on the next send — which may
61
+ # succeed if the kernel send buffer absorbs the data.
62
+ #
63
+ def start_monitor(conn)
64
+ @tasks << Reactor.spawn_pump do
65
+ conn.receive_message # blocks until peer disconnects
66
+ rescue *ZMTP::CONNECTION_LOST
67
+ @engine.connection_lost(conn)
68
+ end
69
+ end
54
70
  end
55
71
  end
56
72
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # RADIO socket routing: group-based fan-out to DISH peers.
7
+ #
8
+ # Like PUB/FanOut but with exact group matching and JOIN/LEAVE
9
+ # commands instead of SUBSCRIBE/CANCEL.
10
+ #
11
+ # Messages are sent as two frames on the wire:
12
+ # group (MORE=1) + body (MORE=0)
13
+ #
14
+ class Radio
15
+
16
+ # @param engine [Engine]
17
+ #
18
+ def initialize(engine)
19
+ @engine = engine
20
+ @connections = []
21
+ @groups = {} # connection => Set of joined groups
22
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
23
+ @send_pump_started = false
24
+ @tasks = []
25
+ end
26
+
27
+ # @return [Async::LimitedQueue]
28
+ #
29
+ attr_reader :send_queue
30
+
31
+ # RADIO is write-only.
32
+ #
33
+ def recv_queue
34
+ raise "RADIO sockets cannot receive"
35
+ end
36
+
37
+ # @param connection [Connection]
38
+ #
39
+ def connection_added(connection)
40
+ @connections << connection
41
+ @groups[connection] = Set.new
42
+ start_group_listener(connection)
43
+ start_send_pump unless @send_pump_started
44
+ end
45
+
46
+ # @param connection [Connection]
47
+ #
48
+ def connection_removed(connection)
49
+ @connections.delete(connection)
50
+ @groups.delete(connection)
51
+ end
52
+
53
+ # Enqueues a message for sending.
54
+ #
55
+ # @param parts [Array<String>] [group, body]
56
+ #
57
+ def enqueue(parts)
58
+ @send_queue.enqueue(parts)
59
+ end
60
+
61
+ def stop
62
+ @tasks.each(&:stop)
63
+ @tasks.clear
64
+ end
65
+
66
+ private
67
+
68
+ def start_send_pump
69
+ @send_pump_started = true
70
+ @tasks << Reactor.spawn_pump do
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
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def start_group_listener(conn)
89
+ @tasks << Reactor.spawn_pump do
90
+ loop do
91
+ frame = conn.read_frame
92
+ next unless frame.command?
93
+ cmd = Codec::Command.from_body(frame.body)
94
+ case cmd.name
95
+ when "JOIN" then @groups[conn]&.add(cmd.data)
96
+ when "LEAVE" then @groups[conn]&.delete(cmd.data)
97
+ end
98
+ end
99
+ rescue *ZMTP::CONNECTION_LOST
100
+ @engine.connection_lost(conn)
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # SCATTER socket routing: round-robin send to GATHER peers.
7
+ #
8
+ class Scatter
9
+ include RoundRobin
10
+
11
+ # @param engine [Engine]
12
+ #
13
+ def initialize(engine)
14
+ @engine = engine
15
+ @tasks = []
16
+ init_round_robin(engine)
17
+ end
18
+
19
+ # @return [Async::LimitedQueue]
20
+ #
21
+ attr_reader :send_queue
22
+
23
+ # SCATTER is write-only.
24
+ #
25
+ def recv_queue
26
+ raise "SCATTER sockets cannot receive"
27
+ end
28
+
29
+ # @param connection [Connection]
30
+ #
31
+ def connection_added(connection)
32
+ @connections << connection
33
+ signal_connection_available
34
+ start_send_pump unless @send_pump_started
35
+ start_monitor(connection)
36
+ end
37
+
38
+ # @param connection [Connection]
39
+ #
40
+ def connection_removed(connection)
41
+ @connections.delete(connection)
42
+ end
43
+
44
+ # @param parts [Array<String>]
45
+ #
46
+ def enqueue(parts)
47
+ @send_queue.enqueue(parts)
48
+ end
49
+
50
+ #
51
+ def stop
52
+ @tasks.each(&:stop)
53
+ @tasks.clear
54
+ end
55
+
56
+ private
57
+
58
+ def start_monitor(conn)
59
+ @tasks << Reactor.spawn_pump do
60
+ conn.receive_message
61
+ rescue *ZMTP::CONNECTION_LOST
62
+ @engine.connection_lost(conn)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module OMQ
6
+ module ZMTP
7
+ module Routing
8
+ # SERVER socket routing: identity-based routing with auto-generated
9
+ # 4-byte routing IDs.
10
+ #
11
+ # Prepends routing ID on receive. Strips routing ID on send and
12
+ # routes to the identified connection.
13
+ #
14
+ class Server
15
+ # @param engine [Engine]
16
+ #
17
+ def initialize(engine)
18
+ @engine = engine
19
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
20
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
21
+ @connections_by_routing_id = {}
22
+ @tasks = []
23
+ @send_pump_started = false
24
+ end
25
+
26
+ # @return [Async::LimitedQueue]
27
+ #
28
+ attr_reader :recv_queue, :send_queue
29
+
30
+ # @param connection [Connection]
31
+ #
32
+ def connection_added(connection)
33
+ routing_id = SecureRandom.bytes(4)
34
+ @connections_by_routing_id[routing_id] = connection
35
+
36
+ task = @engine.start_recv_pump(connection, @recv_queue,
37
+ transform: ->(msg) { [routing_id, *msg] })
38
+ @tasks << task if task
39
+
40
+ start_send_pump unless @send_pump_started
41
+ end
42
+
43
+ # @param connection [Connection]
44
+ #
45
+ def connection_removed(connection)
46
+ @connections_by_routing_id.reject! { |_, c| c == connection }
47
+ end
48
+
49
+ # @param parts [Array<String>]
50
+ #
51
+ def enqueue(parts)
52
+ @send_queue.enqueue(parts)
53
+ end
54
+
55
+ def stop
56
+ @tasks.each(&:stop)
57
+ @tasks.clear
58
+ end
59
+
60
+ private
61
+
62
+ def start_send_pump
63
+ @send_pump_started = true
64
+ @tasks << Reactor.spawn_pump do
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..])
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -28,8 +28,16 @@ module OMQ
28
28
  when :SUB then Sub
29
29
  when :XPUB then XPub
30
30
  when :XSUB then XSub
31
- when :PUSH then Push
32
- when :PULL then Pull
31
+ when :PUSH then Push
32
+ when :PULL then Pull
33
+ when :CLIENT then Client
34
+ when :SERVER then Server
35
+ when :RADIO then Radio
36
+ when :DISH then Dish
37
+ when :SCATTER then Scatter
38
+ when :GATHER then Gather
39
+ when :PEER then Peer
40
+ when :CHANNEL then Channel
33
41
  else raise ArgumentError, "unknown socket type: #{socket_type}"
34
42
  end
35
43
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ # Mixin that rejects multipart messages.
6
+ #
7
+ # All draft socket types (CLIENT, SERVER, RADIO, DISH, SCATTER,
8
+ # GATHER, PEER, CHANNEL) require single-frame messages for
9
+ # thread-safe atomic operations.
10
+ #
11
+ module SingleFrame
12
+ def send(message)
13
+ if message.is_a?(Array) && message.size > 1
14
+ raise ArgumentError, "#{self.class} does not support multipart messages"
15
+ end
16
+ super
17
+ end
18
+ end
19
+ end
20
+ end
@@ -17,7 +17,7 @@ module OMQ
17
17
  module Inproc
18
18
  # Socket types that exchange commands (SUBSCRIBE/CANCEL) over inproc.
19
19
  #
20
- COMMAND_TYPES = %i[PUB SUB XPUB XSUB].freeze
20
+ COMMAND_TYPES = %i[PUB SUB XPUB XSUB RADIO DISH].freeze
21
21
 
22
22
  # Global registry of bound inproc endpoints.
23
23
  #
@@ -14,8 +14,16 @@ module OMQ
14
14
  SUB: %i[PUB XPUB].freeze,
15
15
  XPUB: %i[SUB XSUB].freeze,
16
16
  XSUB: %i[PUB XPUB].freeze,
17
- PUSH: %i[PULL].freeze,
18
- PULL: %i[PUSH].freeze,
17
+ PUSH: %i[PULL].freeze,
18
+ PULL: %i[PUSH].freeze,
19
+ CLIENT: %i[SERVER].freeze,
20
+ SERVER: %i[CLIENT].freeze,
21
+ RADIO: %i[DISH].freeze,
22
+ DISH: %i[RADIO].freeze,
23
+ SCATTER: %i[GATHER].freeze,
24
+ GATHER: %i[SCATTER].freeze,
25
+ PEER: %i[PEER].freeze,
26
+ CHANNEL: %i[CHANNEL].freeze,
19
27
  }.freeze
20
28
  end
21
29
  end
data/lib/omq/zmtp.rb CHANGED
@@ -63,6 +63,15 @@ require_relative "zmtp/routing/xpub"
63
63
  require_relative "zmtp/routing/xsub"
64
64
  require_relative "zmtp/routing/push"
65
65
  require_relative "zmtp/routing/pull"
66
+ require_relative "zmtp/routing/scatter"
67
+ require_relative "zmtp/routing/gather"
68
+ require_relative "zmtp/routing/channel"
69
+ require_relative "zmtp/routing/client"
70
+ require_relative "zmtp/routing/server"
71
+ require_relative "zmtp/routing/radio"
72
+ require_relative "zmtp/routing/dish"
73
+ require_relative "zmtp/routing/peer"
74
+ require_relative "zmtp/single_frame"
66
75
  require_relative "zmtp/engine"
67
76
  require_relative "zmtp/readable"
68
77
  require_relative "zmtp/writable"
data/lib/omq.rb CHANGED
@@ -17,6 +17,11 @@ require_relative "omq/router_dealer"
17
17
  require_relative "omq/pub_sub"
18
18
  require_relative "omq/push_pull"
19
19
  require_relative "omq/pair"
20
+ require_relative "omq/scatter_gather"
21
+ require_relative "omq/channel"
22
+ require_relative "omq/client_server"
23
+ require_relative "omq/radio_dish"
24
+ require_relative "omq/peer"
20
25
 
21
26
  # For the purists.
22
27
  ØMQ = OMQ
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.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -51,11 +51,16 @@ files:
51
51
  - README.md
52
52
  - exe/omqcat
53
53
  - lib/omq.rb
54
+ - lib/omq/channel.rb
55
+ - lib/omq/client_server.rb
54
56
  - lib/omq/pair.rb
57
+ - lib/omq/peer.rb
55
58
  - lib/omq/pub_sub.rb
56
59
  - lib/omq/push_pull.rb
60
+ - lib/omq/radio_dish.rb
57
61
  - lib/omq/req_rep.rb
58
62
  - lib/omq/router_dealer.rb
63
+ - lib/omq/scatter_gather.rb
59
64
  - lib/omq/socket.rb
60
65
  - lib/omq/version.rb
61
66
  - lib/omq/zmtp.rb
@@ -70,19 +75,28 @@ files:
70
75
  - lib/omq/zmtp/reactor.rb
71
76
  - lib/omq/zmtp/readable.rb
72
77
  - lib/omq/zmtp/routing.rb
78
+ - lib/omq/zmtp/routing/channel.rb
79
+ - lib/omq/zmtp/routing/client.rb
73
80
  - lib/omq/zmtp/routing/dealer.rb
81
+ - lib/omq/zmtp/routing/dish.rb
74
82
  - lib/omq/zmtp/routing/fan_out.rb
83
+ - lib/omq/zmtp/routing/gather.rb
75
84
  - lib/omq/zmtp/routing/pair.rb
85
+ - lib/omq/zmtp/routing/peer.rb
76
86
  - lib/omq/zmtp/routing/pub.rb
77
87
  - lib/omq/zmtp/routing/pull.rb
78
88
  - lib/omq/zmtp/routing/push.rb
89
+ - lib/omq/zmtp/routing/radio.rb
79
90
  - lib/omq/zmtp/routing/rep.rb
80
91
  - lib/omq/zmtp/routing/req.rb
81
92
  - lib/omq/zmtp/routing/round_robin.rb
82
93
  - lib/omq/zmtp/routing/router.rb
94
+ - lib/omq/zmtp/routing/scatter.rb
95
+ - lib/omq/zmtp/routing/server.rb
83
96
  - lib/omq/zmtp/routing/sub.rb
84
97
  - lib/omq/zmtp/routing/xpub.rb
85
98
  - lib/omq/zmtp/routing/xsub.rb
99
+ - lib/omq/zmtp/single_frame.rb
86
100
  - lib/omq/zmtp/transport/inproc.rb
87
101
  - lib/omq/zmtp/transport/ipc.rb
88
102
  - lib/omq/zmtp/transport/tcp.rb