omq-draft 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 227c70413e95a09337fe74eff2c92393a218456aa16c75cb45bd2312cedf08af
4
+ data.tar.gz: 14345eedca4ea644f478fb3c2538a06f93eb8bf387f7a9f3a2664aace0022aad
5
+ SHA512:
6
+ metadata.gz: 75cea8ecb3cd08f469897a0ceeef2bac1bc6c087b0cf3b5f60b77bc2ce53891338f62fcddb3cf024077565b30383badfe8b8ecfb1950fb8d9d740a39c4040b7f
7
+ data.tar.gz: 5f691e089cdaa0010955948bd722f844768a1e678dce56d5fd75da7b795a6b6fdadd782b84c751647979a68f0b7b1969fff39aad7e2fc9c8dd88bbded1314ff9
data/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ ISC License
2
+
3
+ Copyright (c) 2025-2026, Patrik Wenger
4
+
5
+ Permission to use, copy, modify, and/or distribute this software for any
6
+ purpose with or without fee is hereby granted, provided that the above
7
+ copyright notice and this permission notice appear in all copies.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # omq-draft
2
+
3
+ Draft ZMQ socket types for [OMQ](https://github.com/zeromq/omq) — the pure Ruby ZeroMQ library.
4
+
5
+ Implements the ZeroMQ draft RFCs as opt-in, separately loadable modules:
6
+
7
+ | Require | Socket types | Transport |
8
+ |---|---|---|
9
+ | `omq/draft/radiodish` | `OMQ::RADIO`, `OMQ::DISH` | TCP, IPC, inproc, **UDP** |
10
+ | `omq/draft/clientserver` | `OMQ::CLIENT`, `OMQ::SERVER` | TCP, IPC, inproc |
11
+ | `omq/draft/scattergather` | `OMQ::SCATTER`, `OMQ::GATHER` | TCP, IPC, inproc |
12
+ | `omq/draft/channel` | `OMQ::CHANNEL` | TCP, IPC, inproc |
13
+ | `omq/draft/p2p` | `OMQ::PEER` | TCP, IPC, inproc |
14
+
15
+ ## Installation
16
+
17
+ ```ruby
18
+ gem "omq-draft"
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Load only the socket types you need:
24
+
25
+ ```ruby
26
+ require "omq/draft/radiodish"
27
+
28
+ radio = OMQ::RADIO.connect("udp://127.0.0.1:5555")
29
+ dish = OMQ::DISH.bind("udp://127.0.0.1:5555")
30
+ dish.join("weather")
31
+
32
+ radio.publish("weather", "sunny")
33
+ puts dish.receive.inspect # => ["weather", "sunny"]
34
+ ```
35
+
36
+ ```ruby
37
+ require "omq/draft/clientserver"
38
+
39
+ server = OMQ::SERVER.bind("tcp://127.0.0.1:5555")
40
+ client = OMQ::CLIENT.connect("tcp://127.0.0.1:5555")
41
+
42
+ client.send("hello")
43
+ routing_id, body = server.receive
44
+ server.send_to(routing_id, "world")
45
+ puts client.receive.inspect # => ["world"]
46
+ ```
47
+
48
+ ## Development
49
+
50
+ ```sh
51
+ export OMQ_DEV=1 # use ../omq from source
52
+ bundle install
53
+ bundle exec rake
54
+ ```
55
+
56
+ ## License
57
+
58
+ ISC
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class CHANNEL < Socket
5
+ include Readable
6
+ include Writable
7
+ include SingleFrame
8
+
9
+ def initialize(endpoints = nil, linger: 0, backend: nil)
10
+ _init_engine(:CHANNEL, linger: linger, backend: backend)
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 Readable
6
+ include Writable
7
+ include SingleFrame
8
+
9
+ def initialize(endpoints = nil, linger: 0, backend: nil)
10
+ _init_engine(:CLIENT, linger: linger, backend: backend)
11
+ _attach(endpoints, default: :connect)
12
+ end
13
+ end
14
+
15
+ class SERVER < Socket
16
+ include Readable
17
+ include Writable
18
+ include SingleFrame
19
+
20
+ def initialize(endpoints = nil, linger: 0, backend: nil)
21
+ _init_engine(:SERVER, linger: linger, backend: backend)
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
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ draft CHANNEL socket type.
4
+ #
5
+ # Usage:
6
+ # require "omq/draft/channel"
7
+ #
8
+ # a = OMQ::CHANNEL.bind("tcp://127.0.0.1:5555")
9
+ # b = OMQ::CHANNEL.connect("tcp://127.0.0.1:5555")
10
+
11
+ require "omq"
12
+
13
+ require_relative "../single_frame"
14
+ require_relative "../routing/channel"
15
+ require_relative "../channel"
16
+
17
+ OMQ::Routing.register(:CHANNEL, OMQ::Routing::Channel)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ draft CLIENT/SERVER socket types.
4
+ #
5
+ # Usage:
6
+ # require "omq/draft/clientserver"
7
+ #
8
+ # server = OMQ::SERVER.bind("tcp://127.0.0.1:5555")
9
+ # client = OMQ::CLIENT.connect("tcp://127.0.0.1:5555")
10
+
11
+ require "omq"
12
+
13
+ require_relative "../single_frame"
14
+ require_relative "../routing/client"
15
+ require_relative "../routing/server"
16
+ require_relative "../client_server"
17
+
18
+ OMQ::Routing.register(:CLIENT, OMQ::Routing::Client)
19
+ OMQ::Routing.register(:SERVER, OMQ::Routing::Server)
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ draft PEER socket type.
4
+ #
5
+ # Usage:
6
+ # require "omq/draft/p2p"
7
+ #
8
+ # a = OMQ::PEER.bind("tcp://127.0.0.1:5555")
9
+ # b = OMQ::PEER.connect("tcp://127.0.0.1:5555")
10
+
11
+ require "omq"
12
+
13
+ require_relative "../single_frame"
14
+ require_relative "../routing/peer"
15
+ require_relative "../peer"
16
+
17
+ OMQ::Routing.register(:PEER, OMQ::Routing::Peer)
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ draft RADIO/DISH socket types with UDP transport.
4
+ #
5
+ # Usage:
6
+ # require "omq/draft/radiodish"
7
+ #
8
+ # radio = OMQ::RADIO.connect("udp://127.0.0.1:5555")
9
+ # dish = OMQ::DISH.bind("udp://127.0.0.1:5555")
10
+ # dish.join("weather")
11
+ # radio.publish("weather", "sunny")
12
+
13
+ require "omq"
14
+
15
+ require_relative "../single_frame"
16
+ require_relative "../routing/radio"
17
+ require_relative "../routing/dish"
18
+ require_relative "../transport/udp"
19
+ require_relative "../radio_dish"
20
+
21
+ OMQ::Routing.register(:RADIO, OMQ::Routing::Radio)
22
+ OMQ::Routing.register(:DISH, OMQ::Routing::Dish)
23
+ OMQ::Engine.transports["udp"] = OMQ::Transport::UDP
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ draft SCATTER/GATHER socket types.
4
+ #
5
+ # Usage:
6
+ # require "omq/draft/scattergather"
7
+ #
8
+ # gather = OMQ::GATHER.bind("tcp://127.0.0.1:5555")
9
+ # scatter = OMQ::SCATTER.connect("tcp://127.0.0.1:5555")
10
+
11
+ require "omq"
12
+
13
+ require_relative "../single_frame"
14
+ require_relative "../routing/scatter"
15
+ require_relative "../routing/gather"
16
+ require_relative "../scatter_gather"
17
+
18
+ OMQ::Routing.register(:SCATTER, OMQ::Routing::Scatter)
19
+ OMQ::Routing.register(:GATHER, OMQ::Routing::Gather)
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Draft
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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 Readable
6
+ include Writable
7
+ include SingleFrame
8
+
9
+ def initialize(endpoints = nil, linger: 0, backend: nil)
10
+ _init_engine(:PEER, linger: linger, backend: backend)
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 Writable
6
+
7
+ def initialize(endpoints = nil, linger: 0, on_mute: :drop_newest, conflate: false, backend: nil)
8
+ _init_engine(:RADIO, linger: linger, on_mute: on_mute, conflate: conflate, backend: backend)
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 Readable
49
+
50
+ def initialize(endpoints = nil, linger: 0, group: nil, on_mute: :block, backend: nil)
51
+ _init_engine(:DISH, linger: linger, on_mute: on_mute, backend: backend)
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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # CHANNEL socket routing: exclusive 1-to-1 bidirectional.
6
+ #
7
+ class Channel
8
+
9
+ # @param engine [Engine]
10
+ #
11
+ def initialize(engine)
12
+ @engine = engine
13
+ @connection = nil
14
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
15
+ @send_queue = Routing.build_queue(engine.options.send_hwm, :block)
16
+ @tasks = []
17
+ @send_pump_idle = true
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) unless connection.is_a?(Transport::Inproc::DirectPipe)
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
+ conn = @connection
49
+ if conn.is_a?(Transport::Inproc::DirectPipe) && conn.direct_recv_queue
50
+ conn.send_message(parts)
51
+ else
52
+ @send_queue.enqueue(parts)
53
+ end
54
+ end
55
+
56
+ #
57
+ def stop
58
+ @tasks.each(&:stop)
59
+ @tasks.clear
60
+ end
61
+
62
+ def send_pump_idle? = @send_pump_idle
63
+
64
+ private
65
+
66
+ def start_send_pump(conn)
67
+ @send_pump = @engine.spawn_pump_task(annotation: "send pump") do
68
+ loop do
69
+ @send_pump_idle = true
70
+ batch = [@send_queue.dequeue]
71
+ @send_pump_idle = false
72
+ Routing.drain_send_queue(@send_queue, batch)
73
+ batch.each { |parts| conn.write_message(parts) }
74
+ conn.flush
75
+ end
76
+ rescue *CONNECTION_LOST
77
+ @engine.connection_lost(conn)
78
+ end
79
+ @tasks << @send_pump
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # CLIENT socket routing: round-robin send, fair-queue receive.
6
+ #
7
+ # Same as DEALER — no envelope manipulation.
8
+ #
9
+ class Client
10
+ include RoundRobin
11
+
12
+ # @param engine [Engine]
13
+ #
14
+ def initialize(engine)
15
+ @engine = engine
16
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
17
+ @tasks = []
18
+ init_round_robin(engine)
19
+ end
20
+
21
+ # @return [Async::LimitedQueue]
22
+ #
23
+ attr_reader :recv_queue, :send_queue
24
+
25
+ # @param connection [Connection]
26
+ #
27
+ def connection_added(connection)
28
+ @connections << connection
29
+ signal_connection_available
30
+ update_direct_pipe
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
+ update_direct_pipe
41
+ end
42
+
43
+ # @param parts [Array<String>]
44
+ #
45
+ def enqueue(parts)
46
+ enqueue_round_robin(parts)
47
+ end
48
+
49
+ #
50
+ def stop
51
+ @tasks.each(&:stop)
52
+ @tasks.clear
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # DISH socket routing: group-based receive from RADIO peers.
6
+ #
7
+ # Sends JOIN/LEAVE commands to connected RADIO peers.
8
+ # Receives two-frame messages (group + body) from RADIO.
9
+ #
10
+ class Dish
11
+
12
+ # @param engine [Engine]
13
+ #
14
+ def initialize(engine)
15
+ @engine = engine
16
+ @connections = []
17
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, engine.options.on_mute)
18
+ @groups = Set.new
19
+ @tasks = []
20
+ end
21
+
22
+ # @return [Async::LimitedQueue]
23
+ #
24
+ attr_reader :recv_queue
25
+
26
+ # @param connection [Connection]
27
+ #
28
+ def connection_added(connection)
29
+ @connections << connection
30
+ @groups.each do |group|
31
+ connection.send_command(Protocol::ZMTP::Codec::Command.join(group))
32
+ end
33
+ task = @engine.start_recv_pump(connection, @recv_queue)
34
+ @tasks << task if task
35
+ end
36
+
37
+ # @param connection [Connection]
38
+ #
39
+ def connection_removed(connection)
40
+ @connections.delete(connection)
41
+ end
42
+
43
+ # DISH is read-only.
44
+ #
45
+ def enqueue(_parts)
46
+ raise "DISH sockets cannot send"
47
+ end
48
+
49
+ # Joins a group.
50
+ #
51
+ # @param group [String]
52
+ #
53
+ def join(group)
54
+ @groups << group
55
+ @connections.each do |conn|
56
+ conn.send_command(Protocol::ZMTP::Codec::Command.join(group))
57
+ end
58
+ end
59
+
60
+ # Leaves a group.
61
+ #
62
+ # @param group [String]
63
+ #
64
+ def leave(group)
65
+ @groups.delete(group)
66
+ @connections.each do |conn|
67
+ conn.send_command(Protocol::ZMTP::Codec::Command.leave(group))
68
+ end
69
+ end
70
+
71
+ def stop
72
+ @tasks.each(&:stop)
73
+ @tasks.clear
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # GATHER socket routing: fair-queue receive from SCATTER peers.
6
+ #
7
+ class Gather
8
+ # @param engine [Engine]
9
+ #
10
+ def initialize(engine)
11
+ @engine = engine
12
+ @recv_queue = Routing.build_queue(engine.options.recv_hwm, :block)
13
+ @tasks = []
14
+ end
15
+
16
+ # @return [Async::LimitedQueue]
17
+ #
18
+ attr_reader :recv_queue
19
+
20
+ # @param connection [Connection]
21
+ #
22
+ def connection_added(connection)
23
+ task = @engine.start_recv_pump(connection, @recv_queue)
24
+ @tasks << task if task
25
+ end
26
+
27
+ # @param connection [Connection]
28
+ #
29
+ def connection_removed(connection)
30
+ # recv pump stops on CONNECTION_LOST
31
+ end
32
+
33
+ # GATHER is read-only.
34
+ #
35
+ def enqueue(_parts)
36
+ raise "GATHER sockets cannot send"
37
+ end
38
+
39
+ #
40
+ def stop
41
+ @tasks.each(&:stop)
42
+ @tasks.clear
43
+ end
44
+ end
45
+ end
46
+ end