nnq 0.2.0 → 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.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ module Routing
5
+ # Shared backtrace parsing for SP protocols that use the
6
+ # request-id / hop-stack wire format (REQ/REP, SURVEYOR/RESPONDENT).
7
+ #
8
+ # Wire format: one or more 4-byte big-endian words. The terminal
9
+ # word (request or survey id) has its high bit set (0x80). Preceding
10
+ # words (hop ids added by devices) have the high bit clear.
11
+ #
12
+ module Backtrace
13
+ MAX_HOPS = 8 # nng's default ttl
14
+
15
+ # Reads 4-byte BE words off the front of +body+, stopping at the
16
+ # first one whose top byte has its high bit set. Returns
17
+ # [backtrace_bytes, remaining_payload] or nil on malformed input.
18
+ def parse_backtrace(body)
19
+ offset = 0
20
+ hops = 0
21
+
22
+ while hops < MAX_HOPS
23
+ return nil if body.bytesize - offset < 4
24
+
25
+ word = body.byteslice(offset, 4)
26
+ offset += 4
27
+ hops += 1
28
+
29
+ if word.getbyte(0) & 0x80 != 0
30
+ return [body.byteslice(0, offset), body.byteslice(offset..)]
31
+ end
32
+ end
33
+
34
+ nil # exceeded TTL without finding terminator
35
+ end
36
+
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "async/queue"
5
+ require "async/limited_queue"
6
+
7
+ module NNQ
8
+ module Routing
9
+ # BUS0: best-effort bidirectional mesh.
10
+ #
11
+ # Send side: fan-out to all connected peers. Each peer gets its own
12
+ # bounded send queue and pump fiber — a slow peer drops messages
13
+ # instead of blocking fast ones (same as PUB). Send never blocks.
14
+ #
15
+ # Recv side: all incoming messages are pushed into a shared
16
+ # unbounded queue (same as PULL).
17
+ #
18
+ # No SP headers in cooked mode — body on the wire is the user
19
+ # payload.
20
+ #
21
+ class Bus
22
+ def initialize(engine)
23
+ @engine = engine
24
+ @queues = {} # conn => Async::LimitedQueue
25
+ @pump_tasks = {} # conn => Async::Task
26
+ @recv_queue = Async::Queue.new
27
+ end
28
+
29
+
30
+ # Broadcasts +body+ to every connected peer. Non-blocking per
31
+ # peer: drops when a peer's queue is at HWM.
32
+ #
33
+ # @param body [String]
34
+ def send(body)
35
+ @queues.each_value do |queue|
36
+ queue.enqueue(body) unless queue.limited?
37
+ end
38
+ end
39
+
40
+
41
+ # Called by the engine recv loop with each received message.
42
+ def enqueue(body, _conn = nil)
43
+ @recv_queue.enqueue(body)
44
+ end
45
+
46
+
47
+ # @return [String, nil] message body, or nil once the socket is closed
48
+ def receive
49
+ @recv_queue.dequeue
50
+ end
51
+
52
+
53
+ def connection_added(conn)
54
+ queue = Async::LimitedQueue.new(@engine.options.send_hwm)
55
+ @queues[conn] = queue
56
+ @pump_tasks[conn] = spawn_pump(conn, queue)
57
+ end
58
+
59
+
60
+ def connection_removed(conn)
61
+ @queues.delete(conn)
62
+ task = @pump_tasks.delete(conn)
63
+ return unless task
64
+ return if task == Async::Task.current
65
+ task.stop
66
+ rescue IOError, Errno::EPIPE
67
+ end
68
+
69
+
70
+ def send_queue_drained?
71
+ @queues.each_value.all? { |q| q.empty? }
72
+ end
73
+
74
+
75
+ def close
76
+ @pump_tasks.each_value(&:stop)
77
+ @pump_tasks.clear
78
+ @queues.clear
79
+ @recv_queue.enqueue(nil)
80
+ end
81
+
82
+
83
+ def close_read
84
+ @recv_queue.enqueue(nil)
85
+ end
86
+
87
+
88
+ private
89
+
90
+
91
+ def spawn_pump(conn, queue)
92
+ annotation = "nnq bus pump #{conn.endpoint}"
93
+ parent = @engine.connections[conn]&.barrier || @engine.barrier
94
+
95
+ @engine.spawn_task(annotation:, parent:) do
96
+ loop do
97
+ body = queue.dequeue
98
+ conn.send_message(body)
99
+ @engine.emit_verbose_msg_sent(body)
100
+ rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
101
+ break
102
+ end
103
+ end
104
+ end
105
+
106
+ end
107
+ end
108
+ end
@@ -22,6 +22,7 @@ module NNQ
22
22
  class Pair
23
23
  include SendPump
24
24
 
25
+
25
26
  def initialize(engine)
26
27
  init_send_pump(engine)
27
28
  @recv_queue = Async::Queue.new
@@ -41,7 +42,7 @@ module NNQ
41
42
  end
42
43
 
43
44
 
44
- # Called by the recv loop with each frame off the wire.
45
+ # Called by the recv loop with each message off the wire.
45
46
  def enqueue(body, _conn = nil)
46
47
  @recv_queue.enqueue(body)
47
48
  end
@@ -52,6 +53,7 @@ module NNQ
52
53
  # without ever exposing it to pumps.
53
54
  def connection_added(conn)
54
55
  raise ConnectionRejected, "PAIR socket already has a peer" if @peer
56
+
55
57
  @peer = conn
56
58
  spawn_send_pump_for(conn)
57
59
  end
@@ -67,6 +69,13 @@ module NNQ
67
69
  super
68
70
  @recv_queue.enqueue(nil) # wake any waiter
69
71
  end
72
+
73
+
74
+ # Wake recv side without tearing down the send pump.
75
+ def close_read
76
+ @recv_queue.enqueue(nil)
77
+ end
78
+
70
79
  end
71
80
  end
72
81
  end
@@ -60,7 +60,7 @@ module NNQ
60
60
 
61
61
  # True once every peer's queue is empty. Engine linger polls this.
62
62
  def send_queue_drained?
63
- @queues.each_value.all?(&:empty?)
63
+ @queues.each_value.all? { |q| q.empty? }
64
64
  end
65
65
 
66
66
 
@@ -70,20 +70,25 @@ module NNQ
70
70
  @queues.clear
71
71
  end
72
72
 
73
+
73
74
  private
74
75
 
76
+
75
77
  def spawn_pump(conn, queue)
76
- @engine.spawn_task(annotation: "nnq pub pump #{conn.endpoint}") do
78
+ annotation = "nnq pub pump #{conn.endpoint}"
79
+ parent = @engine.connections[conn]&.barrier || @engine.barrier
80
+
81
+ @engine.spawn_task(annotation:, parent:) do
77
82
  loop do
78
83
  body = queue.dequeue
79
84
  conn.send_message(body)
85
+ @engine.emit_verbose_msg_sent(body)
80
86
  rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
81
87
  break
82
88
  end
83
- ensure
84
- @engine.handle_connection_lost(conn)
85
89
  end
86
90
  end
91
+
87
92
  end
88
93
  end
89
94
  end
@@ -6,7 +6,7 @@ module NNQ
6
6
  module Routing
7
7
  # PULL side: an unbounded queue of received messages. Per-connection
8
8
  # recv fibers (spawned by the Engine when each pipe is established)
9
- # call {#enqueue} on each frame; user code calls {#receive}.
9
+ # call {#enqueue} on each message; user code calls {#receive}.
10
10
  #
11
11
  # No HWM, no prefetch buffer — TCP throttles the senders directly
12
12
  # via the kernel buffer.
@@ -33,6 +33,15 @@ module NNQ
33
33
  def close
34
34
  @queue.enqueue(nil)
35
35
  end
36
+
37
+
38
+ # Wakes any waiters with nil, leaving the send side untouched
39
+ # (PULL has no send side — close_read is identical to close here,
40
+ # but kept separate for the `Socket#close_read` contract).
41
+ def close_read
42
+ @queue.enqueue(nil)
43
+ end
44
+
36
45
  end
37
46
  end
38
47
  end
@@ -17,6 +17,7 @@ module NNQ
17
17
  class Push
18
18
  include SendPump
19
19
 
20
+
20
21
  def initialize(engine)
21
22
  init_send_pump(engine)
22
23
  end
@@ -38,6 +39,7 @@ module NNQ
38
39
  def connection_removed(conn)
39
40
  remove_send_pump_for(conn)
40
41
  end
42
+
41
43
  end
42
44
  end
43
45
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "async/queue"
4
+ require_relative "backtrace"
4
5
 
5
6
  module NNQ
6
7
  module Routing
@@ -27,7 +28,8 @@ module NNQ
27
28
  # - TTL cap on the backtrace stack: 8 hops, matching nng's default.
28
29
  #
29
30
  class Rep
30
- MAX_HOPS = 8 # nng's default ttl
31
+ include Backtrace
32
+
31
33
 
32
34
  def initialize(engine)
33
35
  @engine = engine
@@ -46,7 +48,9 @@ module NNQ
46
48
  # again without replying is how users drop unwanted requests.
47
49
  @mutex.synchronize { @pending = nil }
48
50
  item = @recv_queue.dequeue
51
+
49
52
  return nil if item.nil?
53
+
50
54
  conn, btrace, body = item
51
55
  @mutex.synchronize { @pending = [conn, btrace] }
52
56
  body
@@ -69,7 +73,7 @@ module NNQ
69
73
  end
70
74
 
71
75
 
72
- # Called by the engine recv loop with each received frame.
76
+ # Called by the engine recv loop with each received message.
73
77
  def enqueue(body, conn)
74
78
  btrace, payload = parse_backtrace(body)
75
79
  return unless btrace # malformed/over-TTL — drop
@@ -89,25 +93,11 @@ module NNQ
89
93
  end
90
94
 
91
95
 
92
- private
93
-
94
- # Reads 4-byte BE words off the front of +body+, stopping at the
95
- # first one whose top byte has its high bit set. Returns
96
- # [backtrace_bytes, remaining_payload] or nil on malformed input.
97
- def parse_backtrace(body)
98
- offset = 0
99
- hops = 0
100
- while hops < MAX_HOPS
101
- return nil if body.bytesize - offset < 4
102
- word = body.byteslice(offset, 4)
103
- offset += 4
104
- hops += 1
105
- if word.getbyte(0) & 0x80 != 0
106
- return [body.byteslice(0, offset), body.byteslice(offset..)]
107
- end
108
- end
109
- nil # exceeded TTL without finding terminator
96
+ def close_read
97
+ @recv_queue.enqueue(nil)
110
98
  end
99
+
100
+
111
101
  end
112
102
  end
113
103
  end
@@ -10,7 +10,7 @@ module NNQ
10
10
  # Wire format: each message body on the wire is `[4-byte BE
11
11
  # request_id][user_payload]`. The request id has the high bit set
12
12
  # (`0x80000000..0xFFFFFFFF`) — that's nng's marker for "this is the
13
- # last (deepest) frame on the backtrace stack". Direct REQ→REP has
13
+ # last (deepest) word on the backtrace stack". Direct REQ→REP has
14
14
  # exactly one id.
15
15
  #
16
16
  # Semantics (cooked mode, what this implements):
@@ -66,7 +66,7 @@ module NNQ
66
66
  end
67
67
 
68
68
 
69
- # Called by the engine recv loop with each received frame.
69
+ # Called by the engine recv loop with each received message.
70
70
  def enqueue(body, _conn)
71
71
  return if body.bytesize < 4
72
72
  id = body.unpack1("N")
@@ -91,17 +91,21 @@ module NNQ
91
91
 
92
92
  private
93
93
 
94
+
94
95
  def pick_peer
95
96
  loop do
96
97
  conns = @engine.connections.keys
98
+
97
99
  if conns.empty?
98
100
  @engine.new_pipe.wait
99
101
  next
100
102
  end
103
+
101
104
  @next_idx = (@next_idx + 1) % conns.size
102
105
  return conns[@next_idx]
103
106
  end
104
107
  end
108
+
105
109
  end
106
110
  end
107
111
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/queue"
4
+ require_relative "backtrace"
5
+
6
+ module NNQ
7
+ module Routing
8
+ # RESPONDENT: reply side of the survey0 pattern.
9
+ #
10
+ # Semantics mirror REP: strict alternation of #receive then
11
+ # #send_reply. The backtrace (survey ID + any hop IDs) is stripped
12
+ # on receive and echoed verbatim on reply.
13
+ #
14
+ class Respondent
15
+ include Backtrace
16
+
17
+
18
+ def initialize(engine)
19
+ @engine = engine
20
+ @recv_queue = Async::Queue.new
21
+ @pending = nil
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+
26
+ # Receives the next survey body. Stashes the backtrace +
27
+ # originating connection for the subsequent #send_reply.
28
+ #
29
+ # @return [String, nil] survey body, or nil if the socket was closed
30
+ def receive
31
+ @mutex.synchronize { @pending = nil }
32
+ item = @recv_queue.dequeue
33
+
34
+ return nil if item.nil?
35
+
36
+ conn, btrace, body = item
37
+ @mutex.synchronize { @pending = [conn, btrace] }
38
+ body
39
+ end
40
+
41
+
42
+ # Sends +body+ as the reply to the most recently received survey.
43
+ #
44
+ # @param body [String]
45
+ def send_reply(body)
46
+ conn, btrace = @mutex.synchronize do
47
+ raise Error, "RESPONDENT socket has no pending survey to reply to" unless @pending
48
+ taken = @pending
49
+ @pending = nil
50
+ taken
51
+ end
52
+
53
+ return if conn.closed?
54
+ conn.send_message(btrace + body)
55
+ end
56
+
57
+
58
+ # Called by the engine recv loop with each received message.
59
+ def enqueue(body, conn)
60
+ btrace, payload = parse_backtrace(body)
61
+ return unless btrace # malformed/over-TTL — drop
62
+ @recv_queue.enqueue([conn, btrace, payload])
63
+ end
64
+
65
+
66
+ def connection_removed(conn)
67
+ @mutex.synchronize do
68
+ @pending = nil if @pending && @pending[0] == conn
69
+ end
70
+ end
71
+
72
+
73
+ def close
74
+ @recv_queue.enqueue(nil)
75
+ end
76
+
77
+
78
+ def close_read
79
+ @recv_queue.enqueue(nil)
80
+ end
81
+
82
+ end
83
+ end
84
+ end
@@ -21,7 +21,13 @@ module NNQ
21
21
  # {#spawn_send_pump_for} from their #connection_added hook.
22
22
  #
23
23
  module SendPump
24
+ # Max messages a single pump will drain from the shared queue in
25
+ # one batch before yielding. Bounds the worst-case latency other
26
+ # pumps wait when the queue is under sustained pressure.
24
27
  BATCH_MSG_CAP = 256
28
+
29
+ # Max cumulative body bytes in one batch. Keeps a single pump
30
+ # from monopolising the writer mutex on huge payloads.
25
31
  BATCH_BYTE_CAP = 256 * 1024
26
32
 
27
33
 
@@ -56,6 +62,7 @@ module NNQ
56
62
 
57
63
  private
58
64
 
65
+
59
66
  # @param engine [Engine]
60
67
  def init_send_pump(engine)
61
68
  @engine = engine
@@ -79,11 +86,16 @@ module NNQ
79
86
  #
80
87
  # @param conn [Connection]
81
88
  def spawn_send_pump_for(conn)
82
- task = @engine.spawn_task(annotation: "nnq send pump #{conn.endpoint}") do
89
+ annotation = "nnq send pump #{conn.endpoint}"
90
+ parent = @engine.connections[conn]&.barrier || @engine.barrier
91
+
92
+ task = @engine.spawn_task(annotation:, parent:) do
83
93
  loop do
84
94
  first = @send_queue.dequeue
85
95
  break if first.nil? # queue closed
96
+
86
97
  @in_flight += 1
98
+
87
99
  begin
88
100
  batch = [first]
89
101
  drain_capped(batch)
@@ -91,22 +103,26 @@ module NNQ
91
103
  ensure
92
104
  @in_flight -= 1
93
105
  end
106
+
107
+ Async::Task.current.yield
94
108
  rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
95
109
  # Peer died mid-flush. In-flight batch dropped.
96
110
  break
97
111
  end
98
- ensure
99
- @engine.handle_connection_lost(conn)
100
112
  end
113
+
101
114
  @pumps[conn] = task
102
115
  end
103
116
 
104
117
 
105
118
  def drain_capped(batch)
106
- bytes = batch[0].bytesize
119
+ bytes = batch.first.bytesize
120
+
107
121
  while batch.size < BATCH_MSG_CAP && bytes < BATCH_BYTE_CAP
108
122
  msg = @send_queue.dequeue(timeout: 0)
123
+
109
124
  break unless msg
125
+
110
126
  batch << msg
111
127
  bytes += msg.bytesize
112
128
  end
@@ -115,15 +131,21 @@ module NNQ
115
131
 
116
132
  def write_batch(conn, batch)
117
133
  if batch.size == 1
118
- conn.write_message(batch[0])
134
+ conn.write_message(batch.first)
119
135
  else
120
136
  # Single mutex acquisition for the whole batch (batches run
121
137
  # up to BATCH_MSG_CAP messages). The per-message pump loop
122
138
  # would otherwise lock/unlock the SP mutex N times.
123
139
  conn.write_messages(batch)
124
140
  end
141
+
125
142
  conn.flush
143
+
144
+ if @engine.verbose_monitor
145
+ batch.each { |body| @engine.emit_verbose_msg_sent(body) }
146
+ end
126
147
  end
148
+
127
149
  end
128
150
  end
129
151
  end
@@ -48,11 +48,20 @@ module NNQ
48
48
  @queue.enqueue(nil)
49
49
  end
50
50
 
51
+
52
+ def close_read
53
+ @queue.enqueue(nil)
54
+ end
55
+
56
+
51
57
  private
52
58
 
59
+
53
60
  def matches?(body)
61
+ # OPTIMIZE: use Patricia-trie
54
62
  @subscriptions.any? { |prefix| body.start_with?(prefix) }
55
63
  end
64
+
56
65
  end
57
66
  end
58
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