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.
@@ -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
@@ -73,6 +75,7 @@ module NNQ
73
75
  def close_read
74
76
  @recv_queue.enqueue(nil)
75
77
  end
78
+
76
79
  end
77
80
  end
78
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,21 +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)
80
- @engine.emit_verbose_monitor_event(:message_sent, body: body)
85
+ @engine.emit_verbose_msg_sent(body)
81
86
  rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
82
87
  break
83
88
  end
84
- ensure
85
- @engine.handle_connection_lost(conn)
86
89
  end
87
90
  end
91
+
88
92
  end
89
93
  end
90
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.
@@ -41,6 +41,7 @@ module NNQ
41
41
  def close_read
42
42
  @queue.enqueue(nil)
43
43
  end
44
+
44
45
  end
45
46
  end
46
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
@@ -65,11 +69,11 @@ module NNQ
65
69
  end
66
70
 
67
71
  return if conn.closed?
68
- conn.send_message(btrace + body)
72
+ conn.send_message(body, header: btrace)
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
@@ -94,25 +98,6 @@ module NNQ
94
98
  end
95
99
 
96
100
 
97
- private
98
-
99
- # Reads 4-byte BE words off the front of +body+, stopping at the
100
- # first one whose top byte has its high bit set. Returns
101
- # [backtrace_bytes, remaining_payload] or nil on malformed input.
102
- def parse_backtrace(body)
103
- offset = 0
104
- hops = 0
105
- while hops < MAX_HOPS
106
- return nil if body.bytesize - offset < 4
107
- word = body.byteslice(offset, 4)
108
- offset += 4
109
- hops += 1
110
- if word.getbyte(0) & 0x80 != 0
111
- return [body.byteslice(0, offset), body.byteslice(offset..)]
112
- end
113
- end
114
- nil # exceeded TTL without finding terminator
115
- end
116
101
  end
117
102
  end
118
103
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/limited_queue"
4
+ require_relative "backtrace"
5
+
6
+ module NNQ
7
+ module Routing
8
+ # Raw REP: bypasses the cooked state machine. The incoming
9
+ # backtrace header is split off once at parse time and handed to
10
+ # the caller alongside the live Connection as `[pipe, header, body]`.
11
+ # Replies go back via `send(body, to:, header:)` which writes the
12
+ # caller-supplied header verbatim — no cooked pending/echo logic,
13
+ # no single-in-flight constraint.
14
+ #
15
+ class RepRaw
16
+ include Backtrace
17
+
18
+
19
+ def initialize(engine)
20
+ @engine = engine
21
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
22
+ end
23
+
24
+
25
+ # @return [Array, nil] [pipe, header, body] or nil on close
26
+ def receive
27
+ @recv_queue.dequeue
28
+ end
29
+
30
+
31
+ # Sends +body+ with the caller-supplied +header+ back to +to+
32
+ # (a Connection handed out by a prior #receive). Silent drop
33
+ # if the target is closed or the header would push total hops
34
+ # over MAX_HOPS.
35
+ def send(body, to:, header:)
36
+ return if to.closed?
37
+ return if Backtrace.too_many_hops?(header)
38
+ to.send_message(body, header: header)
39
+ rescue ClosedError
40
+ # peer went away between receive and send — drop
41
+ end
42
+
43
+
44
+ # Called by the engine recv loop.
45
+ def enqueue(wire_bytes, conn)
46
+ header, payload = parse_backtrace(wire_bytes)
47
+ return unless header # malformed / over-TTL — drop
48
+ @recv_queue.enqueue([conn, header, payload])
49
+ end
50
+
51
+
52
+ def close
53
+ @recv_queue.enqueue(nil)
54
+ end
55
+
56
+
57
+ def close_read
58
+ @recv_queue.enqueue(nil)
59
+ end
60
+
61
+ end
62
+ end
63
+ 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):
@@ -55,7 +55,7 @@ module NNQ
55
55
 
56
56
  conn = pick_peer
57
57
  header = [id].pack("N")
58
- conn.send_message(header + body)
58
+ conn.send_message(body, header: header)
59
59
  promise.wait
60
60
  ensure
61
61
  @mutex.synchronize do
@@ -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,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/limited_queue"
4
+ require_relative "backtrace"
5
+
6
+ module NNQ
7
+ module Routing
8
+ # Raw REQ: bypasses the cooked single-in-flight request-id
9
+ # state machine. Sends are fire-and-forget round-robin with a
10
+ # caller-supplied header (typically `[id | 0x80000000].pack("N")`);
11
+ # replies land in a bounded queue and are delivered as
12
+ # `[pipe, header, body]` tuples so the app can correlate by
13
+ # header verbatim without ever parsing or slicing bytes.
14
+ #
15
+ class ReqRaw
16
+ include Backtrace
17
+
18
+
19
+ def initialize(engine)
20
+ @engine = engine
21
+ @next_idx = 0
22
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
23
+ end
24
+
25
+
26
+ def send(body, header:)
27
+ conn = pick_peer
28
+ conn.send_message(body, header: header)
29
+ end
30
+
31
+
32
+ def receive
33
+ @recv_queue.dequeue
34
+ end
35
+
36
+
37
+ def enqueue(wire_bytes, conn)
38
+ header, payload = parse_backtrace(wire_bytes)
39
+ return unless header
40
+ @recv_queue.enqueue([conn, header, payload])
41
+ end
42
+
43
+
44
+ def close
45
+ @recv_queue.enqueue(nil)
46
+ end
47
+
48
+
49
+ def close_read
50
+ @recv_queue.enqueue(nil)
51
+ end
52
+
53
+
54
+ private
55
+
56
+
57
+ def pick_peer
58
+ loop do
59
+ conns = @engine.connections.keys
60
+
61
+ if conns.empty?
62
+ @engine.new_pipe.wait
63
+ next
64
+ end
65
+
66
+ @next_idx = (@next_idx + 1) % conns.size
67
+ return conns[@next_idx]
68
+ end
69
+ end
70
+
71
+ end
72
+ end
73
+ 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(body, header: btrace)
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
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async/limited_queue"
4
+ require_relative "backtrace"
5
+
6
+ module NNQ
7
+ module Routing
8
+ # Raw RESPONDENT: mirror of {RepRaw} for the survey pattern.
9
+ # No survey-window state, no pending slot — the app receives
10
+ # `[pipe, header, body]` tuples and chooses whether (and when)
11
+ # to reply via `send(body, to:, header:)`.
12
+ #
13
+ class RespondentRaw
14
+ include Backtrace
15
+
16
+
17
+ def initialize(engine)
18
+ @engine = engine
19
+ @recv_queue = Async::LimitedQueue.new(engine.options.recv_hwm)
20
+ end
21
+
22
+
23
+ def receive
24
+ @recv_queue.dequeue
25
+ end
26
+
27
+
28
+ def send(body, to:, header:)
29
+ return if to.closed?
30
+ return if Backtrace.too_many_hops?(header)
31
+ to.send_message(body, header: header)
32
+ rescue ClosedError
33
+ end
34
+
35
+
36
+ def enqueue(wire_bytes, conn)
37
+ header, payload = parse_backtrace(wire_bytes)
38
+ return unless header
39
+ @recv_queue.enqueue([conn, header, payload])
40
+ end
41
+
42
+
43
+ def close
44
+ @recv_queue.enqueue(nil)
45
+ end
46
+
47
+
48
+ def close_read
49
+ @recv_queue.enqueue(nil)
50
+ end
51
+
52
+ end
53
+ end
54
+ 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,16 +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
126
- batch.each { |body| @engine.emit_verbose_monitor_event(:message_sent, body: body) }
143
+
144
+ if @engine.verbose_monitor
145
+ batch.each { |body| @engine.emit_verbose_msg_sent(body) }
146
+ end
127
147
  end
148
+
128
149
  end
129
150
  end
130
151
  end