nnq 0.4.0 → 0.6.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.
@@ -53,11 +53,15 @@ module NNQ
53
53
  @queue.enqueue(nil)
54
54
  end
55
55
 
56
+
56
57
  private
57
58
 
59
+
58
60
  def matches?(body)
61
+ # OPTIMIZE: use Patricia-trie
59
62
  @subscriptions.any? { |prefix| body.start_with?(prefix) }
60
63
  end
64
+
61
65
  end
62
66
  end
63
67
  end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+ require "async/limited_queue"
6
+ require "securerandom"
7
+
8
+ module NNQ
9
+ module Routing
10
+ # SURVEYOR: broadcast side of the survey0 pattern.
11
+ #
12
+ # Wire format: each survey is prepended with a 4-byte BE survey ID
13
+ # (high bit set — same terminal-marker convention as REQ). Replies
14
+ # carry the same ID back. Stale replies (wrong ID) are dropped.
15
+ #
16
+ # Send side: fan-out to all connected respondents (like PUB). Each
17
+ # peer gets its own bounded queue and pump.
18
+ #
19
+ # Recv side: replies are matched by survey ID. Only replies
20
+ # matching the current survey are delivered. After `survey_time`
21
+ # elapses, {#receive} raises {NNQ::TimedOut}.
22
+ #
23
+ class Surveyor
24
+ def initialize(engine)
25
+ @engine = engine
26
+ @queues = {} # conn => Async::LimitedQueue
27
+ @pump_tasks = {} # conn => Async::Task
28
+ @recv_queue = Async::Queue.new
29
+ @current_id = nil
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+
34
+ # Broadcasts +body+ as a survey to all connected respondents.
35
+ # Starts a new survey window; any previous survey is abandoned.
36
+ #
37
+ # @param body [String]
38
+ def send_survey(body)
39
+ id = SecureRandom.random_number(0x80000000) | 0x80000000
40
+
41
+ @mutex.synchronize do
42
+ @current_id = id
43
+ end
44
+
45
+ header = [id].pack("N")
46
+ wire = header + body
47
+
48
+ @queues.each_value do |q|
49
+ q.enqueue(wire) unless q.limited?
50
+ end
51
+ end
52
+
53
+
54
+ # Receives the next reply within the survey window. Raises
55
+ # {NNQ::TimedOut} when the window expires.
56
+ #
57
+ # @return [String] reply body
58
+ def receive
59
+ survey_time = @engine.options.survey_time
60
+ Fiber.scheduler.with_timeout(survey_time) { @recv_queue.dequeue }
61
+ rescue Async::TimeoutError
62
+ raise NNQ::TimedOut, "survey timed out"
63
+ end
64
+
65
+
66
+ # Called by the engine recv loop with each received message.
67
+ def enqueue(body, _conn)
68
+ return if body.bytesize < 4
69
+
70
+ id = body.unpack1("N")
71
+ payload = body.byteslice(4..)
72
+
73
+ @mutex.synchronize do
74
+ return unless @current_id == id
75
+ end
76
+
77
+ @recv_queue.enqueue(payload)
78
+ end
79
+
80
+
81
+ def connection_added(conn)
82
+ queue = Async::LimitedQueue.new(@engine.options.send_hwm)
83
+ @queues[conn] = queue
84
+ @pump_tasks[conn] = spawn_pump(conn, queue)
85
+ end
86
+
87
+
88
+ def connection_removed(conn)
89
+ @queues.delete(conn)
90
+ task = @pump_tasks.delete(conn)
91
+
92
+ return unless task
93
+ return if task == Async::Task.current
94
+
95
+ task.stop
96
+ rescue IOError, Errno::EPIPE
97
+ end
98
+
99
+
100
+ def send_queue_drained?
101
+ @queues.each_value.all? { |q| q.empty? }
102
+ end
103
+
104
+
105
+ def close
106
+ @pump_tasks.each_value(&:stop)
107
+ @pump_tasks.clear
108
+ @queues.clear
109
+ @recv_queue.enqueue(nil)
110
+ end
111
+
112
+
113
+ def close_read
114
+ @recv_queue.enqueue(nil)
115
+ end
116
+
117
+
118
+ private
119
+
120
+
121
+ def spawn_pump(conn, queue)
122
+ annotation = "nnq surveyor pump #{conn.endpoint}"
123
+ parent = @engine.connections[conn]&.barrier || @engine.barrier
124
+
125
+ @engine.spawn_task(annotation:, parent:) do
126
+ loop do
127
+ body = queue.dequeue
128
+ conn.send_message(body)
129
+ @engine.emit_verbose_msg_sent(body)
130
+ rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
131
+ break
132
+ end
133
+ end
134
+ end
135
+
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/limited_queue"
5
+ require_relative "backtrace"
6
+
7
+ module NNQ
8
+ module Routing
9
+ # Raw SURVEYOR: fans out surveys to all peers like cooked
10
+ # {Surveyor}, but without a survey window, survey-id matching,
11
+ # or timeout. Replies are delivered as `[pipe, header, body]`
12
+ # tuples so the app can correlate by header verbatim.
13
+ #
14
+ # Each per-conn send queue holds `[header, body]` pairs and the
15
+ # pump calls `conn.write_message(body, header: header)` so the
16
+ # protocol-sp header kwarg is threaded through the fan-out —
17
+ # zero concat even on the broadcast path.
18
+ #
19
+ class SurveyorRaw
20
+ include Backtrace
21
+
22
+
23
+ def initialize(engine)
24
+ @engine = engine
25
+ @queues = {} # conn => Async::LimitedQueue
26
+ @pump_tasks = {} # conn => Async::Task
27
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
28
+ end
29
+
30
+
31
+ def send(body, header:)
32
+ @queues.each_value do |q|
33
+ q.enqueue([header, body]) unless q.limited?
34
+ end
35
+ end
36
+
37
+
38
+ def receive
39
+ @recv_queue.dequeue
40
+ end
41
+
42
+
43
+ def enqueue(wire_bytes, conn)
44
+ header, payload = parse_backtrace(wire_bytes)
45
+ return unless header
46
+ @recv_queue.enqueue([conn, header, payload])
47
+ end
48
+
49
+
50
+ def connection_added(conn)
51
+ queue = Async::LimitedQueue.new(@engine.options.send_hwm)
52
+ @queues[conn] = queue
53
+ @pump_tasks[conn] = spawn_pump(conn, queue)
54
+ end
55
+
56
+
57
+ def connection_removed(conn)
58
+ @queues.delete(conn)
59
+ task = @pump_tasks.delete(conn)
60
+
61
+ return unless task
62
+ return if task == Async::Task.current
63
+
64
+ task.stop
65
+ rescue IOError, Errno::EPIPE
66
+ end
67
+
68
+
69
+ def send_queue_drained?
70
+ @queues.each_value.all? { |q| q.empty? }
71
+ end
72
+
73
+
74
+ def close
75
+ @pump_tasks.each_value(&:stop)
76
+ @pump_tasks.clear
77
+ @queues.clear
78
+ @recv_queue.enqueue(nil)
79
+ end
80
+
81
+
82
+ def close_read
83
+ @recv_queue.enqueue(nil)
84
+ end
85
+
86
+
87
+ private
88
+
89
+
90
+ def spawn_pump(conn, queue)
91
+ annotation = "nnq surveyor_raw pump #{conn.endpoint}"
92
+ parent = @engine.connections[conn]&.barrier || @engine.barrier
93
+
94
+ @engine.spawn_task(annotation:, parent:) do
95
+ loop do
96
+ header, body = queue.dequeue
97
+ conn.send_message(body, header: header)
98
+ @engine.emit_verbose_msg_sent(body)
99
+ rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
100
+ break
101
+ end
102
+ end
103
+ end
104
+
105
+ end
106
+ end
107
+ end
data/lib/nnq/socket.rb CHANGED
@@ -30,9 +30,23 @@ module NNQ
30
30
  end
31
31
 
32
32
 
33
- def initialize(linger: nil, send_hwm: Options::DEFAULT_HWM)
34
- @options = Options.new(linger: linger, send_hwm: send_hwm)
33
+ # @yieldparam [self] the socket; when a block is passed the socket
34
+ # is {#close}d when the block returns (or raises), File.open-style.
35
+ def initialize(raw: false, linger: Float::INFINITY, send_hwm: Options::DEFAULT_HWM, recv_hwm: Options::DEFAULT_HWM)
36
+ @raw = raw
37
+ @options = Options.new(linger: linger, send_hwm: send_hwm, recv_hwm: recv_hwm)
35
38
  @engine = Engine.new(protocol: protocol, options: @options) { |engine| build_routing(engine) }
39
+
40
+ begin
41
+ yield self
42
+ ensure
43
+ close
44
+ end if block_given?
45
+ end
46
+
47
+
48
+ def raw?
49
+ @raw
36
50
  end
37
51
 
38
52
 
@@ -54,23 +68,35 @@ module NNQ
54
68
  end
55
69
 
56
70
 
57
- def last_endpoint = @engine.last_endpoint
71
+ def last_endpoint
72
+ @engine.last_endpoint
73
+ end
58
74
 
59
75
 
60
- def connection_count = @engine.connections.size
76
+ def connection_count
77
+ @engine.connections.size
78
+ end
61
79
 
62
80
 
63
81
  # Resolves with the first connected peer (or nil on close without
64
82
  # any peers). Block on `.wait` to wait until a connection is ready.
65
- def peer_connected = @engine.peer_connected
83
+ def peer_connected
84
+ @engine.peer_connected
85
+ end
66
86
 
67
87
 
68
88
  # Resolves with `true` the first time all peers have disconnected
69
89
  # (after at least one peer was connected). Edge-triggered.
70
- def all_peers_gone = @engine.all_peers_gone
90
+ def all_peers_gone
91
+ @engine.all_peers_gone
92
+ end
93
+
94
+
95
+ def reconnect_enabled
96
+ @engine.reconnect_enabled
97
+ end
71
98
 
72
99
 
73
- def reconnect_enabled = @engine.reconnect_enabled
74
100
  def reconnect_enabled=(value)
75
101
  @engine.reconnect_enabled = value
76
102
  end
@@ -94,9 +120,11 @@ module NNQ
94
120
  # @return [Async::Task]
95
121
  def monitor(verbose: false, &block)
96
122
  ensure_parent_task
97
- queue = Async::Queue.new
123
+
124
+ queue = Async::Queue.new
98
125
  @engine.monitor_queue = queue
99
126
  @engine.verbose_monitor = verbose
127
+
100
128
  Reactor.run do
101
129
  @engine.spawn_task(annotation: "nnq monitor") do
102
130
  while (event = queue.dequeue)
@@ -111,8 +139,22 @@ module NNQ
111
139
  end
112
140
 
113
141
 
142
+ # Coerces +body+ to a frozen binary string. Called by every send
143
+ # method so a caller can't mutate the string after it's been
144
+ # enqueued (the body sits in a send queue or per-peer queue until
145
+ # the pump writes it, and an unfrozen caller-owned buffer could be
146
+ # appended to mid-flight).
147
+ #
148
+ # Fast-path: already frozen + binary → returned as-is.
149
+ def frozen_binary(body)
150
+ return body if body.frozen? && body.encoding == Encoding::BINARY
151
+ body.b.freeze
152
+ end
153
+
154
+
114
155
  private
115
156
 
157
+
116
158
  def ensure_parent_task
117
159
  # Must run OUTSIDE Reactor.run so that non-Async callers capture
118
160
  # the IO thread's root task, not the ephemeral work-item task
@@ -136,5 +178,6 @@ module NNQ
136
178
  def build_routing(_engine)
137
179
  raise NotImplementedError
138
180
  end
181
+
139
182
  end
140
183
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "socket"
4
+ require_relative "routing/surveyor"
5
+ require_relative "routing/respondent"
6
+ require_relative "routing/surveyor_raw"
7
+ require_relative "routing/respondent_raw"
8
+
9
+ module NNQ
10
+ # SURVEYOR (nng surveyor0): broadcast side of the survey pattern.
11
+ # Cooked mode enforces a timed survey window and matches replies by
12
+ # survey id; raw mode fans out with a caller-supplied +header+ and
13
+ # delivers replies as `[pipe, header, body]` with no timer.
14
+ #
15
+ class SURVEYOR0 < Socket
16
+ # Cooked: broadcasts +body+ as a survey to all connected respondents.
17
+ def send_survey(body)
18
+ raise Error, "SURVEYOR#send_survey not available in raw mode" if raw?
19
+ body = frozen_binary(body)
20
+ Reactor.run { @engine.routing.send_survey(body) }
21
+ end
22
+
23
+
24
+ # Raw: broadcasts +body+ with +header+ to all connected respondents
25
+ # via per-conn send pumps (header is threaded through the
26
+ # protocol-sp header kwarg — no concat). Raises in cooked mode.
27
+ def send(body, header:)
28
+ raise Error, "SURVEYOR#send not available in cooked mode" unless raw?
29
+ body = frozen_binary(body)
30
+ Reactor.run { @engine.routing.send(body, header: header) }
31
+ end
32
+
33
+
34
+ # Cooked: receives the next reply within the survey window, raises
35
+ # {NNQ::TimedOut} on window expiry. Raw: returns `[pipe, header, body]`
36
+ # and blocks indefinitely (no survey window).
37
+ def receive
38
+ Reactor.run { @engine.routing.receive }
39
+ end
40
+
41
+
42
+ private
43
+
44
+
45
+ def protocol
46
+ Protocol::SP::Protocols::SURVEYOR_V0
47
+ end
48
+
49
+
50
+ def build_routing(engine)
51
+ raw? ? Routing::SurveyorRaw.new(engine) : Routing::Surveyor.new(engine)
52
+ end
53
+ end
54
+
55
+
56
+ # RESPONDENT (nng respondent0): reply side of the survey pattern.
57
+ # Cooked mode strictly alternates #receive / #send_reply; raw mode
58
+ # exposes the backtrace as an opaque +header+ and the originating
59
+ # surveyor pipe as a live Connection.
60
+ #
61
+ class RESPONDENT0 < Socket
62
+ # Cooked: blocks until the next survey arrives. Raw: returns
63
+ # `[pipe, header, body]`.
64
+ def receive
65
+ Reactor.run { @engine.routing.receive }
66
+ end
67
+
68
+
69
+ # Cooked: routes +body+ back to the surveyor that sent the most
70
+ # recent survey. Raises in raw mode.
71
+ def send_reply(body)
72
+ raise Error, "RESPONDENT#send_reply not available in raw mode" if raw?
73
+ body = frozen_binary(body)
74
+ Reactor.run { @engine.routing.send_reply(body) }
75
+ end
76
+
77
+
78
+ # Raw: writes +body+ with +header+ back to +to+. Raises in cooked mode.
79
+ def send(body, to:, header:)
80
+ raise Error, "RESPONDENT#send not available in cooked mode" unless raw?
81
+ body = frozen_binary(body)
82
+ Reactor.run { @engine.routing.send(body, to: to, header: header) }
83
+ end
84
+
85
+
86
+ private
87
+
88
+
89
+ def protocol
90
+ Protocol::SP::Protocols::RESPONDENT_V0
91
+ end
92
+
93
+
94
+ def build_routing(engine)
95
+ raw? ? Routing::RespondentRaw.new(engine) : Routing::Respondent.new(engine)
96
+ end
97
+ end
98
+ end
@@ -31,6 +31,7 @@ module NNQ
31
31
  raise Error, "inproc endpoint already bound: #{endpoint}" if @registry.key?(endpoint)
32
32
  @registry[endpoint] = engine
33
33
  end
34
+
34
35
  Listener.new(endpoint)
35
36
  end
36
37
 
@@ -46,7 +47,9 @@ module NNQ
46
47
  def connect(endpoint, engine)
47
48
  bound = @mutex.synchronize { @registry[endpoint] }
48
49
  raise Error, "inproc endpoint not bound: #{endpoint}" unless bound
50
+
49
51
  a, b = UNIXSocket.pair
52
+
50
53
  # Handshake on the bound side must run concurrently with
51
54
  # ours — if we called bound.handle_accepted synchronously
52
55
  # it would block on reading our greeting before we've had
@@ -75,6 +78,7 @@ module NNQ
75
78
  class Listener
76
79
  attr_reader :endpoint
77
80
 
81
+
78
82
  def initialize(endpoint)
79
83
  @endpoint = endpoint
80
84
  end
@@ -88,6 +92,7 @@ module NNQ
88
92
  def stop
89
93
  Inproc.unbind(@endpoint)
90
94
  end
95
+
91
96
  end
92
97
  end
93
98
  end
@@ -22,7 +22,9 @@ module NNQ
22
22
  def bind(endpoint, engine)
23
23
  path = parse_path(endpoint)
24
24
  sock_path = to_socket_path(path)
25
+
25
26
  File.delete(sock_path) if !abstract?(path) && File.exist?(sock_path)
27
+
26
28
  server = UNIXServer.new(sock_path)
27
29
  Listener.new(endpoint, server, path, engine)
28
30
  end
@@ -91,6 +93,7 @@ module NNQ
91
93
  @server.close rescue nil
92
94
  File.delete(@path) rescue nil unless IPC.abstract?(@path)
93
95
  end
96
+
94
97
  end
95
98
  end
96
99
  end
@@ -19,10 +19,11 @@ module NNQ
19
19
  # @return [Listener]
20
20
  def bind(endpoint, engine)
21
21
  host, port = parse_endpoint(endpoint)
22
- host = "0.0.0.0" if host == "*"
23
- server = TCPServer.new(host, port)
24
- actual = server.local_address.ip_port
25
- host_part = host.include?(":") ? "[#{host}]" : host
22
+ host = "0.0.0.0" if host == "*"
23
+ server = TCPServer.new(host, port)
24
+ actual = server.local_address.ip_port
25
+ host_part = host.include?(":") ? "[#{host}]" : host
26
+
26
27
  Listener.new("tcp://#{host_part}:#{actual}", server, actual, engine)
27
28
  end
28
29
 
@@ -35,11 +36,23 @@ module NNQ
35
36
  # @return [void]
36
37
  def connect(endpoint, engine)
37
38
  host, port = parse_endpoint(endpoint)
38
- sock = TCPSocket.new(host, port)
39
+ sock = ::Socket.tcp(host, port, connect_timeout: connect_timeout(engine.options))
40
+
39
41
  engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: endpoint)
40
42
  end
41
43
 
42
44
 
45
+ # Connect timeout: cap each attempt at the reconnect interval so
46
+ # a hung connect(2) (e.g. macOS kqueue + IPv6 ECONNREFUSED not
47
+ # delivered) doesn't block the retry loop. Floor at 0.5s for
48
+ # real-network latency.
49
+ def connect_timeout(options)
50
+ ri = options.reconnect_interval
51
+ ri = ri.end if ri.is_a?(Range)
52
+ [ri, 0.5].max
53
+ end
54
+
55
+
43
56
  def parse_endpoint(endpoint)
44
57
  uri = URI.parse(endpoint)
45
58
  [uri.hostname, uri.port]
@@ -50,9 +63,17 @@ module NNQ
50
63
  # A bound TCP listener.
51
64
  #
52
65
  class Listener
66
+ # @return [String] the normalised endpoint URL this listener is
67
+ # bound to (with the resolved port substituted when the user
68
+ # bound to port 0).
53
69
  attr_reader :endpoint
70
+
71
+ # @return [Integer] the actual TCP port the listener is bound
72
+ # to. Equals the requested port unless 0 was requested, in
73
+ # which case it reflects the kernel-assigned ephemeral port.
54
74
  attr_reader :port
55
75
 
76
+
56
77
  def initialize(endpoint, server, port, engine)
57
78
  @endpoint = endpoint
58
79
  @server = server
@@ -84,6 +105,7 @@ module NNQ
84
105
  @task&.stop
85
106
  @server.close rescue nil
86
107
  end
108
+
87
109
  end
88
110
  end
89
111
  end
data/lib/nnq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NNQ
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/nnq.rb CHANGED
@@ -23,3 +23,5 @@ require_relative "nnq/push_pull"
23
23
  require_relative "nnq/pair"
24
24
  require_relative "nnq/req_rep"
25
25
  require_relative "nnq/pub_sub"
26
+ require_relative "nnq/bus"
27
+ require_relative "nnq/surveyor_respondent"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nnq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - ">="
45
45
  - !ruby/object:Gem::Version
46
- version: '0.1'
46
+ version: '0.3'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - ">="
52
52
  - !ruby/object:Gem::Version
53
- version: '0.1'
53
+ version: '0.3'
54
54
  description: Pure Ruby implementation of nanomsg's Scalability Protocols (SP) on top
55
55
  of async + io-stream. Per-socket HWM, opportunistic batching, wire-compatible with
56
56
  libnng over inproc/ipc/tcp.
@@ -64,6 +64,7 @@ files:
64
64
  - LICENSE
65
65
  - README.md
66
66
  - lib/nnq.rb
67
+ - lib/nnq/bus.rb
67
68
  - lib/nnq/connection.rb
68
69
  - lib/nnq/engine.rb
69
70
  - lib/nnq/engine/connection_lifecycle.rb
@@ -77,15 +78,24 @@ files:
77
78
  - lib/nnq/push_pull.rb
78
79
  - lib/nnq/reactor.rb
79
80
  - lib/nnq/req_rep.rb
81
+ - lib/nnq/routing/backtrace.rb
82
+ - lib/nnq/routing/bus.rb
80
83
  - lib/nnq/routing/pair.rb
81
84
  - lib/nnq/routing/pub.rb
82
85
  - lib/nnq/routing/pull.rb
83
86
  - lib/nnq/routing/push.rb
84
87
  - lib/nnq/routing/rep.rb
88
+ - lib/nnq/routing/rep_raw.rb
85
89
  - lib/nnq/routing/req.rb
90
+ - lib/nnq/routing/req_raw.rb
91
+ - lib/nnq/routing/respondent.rb
92
+ - lib/nnq/routing/respondent_raw.rb
86
93
  - lib/nnq/routing/send_pump.rb
87
94
  - lib/nnq/routing/sub.rb
95
+ - lib/nnq/routing/surveyor.rb
96
+ - lib/nnq/routing/surveyor_raw.rb
88
97
  - lib/nnq/socket.rb
98
+ - lib/nnq/surveyor_respondent.rb
89
99
  - lib/nnq/transport/inproc.rb
90
100
  - lib/nnq/transport/ipc.rb
91
101
  - lib/nnq/transport/tcp.rb