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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +118 -0
- data/lib/nnq/bus.rb +37 -0
- data/lib/nnq/connection.rb +9 -2
- data/lib/nnq/engine/connection_lifecycle.rb +72 -12
- data/lib/nnq/engine/reconnect.rb +112 -0
- data/lib/nnq/engine/socket_lifecycle.rb +40 -3
- data/lib/nnq/engine.rb +186 -35
- data/lib/nnq/error.rb +26 -6
- data/lib/nnq/monitor_event.rb +18 -0
- data/lib/nnq/options.rb +8 -1
- data/lib/nnq/pair.rb +6 -1
- data/lib/nnq/pub_sub.rb +9 -2
- data/lib/nnq/push_pull.rb +16 -3
- data/lib/nnq/reactor.rb +12 -11
- data/lib/nnq/req_rep.rb +10 -2
- data/lib/nnq/routing/backtrace.rb +39 -0
- data/lib/nnq/routing/bus.rb +108 -0
- data/lib/nnq/routing/pair.rb +10 -1
- data/lib/nnq/routing/pub.rb +9 -4
- data/lib/nnq/routing/pull.rb +10 -1
- data/lib/nnq/routing/push.rb +2 -0
- data/lib/nnq/routing/rep.rb +10 -20
- data/lib/nnq/routing/req.rb +6 -2
- data/lib/nnq/routing/respondent.rb +84 -0
- data/lib/nnq/routing/send_pump.rb +27 -5
- data/lib/nnq/routing/sub.rb +9 -0
- data/lib/nnq/routing/surveyor.rb +138 -0
- data/lib/nnq/socket.rb +102 -5
- data/lib/nnq/surveyor_respondent.rb +78 -0
- data/lib/nnq/transport/inproc.rb +5 -0
- data/lib/nnq/transport/ipc.rb +3 -0
- data/lib/nnq/transport/tcp.rb +27 -5
- data/lib/nnq/version.rb +1 -1
- data/lib/nnq.rb +10 -0
- metadata +11 -3
|
@@ -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
|
data/lib/nnq/routing/pair.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/nnq/routing/pub.rb
CHANGED
|
@@ -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?
|
|
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
|
-
|
|
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
|
data/lib/nnq/routing/pull.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/nnq/routing/push.rb
CHANGED
data/lib/nnq/routing/rep.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/nnq/routing/req.rb
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/nnq/routing/sub.rb
CHANGED
|
@@ -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
|