omq 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.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/LICENSE +15 -0
  4. data/README.md +145 -0
  5. data/lib/omq/pair.rb +13 -0
  6. data/lib/omq/pub_sub.rb +77 -0
  7. data/lib/omq/push_pull.rb +21 -0
  8. data/lib/omq/req_rep.rb +23 -0
  9. data/lib/omq/router_dealer.rb +36 -0
  10. data/lib/omq/socket.rb +178 -0
  11. data/lib/omq/version.rb +5 -0
  12. data/lib/omq/zmtp/codec/command.rb +207 -0
  13. data/lib/omq/zmtp/codec/frame.rb +104 -0
  14. data/lib/omq/zmtp/codec/greeting.rb +96 -0
  15. data/lib/omq/zmtp/codec.rb +18 -0
  16. data/lib/omq/zmtp/connection.rb +233 -0
  17. data/lib/omq/zmtp/engine.rb +339 -0
  18. data/lib/omq/zmtp/mechanism/null.rb +70 -0
  19. data/lib/omq/zmtp/options.rb +57 -0
  20. data/lib/omq/zmtp/reactor.rb +142 -0
  21. data/lib/omq/zmtp/readable.rb +29 -0
  22. data/lib/omq/zmtp/routing/dealer.rb +57 -0
  23. data/lib/omq/zmtp/routing/fan_out.rb +89 -0
  24. data/lib/omq/zmtp/routing/pair.rb +68 -0
  25. data/lib/omq/zmtp/routing/pub.rb +62 -0
  26. data/lib/omq/zmtp/routing/pull.rb +48 -0
  27. data/lib/omq/zmtp/routing/push.rb +57 -0
  28. data/lib/omq/zmtp/routing/rep.rb +83 -0
  29. data/lib/omq/zmtp/routing/req.rb +70 -0
  30. data/lib/omq/zmtp/routing/round_robin.rb +69 -0
  31. data/lib/omq/zmtp/routing/router.rb +88 -0
  32. data/lib/omq/zmtp/routing/sub.rb +80 -0
  33. data/lib/omq/zmtp/routing/xpub.rb +74 -0
  34. data/lib/omq/zmtp/routing/xsub.rb +80 -0
  35. data/lib/omq/zmtp/routing.rb +38 -0
  36. data/lib/omq/zmtp/transport/inproc.rb +299 -0
  37. data/lib/omq/zmtp/transport/ipc.rb +114 -0
  38. data/lib/omq/zmtp/transport/tcp.rb +98 -0
  39. data/lib/omq/zmtp/valid_peers.rb +21 -0
  40. data/lib/omq/zmtp/writable.rb +44 -0
  41. data/lib/omq/zmtp.rb +47 -0
  42. data/lib/omq.rb +19 -0
  43. metadata +110 -0
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # Mixin for routing strategies that fan-out to subscribers.
7
+ #
8
+ # Manages per-connection subscription sets, subscription command
9
+ # listeners, and a send pump that delivers to all matching peers.
10
+ #
11
+ # Including classes must call `init_fan_out(engine)` from
12
+ # their #initialize.
13
+ #
14
+ module FanOut
15
+ private
16
+
17
+ def init_fan_out(engine)
18
+ @connections = []
19
+ @subscriptions = {} # connection => Set of prefixes
20
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
21
+ @send_pump_started = false
22
+ end
23
+
24
+ # @return [Boolean] whether the connection is subscribed to the topic
25
+ #
26
+ def subscribed?(conn, topic)
27
+ subs = @subscriptions[conn]
28
+ return false unless subs
29
+ subs.any? { |prefix| topic.b.start_with?(prefix.b) }
30
+ end
31
+
32
+ # Called when a subscription command is received from a peer.
33
+ # Override in subclasses to expose subscriptions to the
34
+ # application (e.g. XPUB enqueues to recv_queue).
35
+ #
36
+ # @param conn [Connection]
37
+ # @param prefix [String]
38
+ #
39
+ def on_subscribe(conn, prefix)
40
+ @subscriptions[conn] << prefix
41
+ end
42
+
43
+ # Called when a cancel command is received from a peer.
44
+ # Override in subclasses (e.g. XPUB enqueues to recv_queue).
45
+ #
46
+ # @param conn [Connection]
47
+ # @param prefix [String]
48
+ #
49
+ def on_cancel(conn, prefix)
50
+ @subscriptions[conn]&.delete(prefix)
51
+ end
52
+
53
+ def start_send_pump
54
+ @send_pump_started = true
55
+ @tasks << Reactor.spawn_pump do
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 IOError, EOFError
64
+ # connection dead — will be cleaned up
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+
71
+ def start_subscription_listener(conn)
72
+ @tasks << Reactor.spawn_pump do
73
+ loop do
74
+ frame = conn.read_frame
75
+ next unless frame.command?
76
+ cmd = Codec::Command.from_body(frame.body)
77
+ case cmd.name
78
+ when "SUBSCRIBE" then on_subscribe(conn, cmd.data)
79
+ when "CANCEL" then on_cancel(conn, cmd.data)
80
+ end
81
+ end
82
+ rescue EOFError
83
+ @engine.connection_lost(conn)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # PAIR socket routing: exclusive 1-to-1 bidirectional.
7
+ #
8
+ # Only one peer connection is allowed. Messages flow through
9
+ # internal send/recv queues backed by Async::LimitedQueue.
10
+ #
11
+ class Pair
12
+
13
+ # @param engine [Engine]
14
+ #
15
+ def initialize(engine)
16
+ @engine = engine
17
+ @connection = nil
18
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
19
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
20
+ @tasks = []
21
+ end
22
+
23
+ # @return [Async::LimitedQueue]
24
+ #
25
+ attr_reader :recv_queue, :send_queue
26
+
27
+ # @param connection [Connection]
28
+ # @raise [RuntimeError] if a connection already exists
29
+ #
30
+ def connection_added(connection)
31
+ raise "PAIR allows only one peer" if @connection
32
+ @connection = connection
33
+ task = @engine.start_recv_pump(connection, @recv_queue)
34
+ @tasks << task if task
35
+ start_send_pump(connection)
36
+ end
37
+
38
+ # @param connection [Connection]
39
+ #
40
+ def connection_removed(connection)
41
+ @connection = nil if @connection == 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_send_pump(conn)
59
+ @tasks << Reactor.spawn_pump do
60
+ loop { conn.send_message(@send_queue.dequeue) }
61
+ rescue EOFError, IOError
62
+ @engine.connection_lost(conn)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # PUB socket routing: fan-out to all subscribers.
7
+ #
8
+ # Listens for SUBSCRIBE/CANCEL commands from peers.
9
+ # Drops messages if a subscriber's connection write fails.
10
+ #
11
+ class Pub
12
+ include FanOut
13
+
14
+ # @param engine [Engine]
15
+ #
16
+ def initialize(engine)
17
+ @engine = engine
18
+ @tasks = []
19
+ init_fan_out(engine)
20
+ end
21
+
22
+ # @return [Async::LimitedQueue]
23
+ #
24
+ attr_reader :send_queue
25
+
26
+ # PUB is write-only.
27
+ #
28
+ def recv_queue
29
+ raise "PUB sockets cannot receive"
30
+ end
31
+
32
+ # @param connection [Connection]
33
+ #
34
+ def connection_added(connection)
35
+ @connections << connection
36
+ @subscriptions[connection] = Set.new
37
+ start_subscription_listener(connection)
38
+ start_send_pump unless @send_pump_started
39
+ end
40
+
41
+ # @param connection [Connection]
42
+ #
43
+ def connection_removed(connection)
44
+ @connections.delete(connection)
45
+ @subscriptions.delete(connection)
46
+ end
47
+
48
+ # @param parts [Array<String>]
49
+ #
50
+ def enqueue(parts)
51
+ @send_queue.enqueue(parts)
52
+ end
53
+
54
+ #
55
+ def stop
56
+ @tasks.each(&:stop)
57
+ @tasks.clear
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # PULL socket routing: fair-queue receive from PUSH peers.
7
+ #
8
+ class Pull
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 EOFError
32
+ end
33
+
34
+ # PULL is read-only.
35
+ #
36
+ def enqueue(_parts)
37
+ raise "PULL 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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # PUSH socket routing: round-robin send to PULL peers.
7
+ #
8
+ class Push
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
+ # PUSH is write-only.
24
+ #
25
+ def recv_queue
26
+ raise "PUSH 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
+ end
36
+
37
+ # @param connection [Connection]
38
+ #
39
+ def connection_removed(connection)
40
+ @connections.delete(connection)
41
+ end
42
+
43
+ # @param parts [Array<String>]
44
+ #
45
+ def enqueue(parts)
46
+ @send_queue.enqueue(parts)
47
+ end
48
+
49
+ #
50
+ def stop
51
+ @tasks.each(&:stop)
52
+ @tasks.clear
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # REP socket routing: fair-queue receive, reply routed back to sender.
7
+ #
8
+ # REP strips the routing envelope (everything up to and including the
9
+ # empty delimiter) on receive, saves it internally, and restores it
10
+ # on send.
11
+ #
12
+ class Rep
13
+ # @param engine [Engine]
14
+ #
15
+ def initialize(engine)
16
+ @engine = engine
17
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
18
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
19
+ @pending_replies = []
20
+ @tasks = []
21
+ @send_pump_started = false
22
+ end
23
+
24
+ # @return [Async::LimitedQueue]
25
+ #
26
+ attr_reader :recv_queue, :send_queue
27
+
28
+ # @param connection [Connection]
29
+ #
30
+ def connection_added(connection)
31
+ transform = ->(msg) {
32
+ envelope = []
33
+ while msg.first && !msg.first.empty?
34
+ envelope << msg.shift
35
+ end
36
+ msg.shift # remove empty delimiter
37
+ @pending_replies << { conn: connection, envelope: envelope }
38
+ msg
39
+ }
40
+ task = @engine.start_recv_pump(connection, @recv_queue, transform: transform)
41
+ @tasks << task if task
42
+ start_send_pump unless @send_pump_started
43
+ end
44
+
45
+ # @param connection [Connection]
46
+ #
47
+ def connection_removed(connection)
48
+ # Remove any pending replies for this connection
49
+ @pending_replies.reject! { |r| r[:conn] == connection }
50
+ end
51
+
52
+ # Enqueues a reply for sending.
53
+ #
54
+ # @param parts [Array<String>]
55
+ #
56
+ def enqueue(parts)
57
+ @send_queue.enqueue(parts)
58
+ end
59
+
60
+ def stop
61
+ @tasks.each(&:stop)
62
+ @tasks.clear
63
+ end
64
+
65
+ private
66
+
67
+ def start_send_pump
68
+ @send_pump_started = true
69
+ @tasks << Reactor.spawn_pump do
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])
75
+ end
76
+ rescue EOFError
77
+ # connection lost mid-write
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # REQ socket routing: round-robin send with strict send/recv alternation.
7
+ #
8
+ # REQ prepends an empty delimiter frame on send and strips it on receive.
9
+ #
10
+ class Req
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
+ transform: method(:transform_recv))
33
+ @tasks << task if task
34
+ start_send_pump unless @send_pump_started
35
+ end
36
+
37
+ # @param connection [Connection]
38
+ #
39
+ def connection_removed(connection)
40
+ @connections.delete(connection)
41
+ end
42
+
43
+ # @param parts [Array<String>]
44
+ #
45
+ def enqueue(parts)
46
+ @send_queue.enqueue(parts)
47
+ end
48
+
49
+ #
50
+ def stop
51
+ @tasks.each(&:stop)
52
+ @tasks.clear
53
+ end
54
+
55
+ private
56
+
57
+ # REQ prepends empty delimiter frame on the wire.
58
+ #
59
+ def transform_send(parts) = ["".b, *parts]
60
+
61
+ # REQ strips the leading empty delimiter frame on receive.
62
+ #
63
+ def transform_recv(msg)
64
+ msg.shift if msg.first&.empty?
65
+ msg
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module ZMTP
5
+ module Routing
6
+ # Mixin for routing strategies that send via round-robin.
7
+ #
8
+ # Provides reactive connection management: Async::Promise waits
9
+ # for the first connection, Array#cycle handles round-robin,
10
+ # and a new Promise is created when all connections drop.
11
+ #
12
+ # Including classes must call `init_round_robin(engine)` from
13
+ # their #initialize.
14
+ #
15
+ module RoundRobin
16
+ private
17
+
18
+ def init_round_robin(engine)
19
+ @connections = []
20
+ @cycle = @connections.cycle
21
+ @connection_available = Async::Promise.new
22
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
23
+ @send_pump_started = false
24
+ end
25
+
26
+ def signal_connection_available
27
+ unless @connection_available.resolved?
28
+ @connection_available.resolve(true)
29
+ end
30
+ end
31
+
32
+ # Blocks until a connection is available, then returns
33
+ # the next one in round-robin order.
34
+ #
35
+ # @return [Connection]
36
+ #
37
+ def next_connection
38
+ @cycle.next
39
+ rescue StopIteration
40
+ @connection_available = Async::Promise.new
41
+ @connection_available.wait
42
+ @cycle = @connections.cycle
43
+ retry
44
+ end
45
+
46
+ # Transforms parts before sending. Override in subclasses
47
+ # (e.g. REQ prepends an empty delimiter frame).
48
+ #
49
+ # @param parts [Array<String>]
50
+ # @return [Array<String>]
51
+ #
52
+ def transform_send(parts) = parts
53
+
54
+ def start_send_pump
55
+ @send_pump_started = true
56
+ @tasks << Reactor.spawn_pump do
57
+ loop do
58
+ parts = @send_queue.dequeue
59
+ conn = next_connection
60
+ conn.send_message(transform_send(parts))
61
+ end
62
+ rescue EOFError, IOError
63
+ # connection lost mid-write
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "socket"
5
+
6
+ module OMQ
7
+ module ZMTP
8
+ module Routing
9
+ # ROUTER socket routing: identity-based routing.
10
+ #
11
+ # Prepends peer identity frame on receive. Uses first frame as
12
+ # routing identity on send.
13
+ #
14
+ class Router
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_identity = {}
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
+ identity = connection.peer_identity
34
+ identity = SecureRandom.bytes(5) if identity.nil? || identity.empty?
35
+ @connections_by_identity[identity] = connection
36
+
37
+ task = @engine.start_recv_pump(connection, @recv_queue,
38
+ transform: ->(msg) { [identity, *msg] })
39
+ @tasks << task if task
40
+
41
+ start_send_pump unless @send_pump_started
42
+ end
43
+
44
+ # @param connection [Connection]
45
+ #
46
+ def connection_removed(connection)
47
+ @connections_by_identity.reject! { |_, c| c == connection }
48
+ end
49
+
50
+ # Enqueues a message for sending.
51
+ #
52
+ # @param parts [Array<String>]
53
+ #
54
+ def enqueue(parts)
55
+ @send_queue.enqueue(parts)
56
+ end
57
+
58
+ def stop
59
+ @tasks.each(&:stop)
60
+ @tasks.clear
61
+ end
62
+
63
+ private
64
+
65
+ def start_send_pump
66
+ @send_pump_started = true
67
+ @tasks << Reactor.spawn_pump do
68
+ loop do
69
+ parts = @send_queue.dequeue
70
+ identity = parts.first
71
+ conn = @connections_by_identity[identity]
72
+
73
+ unless conn
74
+ if @engine.options.router_mandatory?
75
+ raise SocketError, "no route to identity #{identity.inspect}"
76
+ end
77
+ next # silently drop
78
+ end
79
+
80
+ # Send everything after the identity frame
81
+ conn.send_message(parts[1..])
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end