nnq 0.4.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.
data/lib/nnq/options.rb CHANGED
@@ -18,14 +18,21 @@ module NNQ
18
18
  attr_accessor :reconnect_interval
19
19
  attr_accessor :max_message_size
20
20
  attr_accessor :send_hwm
21
+ attr_accessor :survey_time
21
22
 
22
- def initialize(linger: nil, send_hwm: DEFAULT_HWM)
23
+
24
+ # @param linger [Numeric] linger period in seconds on close
25
+ # (default Float::INFINITY = wait forever, matching libzmq).
26
+ # Pass 0 for immediate drop-on-close.
27
+ def initialize(linger: Float::INFINITY, send_hwm: DEFAULT_HWM)
23
28
  @linger = linger
24
29
  @read_timeout = nil
25
30
  @write_timeout = nil
26
31
  @reconnect_interval = 0.1
27
32
  @max_message_size = nil
28
33
  @send_hwm = send_hwm
34
+ @survey_time = 1.0
29
35
  end
36
+
30
37
  end
31
38
  end
data/lib/nnq/pair.rb CHANGED
@@ -8,8 +8,9 @@ module NNQ
8
8
  # peer. First peer to connect wins; subsequent peers are dropped
9
9
  # until the current one disconnects. No SP header on the wire.
10
10
  #
11
- class PAIR < Socket
11
+ class PAIR0 < Socket
12
12
  def send(body)
13
+ body = frozen_binary(body)
13
14
  Reactor.run { @engine.routing.send(body) }
14
15
  end
15
16
 
@@ -21,6 +22,7 @@ module NNQ
21
22
 
22
23
  private
23
24
 
25
+
24
26
  def protocol
25
27
  Protocol::SP::Protocols::PAIR_V0
26
28
  end
@@ -30,4 +32,7 @@ module NNQ
30
32
  Routing::Pair.new(engine)
31
33
  end
32
34
  end
35
+
36
+
37
+ PAIR = PAIR0
33
38
  end
data/lib/nnq/pub_sub.rb CHANGED
@@ -10,14 +10,16 @@ module NNQ
10
10
  # a slow peer drops messages instead of blocking fast peers.
11
11
  # Defaults to listening.
12
12
  #
13
- class PUB < Socket
13
+ class PUB0 < Socket
14
14
  def send(body)
15
+ body = frozen_binary(body)
15
16
  Reactor.run { @engine.routing.send(body) }
16
17
  end
17
18
 
18
19
 
19
20
  private
20
21
 
22
+
21
23
  def protocol
22
24
  Protocol::SP::Protocols::PUB_V0
23
25
  end
@@ -34,7 +36,7 @@ module NNQ
34
36
  # are delivered — matching nng (unlike pre-4.x ZeroMQ). Defaults
35
37
  # to dialing.
36
38
  #
37
- class SUB < Socket
39
+ class SUB0 < Socket
38
40
  # Subscribes to +prefix+. Bytes-level match. The empty string
39
41
  # matches everything.
40
42
  #
@@ -60,6 +62,7 @@ module NNQ
60
62
 
61
63
  private
62
64
 
65
+
63
66
  def protocol
64
67
  Protocol::SP::Protocols::SUB_V0
65
68
  end
@@ -69,4 +72,8 @@ module NNQ
69
72
  Routing::Sub.new
70
73
  end
71
74
  end
75
+
76
+
77
+ PUB = PUB0
78
+ SUB = SUB0
72
79
  end
data/lib/nnq/push_pull.rb CHANGED
@@ -9,14 +9,16 @@ module NNQ
9
9
  # bounded send queue (`send_hwm`); per-peer send pumps work-steal from
10
10
  # it. Defaults to dialing.
11
11
  #
12
- class PUSH < Socket
12
+ class PUSH0 < Socket
13
13
  def send(body)
14
+ body = frozen_binary(body)
14
15
  Reactor.run { @engine.routing.send(body) }
15
16
  end
16
17
 
17
18
 
18
19
  private
19
20
 
21
+
20
22
  def protocol
21
23
  Protocol::SP::Protocols::PUSH_V0
22
24
  end
@@ -32,7 +34,7 @@ module NNQ
32
34
  # from all live PUSH peers into one unbounded receive queue. Defaults
33
35
  # to listening.
34
36
  #
35
- class PULL < Socket
37
+ class PULL0 < Socket
36
38
  def receive
37
39
  Reactor.run do
38
40
  if (timeout = @engine.options.read_timeout)
@@ -46,6 +48,7 @@ module NNQ
46
48
 
47
49
  private
48
50
 
51
+
49
52
  def protocol
50
53
  Protocol::SP::Protocols::PULL_V0
51
54
  end
@@ -55,4 +58,8 @@ module NNQ
55
58
  Routing::Pull.new
56
59
  end
57
60
  end
61
+
62
+
63
+ PUSH = PUSH0
64
+ PULL = PULL0
58
65
  end
data/lib/nnq/reactor.rb CHANGED
@@ -22,11 +22,14 @@ module NNQ
22
22
  @root_task = nil
23
23
  @work_queue = nil
24
24
 
25
+
25
26
  class << self
26
27
  def root_task
27
28
  return @root_task if @root_task
29
+
28
30
  @mutex.synchronize do
29
31
  return @root_task if @root_task
32
+
30
33
  ready = Thread::Queue.new
31
34
  @work_queue = Async::Queue.new
32
35
  @thread = Thread.new { run_reactor(ready) }
@@ -34,6 +37,7 @@ module NNQ
34
37
  @root_task = ready.pop
35
38
  at_exit { stop! }
36
39
  end
40
+
37
41
  @root_task
38
42
  end
39
43
 
@@ -42,12 +46,10 @@ module NNQ
42
46
  if Async::Task.current?
43
47
  yield
44
48
  else
45
- result = Thread::Queue.new
49
+ result = Async::Promise.new
46
50
  root_task # ensure started
47
51
  @work_queue.push([block, result])
48
- status, value = result.pop
49
- raise value if status == :error
50
- value
52
+ result.wait
51
53
  end
52
54
  end
53
55
 
@@ -61,23 +63,22 @@ module NNQ
61
63
  @work_queue = nil
62
64
  end
63
65
 
66
+
64
67
  private
65
68
 
69
+
66
70
  def run_reactor(ready)
67
71
  Async do |task|
68
72
  ready.push(task)
73
+
69
74
  loop do
70
- item = @work_queue.dequeue
71
- break if item.nil?
75
+ item = @work_queue.dequeue or break
72
76
  block, result = item
73
- task.async do
74
- result.push([:ok, block.call])
75
- rescue => e
76
- result.push([:error, e])
77
- end
77
+ task.async { result.fulfill { block.call } }
78
78
  end
79
79
  end
80
80
  end
81
+
81
82
  end
82
83
  end
83
84
  end
data/lib/nnq/req_rep.rb CHANGED
@@ -9,16 +9,18 @@ module NNQ
9
9
  # request per socket. #send_request blocks until the matching reply
10
10
  # comes back.
11
11
  #
12
- class REQ < Socket
12
+ class REQ0 < Socket
13
13
  # Sends +body+ as a request, blocks until the matching reply
14
14
  # arrives. Returns the reply body (without the id header).
15
15
  def send_request(body)
16
+ body = frozen_binary(body)
16
17
  Reactor.run { @engine.routing.send_request(body) }
17
18
  end
18
19
 
19
20
 
20
21
  private
21
22
 
23
+
22
24
  def protocol
23
25
  Protocol::SP::Protocols::REQ_V0
24
26
  end
@@ -33,7 +35,7 @@ module NNQ
33
35
  # REP (nng rep0): server side of request/reply. Strict alternation
34
36
  # of #receive then #send_reply, per request.
35
37
  #
36
- class REP < Socket
38
+ class REP0 < Socket
37
39
  # Blocks until the next request arrives. Returns the request body.
38
40
  def receive
39
41
  Reactor.run { @engine.routing.receive }
@@ -42,12 +44,14 @@ module NNQ
42
44
 
43
45
  # Routes +body+ back to the pipe the most recent #receive came from.
44
46
  def send_reply(body)
47
+ body = frozen_binary(body)
45
48
  Reactor.run { @engine.routing.send_reply(body) }
46
49
  end
47
50
 
48
51
 
49
52
  private
50
53
 
54
+
51
55
  def protocol
52
56
  Protocol::SP::Protocols::REP_V0
53
57
  end
@@ -57,4 +61,8 @@ module NNQ
57
61
  Routing::Rep.new(engine)
58
62
  end
59
63
  end
64
+
65
+
66
+ REQ = REQ0
67
+ REP = REP0
60
68
  end
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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