omq 0.8.0 → 0.10.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/README.md +9 -49
  4. data/lib/omq/channel.rb +3 -3
  5. data/lib/omq/client_server.rb +6 -6
  6. data/lib/omq/engine.rb +641 -0
  7. data/lib/omq/options.rb +46 -0
  8. data/lib/omq/pair.rb +2 -2
  9. data/lib/omq/peer.rb +3 -3
  10. data/lib/omq/pub_sub.rb +6 -6
  11. data/lib/omq/push_pull.rb +2 -2
  12. data/lib/omq/radio_dish.rb +2 -2
  13. data/lib/omq/reactor.rb +128 -0
  14. data/lib/omq/readable.rb +42 -0
  15. data/lib/omq/req_rep.rb +4 -4
  16. data/lib/omq/router_dealer.rb +4 -4
  17. data/lib/omq/routing/channel.rb +83 -0
  18. data/lib/omq/routing/client.rb +56 -0
  19. data/lib/omq/routing/dealer.rb +57 -0
  20. data/lib/omq/routing/dish.rb +78 -0
  21. data/lib/omq/routing/fan_out.rb +131 -0
  22. data/lib/omq/routing/gather.rb +46 -0
  23. data/lib/omq/routing/pair.rb +86 -0
  24. data/lib/omq/routing/peer.rb +101 -0
  25. data/lib/omq/routing/pub.rb +60 -0
  26. data/lib/omq/routing/pull.rb +46 -0
  27. data/lib/omq/routing/push.rb +81 -0
  28. data/lib/omq/routing/radio.rb +140 -0
  29. data/lib/omq/routing/rep.rb +101 -0
  30. data/lib/omq/routing/req.rb +65 -0
  31. data/lib/omq/routing/round_robin.rb +168 -0
  32. data/lib/omq/routing/router.rb +110 -0
  33. data/lib/omq/routing/scatter.rb +82 -0
  34. data/lib/omq/routing/server.rb +101 -0
  35. data/lib/omq/routing/sub.rb +78 -0
  36. data/lib/omq/routing/xpub.rb +72 -0
  37. data/lib/omq/routing/xsub.rb +83 -0
  38. data/lib/omq/routing.rb +66 -0
  39. data/lib/omq/scatter_gather.rb +4 -4
  40. data/lib/omq/single_frame.rb +18 -0
  41. data/lib/omq/socket.rb +24 -9
  42. data/lib/omq/transport/inproc.rb +355 -0
  43. data/lib/omq/transport/ipc.rb +117 -0
  44. data/lib/omq/transport/tcp.rb +111 -0
  45. data/lib/omq/version.rb +1 -1
  46. data/lib/omq/writable.rb +65 -0
  47. data/lib/omq.rb +60 -4
  48. metadata +38 -58
  49. data/exe/omq +0 -6
  50. data/lib/omq/cli/base_runner.rb +0 -459
  51. data/lib/omq/cli/channel.rb +0 -8
  52. data/lib/omq/cli/client_server.rb +0 -111
  53. data/lib/omq/cli/config.rb +0 -54
  54. data/lib/omq/cli/formatter.rb +0 -75
  55. data/lib/omq/cli/pair.rb +0 -31
  56. data/lib/omq/cli/peer.rb +0 -8
  57. data/lib/omq/cli/pipe.rb +0 -265
  58. data/lib/omq/cli/pub_sub.rb +0 -14
  59. data/lib/omq/cli/push_pull.rb +0 -14
  60. data/lib/omq/cli/radio_dish.rb +0 -27
  61. data/lib/omq/cli/req_rep.rb +0 -83
  62. data/lib/omq/cli/router_dealer.rb +0 -76
  63. data/lib/omq/cli/scatter_gather.rb +0 -14
  64. data/lib/omq/cli.rb +0 -540
  65. data/lib/omq/zmtp/engine.rb +0 -551
  66. data/lib/omq/zmtp/options.rb +0 -48
  67. data/lib/omq/zmtp/reactor.rb +0 -131
  68. data/lib/omq/zmtp/readable.rb +0 -29
  69. data/lib/omq/zmtp/routing/channel.rb +0 -81
  70. data/lib/omq/zmtp/routing/client.rb +0 -56
  71. data/lib/omq/zmtp/routing/dealer.rb +0 -57
  72. data/lib/omq/zmtp/routing/dish.rb +0 -80
  73. data/lib/omq/zmtp/routing/fan_out.rb +0 -131
  74. data/lib/omq/zmtp/routing/gather.rb +0 -48
  75. data/lib/omq/zmtp/routing/pair.rb +0 -84
  76. data/lib/omq/zmtp/routing/peer.rb +0 -100
  77. data/lib/omq/zmtp/routing/pub.rb +0 -62
  78. data/lib/omq/zmtp/routing/pull.rb +0 -48
  79. data/lib/omq/zmtp/routing/push.rb +0 -80
  80. data/lib/omq/zmtp/routing/radio.rb +0 -139
  81. data/lib/omq/zmtp/routing/rep.rb +0 -101
  82. data/lib/omq/zmtp/routing/req.rb +0 -65
  83. data/lib/omq/zmtp/routing/round_robin.rb +0 -143
  84. data/lib/omq/zmtp/routing/router.rb +0 -109
  85. data/lib/omq/zmtp/routing/scatter.rb +0 -81
  86. data/lib/omq/zmtp/routing/server.rb +0 -100
  87. data/lib/omq/zmtp/routing/sub.rb +0 -80
  88. data/lib/omq/zmtp/routing/xpub.rb +0 -74
  89. data/lib/omq/zmtp/routing/xsub.rb +0 -86
  90. data/lib/omq/zmtp/routing.rb +0 -65
  91. data/lib/omq/zmtp/single_frame.rb +0 -20
  92. data/lib/omq/zmtp/transport/inproc.rb +0 -359
  93. data/lib/omq/zmtp/transport/ipc.rb +0 -118
  94. data/lib/omq/zmtp/transport/tcp.rb +0 -117
  95. data/lib/omq/zmtp/writable.rb +0 -61
  96. data/lib/omq/zmtp.rb +0 -81
data/lib/omq/pub_sub.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OMQ
4
4
  class PUB < Socket
5
- include ZMTP::Writable
5
+ include Writable
6
6
 
7
7
  def initialize(endpoints = nil, linger: 0, conflate: false)
8
8
  _init_engine(:PUB, linger: linger, conflate: conflate)
@@ -13,7 +13,7 @@ module OMQ
13
13
  # SUB socket.
14
14
  #
15
15
  class SUB < Socket
16
- include ZMTP::Readable
16
+ include Readable
17
17
 
18
18
  # @return [String] subscription prefix to subscribe to everything
19
19
  #
@@ -50,8 +50,8 @@ module OMQ
50
50
  end
51
51
 
52
52
  class XPUB < Socket
53
- include ZMTP::Readable
54
- include ZMTP::Writable
53
+ include Readable
54
+ include Writable
55
55
 
56
56
  def initialize(endpoints = nil, linger: 0)
57
57
  _init_engine(:XPUB, linger: linger)
@@ -60,8 +60,8 @@ module OMQ
60
60
  end
61
61
 
62
62
  class XSUB < Socket
63
- include ZMTP::Readable
64
- include ZMTP::Writable
63
+ include Readable
64
+ include Writable
65
65
 
66
66
  # @param endpoints [String, nil]
67
67
  # @param linger [Integer]
data/lib/omq/push_pull.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OMQ
4
4
  class PUSH < Socket
5
- include ZMTP::Writable
5
+ include Writable
6
6
 
7
7
  def initialize(endpoints = nil, linger: 0, send_hwm: nil, send_timeout: nil)
8
8
  _init_engine(:PUSH, linger: linger, send_hwm: send_hwm, send_timeout: send_timeout)
@@ -11,7 +11,7 @@ module OMQ
11
11
  end
12
12
 
13
13
  class PULL < Socket
14
- include ZMTP::Readable
14
+ include Readable
15
15
 
16
16
  def initialize(endpoints = nil, linger: 0, recv_hwm: nil, recv_timeout: nil)
17
17
  _init_engine(:PULL, linger: linger, recv_hwm: recv_hwm, recv_timeout: recv_timeout)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OMQ
4
4
  class RADIO < Socket
5
- include ZMTP::Writable
5
+ include Writable
6
6
 
7
7
  def initialize(endpoints = nil, linger: 0, conflate: false)
8
8
  _init_engine(:RADIO, linger: linger, conflate: conflate)
@@ -45,7 +45,7 @@ module OMQ
45
45
  end
46
46
 
47
47
  class DISH < Socket
48
- include ZMTP::Readable
48
+ include Readable
49
49
 
50
50
  def initialize(endpoints = nil, linger: 0, group: nil)
51
51
  _init_engine(:DISH, linger: linger)
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+
5
+ module OMQ
6
+ # Shared IO reactor for the Ruby backend.
7
+ #
8
+ # When user code runs inside an Async reactor, engine tasks are
9
+ # spawned directly under the caller's Async task. When no reactor
10
+ # is available (e.g. bare Thread.new), a single shared IO thread
11
+ # hosts all engine tasks — mirroring libzmq's IO thread.
12
+ #
13
+ # Engines obtain the IO thread's root task via {.root_task} and
14
+ # use it as their @parent_task. Blocking operations from the main
15
+ # thread are dispatched to the IO thread via {.run}.
16
+ #
17
+ module Reactor
18
+ @mutex = Mutex.new
19
+ @thread = nil
20
+ @root_task = nil
21
+ @work_queue = nil
22
+ @lingers = Hash.new(0) # linger value → count of active sockets
23
+
24
+ class << self
25
+ # Returns the root Async task inside the shared IO thread.
26
+ # Starts the thread exactly once (double-checked lock).
27
+ #
28
+ # @return [Async::Task]
29
+ #
30
+ def root_task
31
+ return @root_task if @root_task
32
+ @mutex.synchronize do
33
+ return @root_task if @root_task
34
+ ready = Thread::Queue.new
35
+ @work_queue = Async::Queue.new
36
+ @thread = Thread.new { run_reactor(ready) }
37
+ @thread.name = "omq-io"
38
+ @root_task = ready.pop
39
+ at_exit { stop! }
40
+ end
41
+ @root_task
42
+ end
43
+
44
+
45
+ # Runs a block inside the Async reactor.
46
+ #
47
+ # Inside an Async reactor: runs directly.
48
+ # Outside: dispatches to the shared IO thread and blocks
49
+ # the calling thread until the result is available.
50
+ #
51
+ # @return [Object] the block's return value
52
+ #
53
+ def run(&block)
54
+ if Async::Task.current?
55
+ yield
56
+ else
57
+ result = Thread::Queue.new
58
+ root_task # ensure started
59
+ @work_queue.push([block, result])
60
+ status, value = result.pop
61
+ raise value if status == :error
62
+ value
63
+ end
64
+ end
65
+
66
+
67
+ # Registers a socket's linger value.
68
+ #
69
+ # @param seconds [Numeric, nil] linger value
70
+ #
71
+ def track_linger(seconds)
72
+ @lingers[seconds || 0] += 1
73
+ end
74
+
75
+
76
+ # Unregisters a socket's linger value.
77
+ #
78
+ # @param seconds [Numeric, nil] linger value
79
+ #
80
+ def untrack_linger(seconds)
81
+ key = seconds || 0
82
+ @lingers[key] -= 1
83
+ @lingers.delete(key) if @lingers[key] <= 0
84
+ end
85
+
86
+
87
+ # Stops the shared IO thread.
88
+ #
89
+ # @return [void]
90
+ #
91
+ def stop!
92
+ return unless @thread&.alive?
93
+ max_linger = @lingers.empty? ? 0 : @lingers.keys.max
94
+ @work_queue&.push(nil)
95
+ @thread&.join(max_linger + 1)
96
+ @thread = nil
97
+ @root_task = nil
98
+ @work_queue = nil
99
+ @lingers = Hash.new(0)
100
+ end
101
+
102
+ private
103
+
104
+ # Runs the shared Async reactor.
105
+ #
106
+ # Processes work items dispatched via {.run} while engine
107
+ # tasks (accept loops, pumps, etc.) run as transient children.
108
+ #
109
+ # @param ready [Thread::Queue] receives the root task once started
110
+ #
111
+ def run_reactor(ready)
112
+ Async do |task|
113
+ ready.push(task)
114
+ loop do
115
+ item = @work_queue.dequeue
116
+ break if item.nil?
117
+ block, result = item
118
+ task.async do
119
+ result.push([:ok, block.call])
120
+ rescue => e
121
+ result.push([:error, e])
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module OMQ
6
+ # Pure Ruby Readable mixin. Dequeues messages from the engine's recv queue.
7
+ #
8
+ module Readable
9
+ # Maximum messages to prefetch from the recv queue per drain.
10
+ RECV_BATCH_SIZE = 64
11
+
12
+ # Receives the next message. Returns from a local prefetch
13
+ # buffer when available, otherwise drains up to
14
+ # {RECV_BATCH_SIZE} messages from the recv queue in one
15
+ # synchronized dequeue.
16
+ #
17
+ # @return [Array<String>] message parts
18
+ # @raise [IO::TimeoutError] if read_timeout exceeded
19
+ #
20
+ def receive
21
+ @recv_mutex.synchronize { @recv_buffer.shift } || fill_recv_buffer
22
+ end
23
+
24
+ # Waits until the socket is readable.
25
+ #
26
+ # @param timeout [Numeric, nil] timeout in seconds
27
+ # @return [true]
28
+ #
29
+ def wait_readable(timeout = @options.read_timeout)
30
+ true
31
+ end
32
+
33
+ private
34
+
35
+ def fill_recv_buffer
36
+ batch = Reactor.run { with_timeout(@options.read_timeout) { @engine.dequeue_recv_batch(RECV_BATCH_SIZE) } }
37
+ msg = batch.shift
38
+ @recv_mutex.synchronize { @recv_buffer.concat(batch) } unless batch.empty?
39
+ msg
40
+ end
41
+ end
42
+ end
data/lib/omq/req_rep.rb CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  module OMQ
4
4
  class REQ < Socket
5
- include ZMTP::Readable
6
- include ZMTP::Writable
5
+ include Readable
6
+ include Writable
7
7
 
8
8
  def initialize(endpoints = nil, linger: 0)
9
9
  _init_engine(:REQ, linger: linger)
@@ -12,8 +12,8 @@ module OMQ
12
12
  end
13
13
 
14
14
  class REP < Socket
15
- include ZMTP::Readable
16
- include ZMTP::Writable
15
+ include Readable
16
+ include Writable
17
17
 
18
18
  def initialize(endpoints = nil, linger: 0)
19
19
  _init_engine(:REP, linger: linger)
@@ -2,8 +2,8 @@
2
2
 
3
3
  module OMQ
4
4
  class DEALER < Socket
5
- include ZMTP::Readable
6
- include ZMTP::Writable
5
+ include Readable
6
+ include Writable
7
7
 
8
8
  def initialize(endpoints = nil, linger: 0)
9
9
  _init_engine(:DEALER, linger: linger)
@@ -14,8 +14,8 @@ module OMQ
14
14
  # ROUTER socket.
15
15
  #
16
16
  class ROUTER < Socket
17
- include ZMTP::Readable
18
- include ZMTP::Writable
17
+ include Readable
18
+ include Writable
19
19
 
20
20
  def initialize(endpoints = nil, linger: 0)
21
21
  _init_engine(:ROUTER, linger: linger)
@@ -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 = Async::LimitedQueue.new(engine.options.recv_hwm)
15
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
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 = Async::LimitedQueue.new(engine.options.recv_hwm)
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,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # DEALER socket routing: round-robin send, fair-queue receive.
6
+ #
7
+ # No envelope manipulation — messages pass through unchanged.
8
+ #
9
+ class Dealer
10
+ include RoundRobin
11
+
12
+ # @param engine [Engine]
13
+ #
14
+ def initialize(engine)
15
+ @engine = engine
16
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
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
+
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,78 @@
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 = Async::LimitedQueue.new(engine.options.recv_hwm)
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
+ # Send existing group memberships to new peer
31
+ @groups.each do |group|
32
+ connection.send_command(Protocol::ZMTP::Codec::Command.join(group))
33
+ end
34
+ task = @engine.start_recv_pump(connection, @recv_queue)
35
+ @tasks << task if task
36
+ end
37
+
38
+ # @param connection [Connection]
39
+ #
40
+ def connection_removed(connection)
41
+ @connections.delete(connection)
42
+ end
43
+
44
+ # DISH is read-only.
45
+ #
46
+ def enqueue(_parts)
47
+ raise "DISH sockets cannot send"
48
+ end
49
+
50
+ # Joins a group.
51
+ #
52
+ # @param group [String]
53
+ #
54
+ def join(group)
55
+ @groups << group
56
+ @connections.each do |conn|
57
+ conn.send_command(Protocol::ZMTP::Codec::Command.join(group))
58
+ end
59
+ end
60
+
61
+ # Leaves a group.
62
+ #
63
+ # @param group [String]
64
+ #
65
+ def leave(group)
66
+ @groups.delete(group)
67
+ @connections.each do |conn|
68
+ conn.send_command(Protocol::ZMTP::Codec::Command.leave(group))
69
+ end
70
+ end
71
+
72
+ def stop
73
+ @tasks.each(&:stop)
74
+ @tasks.clear
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Routing
5
+ # Mixin for routing strategies that fan-out to subscribers.
6
+ #
7
+ # Manages per-connection subscription sets, subscription command
8
+ # listeners, and a send pump that delivers to all matching peers.
9
+ #
10
+ # Including classes must call `init_fan_out(engine)` from
11
+ # their #initialize.
12
+ #
13
+ module FanOut
14
+ attr_reader :subscriber_joined
15
+
16
+ private
17
+
18
+ def init_fan_out(engine)
19
+ @connections = []
20
+ @subscriptions = {} # connection => Set of prefixes
21
+ @send_queue = Async::LimitedQueue.new(engine.options.send_hwm)
22
+ @send_pump_started = false
23
+ @send_pump_idle = true
24
+ @conflate = engine.options.conflate
25
+ @subscriber_joined = Async::Promise.new
26
+ @written = Set.new
27
+ @latest = {} if @conflate
28
+ end
29
+
30
+ # @return [Boolean] whether the connection is subscribed to the topic
31
+ #
32
+ def subscribed?(conn, topic)
33
+ subs = @subscriptions[conn]
34
+ return false unless subs
35
+ subs.any? { |prefix| topic.start_with?(prefix) }
36
+ end
37
+
38
+ # Called when a subscription command is received from a peer.
39
+ # Override in subclasses to expose subscriptions to the
40
+ # application (e.g. XPUB enqueues to recv_queue).
41
+ #
42
+ # @param conn [Connection]
43
+ # @param prefix [String]
44
+ #
45
+ def on_subscribe(conn, prefix)
46
+ @subscriptions[conn] << prefix.b.freeze
47
+ @subscriber_joined.resolve(conn) unless @subscriber_joined.resolved?
48
+ end
49
+
50
+ # Called when a cancel command is received from a peer.
51
+ # Override in subclasses (e.g. XPUB enqueues to recv_queue).
52
+ #
53
+ # @param conn [Connection]
54
+ # @param prefix [String]
55
+ #
56
+ def on_cancel(conn, prefix)
57
+ @subscriptions[conn]&.delete(prefix)
58
+ end
59
+
60
+ # @return [Boolean] true when the send pump is idle (not sending a batch)
61
+ def send_pump_idle? = @send_pump_idle
62
+
63
+
64
+ def start_send_pump
65
+ @send_pump_started = true
66
+ @tasks << @engine.spawn_pump_task(annotation: "send pump") do
67
+ loop do
68
+ @send_pump_idle = true
69
+ batch = [@send_queue.dequeue]
70
+ @send_pump_idle = false
71
+ Routing.drain_send_queue(@send_queue, batch)
72
+
73
+ @written.clear
74
+
75
+ if @conflate
76
+ # Keep only the last matching message per connection.
77
+ @latest.clear
78
+ batch.each do |parts|
79
+ topic = parts.first || EMPTY_BINARY
80
+ @connections.each do |conn|
81
+ next unless subscribed?(conn, topic)
82
+ @latest[conn] = parts
83
+ end
84
+ end
85
+ @latest.each do |conn, parts|
86
+ begin
87
+ conn.write_message(parts)
88
+ @written << conn
89
+ rescue *CONNECTION_LOST
90
+ end
91
+ end
92
+ else
93
+ batch.each do |parts|
94
+ topic = parts.first || EMPTY_BINARY
95
+ @connections.each do |conn|
96
+ next unless subscribed?(conn, topic)
97
+ begin
98
+ conn.write_message(parts)
99
+ @written << conn
100
+ rescue *CONNECTION_LOST
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ @written.each do |conn|
107
+ conn.flush
108
+ rescue *CONNECTION_LOST
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ def start_subscription_listener(conn)
115
+ @tasks << @engine.spawn_pump_task(annotation: "subscription listener") do
116
+ loop do
117
+ frame = conn.read_frame
118
+ next unless frame.command?
119
+ cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
120
+ case cmd.name
121
+ when "SUBSCRIBE" then on_subscribe(conn, cmd.data)
122
+ when "CANCEL" then on_cancel(conn, cmd.data)
123
+ end
124
+ end
125
+ rescue *CONNECTION_LOST
126
+ @engine.connection_lost(conn)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end