nnq 0.2.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 +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +13 -0
- data/README.md +57 -0
- data/lib/nnq/connection.rb +90 -0
- data/lib/nnq/engine/connection_lifecycle.rb +132 -0
- data/lib/nnq/engine/socket_lifecycle.rb +88 -0
- data/lib/nnq/engine.rb +198 -0
- data/lib/nnq/error.rb +10 -0
- data/lib/nnq/options.rb +31 -0
- data/lib/nnq/pair.rb +33 -0
- data/lib/nnq/pub_sub.rb +72 -0
- data/lib/nnq/push_pull.rb +52 -0
- data/lib/nnq/reactor.rb +83 -0
- data/lib/nnq/req_rep.rb +60 -0
- data/lib/nnq/routing/pair.rb +72 -0
- data/lib/nnq/routing/pub.rb +89 -0
- data/lib/nnq/routing/pull.rb +38 -0
- data/lib/nnq/routing/push.rb +43 -0
- data/lib/nnq/routing/rep.rb +113 -0
- data/lib/nnq/routing/req.rb +107 -0
- data/lib/nnq/routing/send_pump.rb +129 -0
- data/lib/nnq/routing/sub.rb +58 -0
- data/lib/nnq/socket.rb +86 -0
- data/lib/nnq/transport/inproc.rb +94 -0
- data/lib/nnq/transport/ipc.rb +97 -0
- data/lib/nnq/transport/tcp.rb +90 -0
- data/lib/nnq/version.rb +5 -0
- data/lib/nnq.rb +17 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1d4408e132c91a9ebf74a96a8c14f6004c5e05c4b9abdc959a798b98f7adbf32
|
|
4
|
+
data.tar.gz: ebc0be58942030dcd326664ddde9286d948e074c953ec06f9c238a59b6f3b02a
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dc12b86758f90e1c36aba100962fc89d8cabbc93d72e9e28d4f7973a90cb1b9fab1ab38908cd3988f1b264f3bff2de34818043e5026cdc5ad655312e374fd150
|
|
7
|
+
data.tar.gz: 7679994454867fa4fcc35d3c16e7c49ffe275ba9cdaf66fbcd428e3fef8b0e89df440918232b0968e38448033198be6f91616aac678ed65bd9c5158a9cda4702
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 — 2026-04-09
|
|
4
|
+
|
|
5
|
+
- `NNQ::PUB` / `NNQ::SUB` with local prefix filtering (pub0/sub0).
|
|
6
|
+
- `NNQ::PAIR` (pair0) — first-pipe-wins exclusive bidirectional channel.
|
|
7
|
+
- `NNQ::REQ` / `NNQ::REP` (req0/rep0) cooked-mode request/reply.
|
|
8
|
+
- `NNQ::Engine::ConnectionLifecycle` — per-connection state machine
|
|
9
|
+
(`new → handshaking → ready → closed`) consolidating registration,
|
|
10
|
+
teardown ordering, and idempotent loss handling.
|
|
11
|
+
- `NNQ::Engine::SocketLifecycle` — socket-level state machine
|
|
12
|
+
(`new → open → closing → closed`) owning the parent task capture and
|
|
13
|
+
close sequencing.
|
|
14
|
+
- `NNQ::ConnectionRejected` — raised by routing strategies (e.g. PAIR's
|
|
15
|
+
second peer) to reject a just-handshook connection without exposing
|
|
16
|
+
it to pumps.
|
|
17
|
+
|
|
18
|
+
## 0.1.0 — 2026-04-09
|
|
19
|
+
|
|
20
|
+
Initial Phase 1 slice (push0/pull0 over TCP). Requires Ruby >= 4.0.
|
|
21
|
+
|
|
22
|
+
- `NNQ::Send::Staging` — opportunistic-batching, HWM-free send path.
|
|
23
|
+
- `NNQ::Connection`, `NNQ::Engine`, `NNQ::Socket`.
|
|
24
|
+
- `NNQ::PUSH` / `NNQ::PULL` with `NNQ::Routing::Push` (round-robin send)
|
|
25
|
+
and `NNQ::Routing::Pull` (unbounded fair-queue receive).
|
|
26
|
+
- `NNQ::Transport::TCP`.
|
|
27
|
+
- `NNQ::Reactor` — per-process fallback IO thread for non-Async callers.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Copyright (c) 2026, Patrik Wenger
|
|
2
|
+
|
|
3
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
4
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
5
|
+
copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
8
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
9
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
10
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
11
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
12
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
13
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# NNQ — pure Ruby NNG on Async
|
|
2
|
+
|
|
3
|
+
[](https://github.com/paddor/nnq/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/nnq)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
|
|
8
|
+
NNQ is a pure-Ruby implementation of nanomsg's Scalability Protocols
|
|
9
|
+
(SP), wire-compatible with libnng. It is the nng-philosophy sibling of
|
|
10
|
+
[omq](https://github.com/paddor/omq) (pure-Ruby ZeroMQ).
|
|
11
|
+
|
|
12
|
+
Status: pre-alpha. v0.0.1 implements push0/pull0 over TCP only. See
|
|
13
|
+
[PLAN.md](PLAN.md) for the design and roadmap.
|
|
14
|
+
|
|
15
|
+
## Why a pure-Ruby NNG?
|
|
16
|
+
|
|
17
|
+
- **No native deps.** Same stack as omq: `async`, `io-stream`,
|
|
18
|
+
`protocol-sp`. No C extension, no FFI.
|
|
19
|
+
- **Faster than libnng** for the multi-fiber case (target: 10–25×).
|
|
20
|
+
libnng's per-message aio model leaves all the throughput of write
|
|
21
|
+
coalescing on the table.
|
|
22
|
+
- **Async-native.** Wrap in `Async{}`, no background thread for users
|
|
23
|
+
who already run a reactor.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
require "nnq"
|
|
29
|
+
require "async"
|
|
30
|
+
|
|
31
|
+
Async do
|
|
32
|
+
pull = NNQ::PULL.bind("tcp://127.0.0.1:5570")
|
|
33
|
+
push = NNQ::PUSH.connect("tcp://127.0.0.1:5570")
|
|
34
|
+
|
|
35
|
+
push.send("hello")
|
|
36
|
+
puts pull.receive # => "hello"
|
|
37
|
+
|
|
38
|
+
push.close
|
|
39
|
+
pull.close
|
|
40
|
+
end
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The "no HWM" philosophy
|
|
44
|
+
|
|
45
|
+
NNQ has no high-water mark. Backpressure comes only from the kernel
|
|
46
|
+
socket buffer. Concurrent senders are coalesced into one writev syscall
|
|
47
|
+
by `NNQ::Send::Staging`, which keeps a tiny in-memory aggregation point
|
|
48
|
+
that exists only during the moments when the drainer fiber is parked
|
|
49
|
+
on `wait_writable`. See `lib/nnq/send/staging.rb` for the full
|
|
50
|
+
discussion.
|
|
51
|
+
|
|
52
|
+
## Cancellation
|
|
53
|
+
|
|
54
|
+
`Async::Task` is the aio. To cancel an in-flight send, stop the task
|
|
55
|
+
that called `send`. The semantics match libnng's
|
|
56
|
+
`nng_aio_cancel` exactly: best-effort, may lose the race if the message
|
|
57
|
+
is already on the wire.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/sp"
|
|
4
|
+
|
|
5
|
+
module NNQ
|
|
6
|
+
# Per-pipe state: thin wrapper around Protocol::SP::Connection.
|
|
7
|
+
#
|
|
8
|
+
# Owns no fibers itself — recv loop and send pump are spawned by
|
|
9
|
+
# the Engine and routing strategy respectively.
|
|
10
|
+
#
|
|
11
|
+
class Connection
|
|
12
|
+
# @return [Protocol::SP::Connection]
|
|
13
|
+
attr_reader :sp
|
|
14
|
+
|
|
15
|
+
# @return [String, nil] endpoint URI we connected to / accepted from
|
|
16
|
+
attr_reader :endpoint
|
|
17
|
+
|
|
18
|
+
# @param sp [Protocol::SP::Connection] handshake-completed SP connection
|
|
19
|
+
# @param endpoint [String, nil]
|
|
20
|
+
def initialize(sp, endpoint: nil)
|
|
21
|
+
@sp = sp
|
|
22
|
+
@endpoint = endpoint
|
|
23
|
+
@closed = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# @return [Integer] peer protocol id (e.g. Protocols::PULL_V0)
|
|
28
|
+
def peer_protocol = @sp.peer_protocol
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Writes one message into the SP connection's send buffer (no flush).
|
|
32
|
+
#
|
|
33
|
+
# @param body [String]
|
|
34
|
+
# @return [void]
|
|
35
|
+
def write_message(body)
|
|
36
|
+
raise ClosedError, "connection closed" if @closed
|
|
37
|
+
@sp.write_message(body)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# Writes a batch of bodies under a single SP mutex acquisition.
|
|
42
|
+
# Used by the work-stealing send pump hot path.
|
|
43
|
+
#
|
|
44
|
+
# @param bodies [Array<String>]
|
|
45
|
+
# @return [void]
|
|
46
|
+
def write_messages(bodies)
|
|
47
|
+
raise ClosedError, "connection closed" if @closed
|
|
48
|
+
@sp.write_messages(bodies)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Writes one message AND flushes immediately. Used by REQ/REP where
|
|
53
|
+
# each call is request-paced and there's nothing to batch.
|
|
54
|
+
#
|
|
55
|
+
# @param body [String]
|
|
56
|
+
# @return [void]
|
|
57
|
+
def send_message(body)
|
|
58
|
+
raise ClosedError, "connection closed" if @closed
|
|
59
|
+
@sp.send_message(body)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Flushes the SP connection's send buffer to the socket.
|
|
64
|
+
#
|
|
65
|
+
# @return [void]
|
|
66
|
+
def flush
|
|
67
|
+
@sp.flush
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Reads one message body off the wire. Blocks the calling fiber.
|
|
72
|
+
#
|
|
73
|
+
# @return [String]
|
|
74
|
+
def receive_message
|
|
75
|
+
@sp.receive_message
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def closed? = @closed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Closes the underlying SP connection. Safe to call twice.
|
|
84
|
+
def close
|
|
85
|
+
return if @closed
|
|
86
|
+
@closed = true
|
|
87
|
+
@sp.close
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "protocol/sp"
|
|
4
|
+
require_relative "../connection"
|
|
5
|
+
|
|
6
|
+
module NNQ
|
|
7
|
+
class Engine
|
|
8
|
+
# Owns the full arc of one connection: handshake → ready → closed.
|
|
9
|
+
#
|
|
10
|
+
# Centralizes the ordering of side effects (routing registration,
|
|
11
|
+
# teardown) so the sequence lives in one place instead of being
|
|
12
|
+
# scattered across Engine, the accept/connect paths, and the
|
|
13
|
+
# recv/send pumps.
|
|
14
|
+
#
|
|
15
|
+
# State machine:
|
|
16
|
+
#
|
|
17
|
+
# new → handshaking → ready → closed
|
|
18
|
+
#
|
|
19
|
+
# #lost! and #close! are idempotent — the state guard ensures side
|
|
20
|
+
# effects run exactly once even if multiple pumps race to report a
|
|
21
|
+
# lost connection.
|
|
22
|
+
#
|
|
23
|
+
class ConnectionLifecycle
|
|
24
|
+
class InvalidTransition < RuntimeError; end
|
|
25
|
+
|
|
26
|
+
STATES = %i[new handshaking ready closed].freeze
|
|
27
|
+
|
|
28
|
+
TRANSITIONS = {
|
|
29
|
+
new: %i[handshaking ready closed].freeze,
|
|
30
|
+
handshaking: %i[ready closed].freeze,
|
|
31
|
+
ready: %i[closed].freeze,
|
|
32
|
+
closed: [].freeze,
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# @return [NNQ::Connection, nil]
|
|
37
|
+
attr_reader :conn
|
|
38
|
+
|
|
39
|
+
# @return [String, nil]
|
|
40
|
+
attr_reader :endpoint
|
|
41
|
+
|
|
42
|
+
# @return [Symbol]
|
|
43
|
+
attr_reader :state
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# @param engine [Engine]
|
|
47
|
+
# @param endpoint [String, nil]
|
|
48
|
+
# @param framing [Symbol] :tcp or :ipc
|
|
49
|
+
def initialize(engine, endpoint:, framing:)
|
|
50
|
+
@engine = engine
|
|
51
|
+
@endpoint = endpoint
|
|
52
|
+
@framing = framing
|
|
53
|
+
@state = :new
|
|
54
|
+
@conn = nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Performs the SP handshake, wraps the result in NNQ::Connection,
|
|
59
|
+
# registers with the engine and routing, and transitions to :ready.
|
|
60
|
+
#
|
|
61
|
+
# @param io [#read, #write, #close]
|
|
62
|
+
# @return [NNQ::Connection]
|
|
63
|
+
def handshake!(io)
|
|
64
|
+
transition!(:handshaking)
|
|
65
|
+
sp = Protocol::SP::Connection.new(
|
|
66
|
+
io,
|
|
67
|
+
protocol: @engine.protocol,
|
|
68
|
+
max_message_size: @engine.options.max_message_size,
|
|
69
|
+
framing: @framing,
|
|
70
|
+
)
|
|
71
|
+
sp.handshake!
|
|
72
|
+
ready!(NNQ::Connection.new(sp, endpoint: @endpoint))
|
|
73
|
+
@conn
|
|
74
|
+
rescue
|
|
75
|
+
io.close rescue nil
|
|
76
|
+
transition!(:closed) unless @state == :closed
|
|
77
|
+
raise
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Transitions to :closed, removing the connection from the engine
|
|
82
|
+
# and notifying the routing strategy. Idempotent.
|
|
83
|
+
def lost!
|
|
84
|
+
tear_down!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Alias for lost!. Kept as a separate method for parity with OMQ,
|
|
89
|
+
# where the distinction drives reconnect scheduling. nnq has no
|
|
90
|
+
# reconnect yet, so the two behave identically.
|
|
91
|
+
def close!
|
|
92
|
+
tear_down!
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def ready!(conn)
|
|
99
|
+
@conn = conn
|
|
100
|
+
@engine.connections[conn] = self
|
|
101
|
+
transition!(:ready)
|
|
102
|
+
begin
|
|
103
|
+
@engine.routing.connection_added(conn) if @engine.routing.respond_to?(:connection_added)
|
|
104
|
+
rescue ConnectionRejected
|
|
105
|
+
tear_down!
|
|
106
|
+
raise
|
|
107
|
+
end
|
|
108
|
+
@engine.new_pipe.signal
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def tear_down!
|
|
113
|
+
return if @state == :closed
|
|
114
|
+
transition!(:closed)
|
|
115
|
+
if @conn
|
|
116
|
+
@engine.connections.delete(@conn)
|
|
117
|
+
@engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
|
|
118
|
+
@conn.close rescue nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def transition!(new_state)
|
|
124
|
+
allowed = TRANSITIONS[@state]
|
|
125
|
+
unless allowed&.include?(new_state)
|
|
126
|
+
raise InvalidTransition, "#{@state} → #{new_state}"
|
|
127
|
+
end
|
|
128
|
+
@state = new_state
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NNQ
|
|
4
|
+
class Engine
|
|
5
|
+
# Owns the socket-level state: `:new → :open → :closing → :closed`
|
|
6
|
+
# and the captured parent task for the socket's task tree.
|
|
7
|
+
#
|
|
8
|
+
# Engine delegates state queries here and uses it to coordinate the
|
|
9
|
+
# ordering of close-time side effects. Mirrors OMQ's SocketLifecycle
|
|
10
|
+
# without the heartbeat/mechanism/monitor machinery nnq doesn't need.
|
|
11
|
+
#
|
|
12
|
+
class SocketLifecycle
|
|
13
|
+
class InvalidTransition < RuntimeError; end
|
|
14
|
+
|
|
15
|
+
STATES = %i[new open closing closed].freeze
|
|
16
|
+
|
|
17
|
+
TRANSITIONS = {
|
|
18
|
+
new: %i[open closed].freeze,
|
|
19
|
+
open: %i[closing closed].freeze,
|
|
20
|
+
closing: %i[closed].freeze,
|
|
21
|
+
closed: [].freeze,
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# @return [Symbol]
|
|
26
|
+
attr_reader :state
|
|
27
|
+
|
|
28
|
+
# @return [Async::Task, nil] root of the socket's task tree
|
|
29
|
+
attr_reader :parent_task
|
|
30
|
+
|
|
31
|
+
# @return [Boolean] true if parent_task is the shared Reactor thread
|
|
32
|
+
attr_reader :on_io_thread
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@state = :new
|
|
37
|
+
@parent_task = nil
|
|
38
|
+
@on_io_thread = false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def open? = @state == :open
|
|
43
|
+
def closing? = @state == :closing
|
|
44
|
+
def closed? = @state == :closed
|
|
45
|
+
def alive? = @state == :new || @state == :open
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Captures +task+ as this socket's task tree root. Transitions
|
|
49
|
+
# `:new → :open`. Idempotent: second call is a no-op.
|
|
50
|
+
#
|
|
51
|
+
# @param task [Async::Task]
|
|
52
|
+
# @param on_io_thread [Boolean] true when +task+ is the shared
|
|
53
|
+
# NNQ::Reactor root task (vs. the caller's own Async task)
|
|
54
|
+
# @return [Boolean] true on first-time capture, false if already captured
|
|
55
|
+
def capture_parent_task(task, on_io_thread:)
|
|
56
|
+
return false if @parent_task
|
|
57
|
+
@parent_task = task
|
|
58
|
+
@on_io_thread = on_io_thread
|
|
59
|
+
transition!(:open)
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Transitions `:open → :closing`.
|
|
65
|
+
def start_closing!
|
|
66
|
+
transition!(:closing)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Transitions `:closing → :closed` (or `:new → :closed` for
|
|
71
|
+
# never-opened sockets).
|
|
72
|
+
def finish_closing!
|
|
73
|
+
transition!(:closed)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def transition!(new_state)
|
|
80
|
+
allowed = TRANSITIONS[@state]
|
|
81
|
+
unless allowed&.include?(new_state)
|
|
82
|
+
raise InvalidTransition, "#{@state} → #{new_state}"
|
|
83
|
+
end
|
|
84
|
+
@state = new_state
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
data/lib/nnq/engine.rb
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async"
|
|
4
|
+
require "async/clock"
|
|
5
|
+
require "protocol/sp"
|
|
6
|
+
require_relative "error"
|
|
7
|
+
require_relative "connection"
|
|
8
|
+
require_relative "reactor"
|
|
9
|
+
require_relative "engine/socket_lifecycle"
|
|
10
|
+
require_relative "engine/connection_lifecycle"
|
|
11
|
+
require_relative "transport/tcp"
|
|
12
|
+
require_relative "transport/ipc"
|
|
13
|
+
require_relative "transport/inproc"
|
|
14
|
+
|
|
15
|
+
module NNQ
|
|
16
|
+
# Per-socket orchestrator. Owns the listener set, the connection map
|
|
17
|
+
# (keyed on NNQ::Connection, with per-connection ConnectionLifecycle
|
|
18
|
+
# values), the transport registry, and the socket-level state machine
|
|
19
|
+
# via {SocketLifecycle}.
|
|
20
|
+
#
|
|
21
|
+
# Mirrors OMQ's Engine in shape but is much smaller because there's
|
|
22
|
+
# no HWM bookkeeping, no mechanisms, no heartbeat, no monitor queue.
|
|
23
|
+
#
|
|
24
|
+
class Engine
|
|
25
|
+
TRANSPORTS = {
|
|
26
|
+
"tcp" => Transport::TCP,
|
|
27
|
+
"ipc" => Transport::IPC,
|
|
28
|
+
"inproc" => Transport::Inproc,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# @return [Integer] our SP protocol id (e.g. Protocols::PUSH_V0)
|
|
33
|
+
attr_reader :protocol
|
|
34
|
+
|
|
35
|
+
# @return [Options]
|
|
36
|
+
attr_reader :options
|
|
37
|
+
|
|
38
|
+
# @return [Hash{NNQ::Connection => ConnectionLifecycle}]
|
|
39
|
+
attr_reader :connections
|
|
40
|
+
|
|
41
|
+
# @return [SocketLifecycle]
|
|
42
|
+
attr_reader :lifecycle
|
|
43
|
+
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
attr_reader :last_endpoint
|
|
46
|
+
|
|
47
|
+
# @return [Async::Condition] signaled when a new pipe is registered
|
|
48
|
+
attr_reader :new_pipe
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# @param protocol [Integer] our SP protocol id (e.g. Protocols::PUSH_V0)
|
|
52
|
+
# @param options [Options]
|
|
53
|
+
# @yieldparam engine [Engine] used by the caller to build a routing
|
|
54
|
+
# strategy with access to the engine's connection map
|
|
55
|
+
def initialize(protocol:, options:)
|
|
56
|
+
@protocol = protocol
|
|
57
|
+
@options = options
|
|
58
|
+
@connections = {}
|
|
59
|
+
@listeners = []
|
|
60
|
+
@lifecycle = SocketLifecycle.new
|
|
61
|
+
@last_endpoint = nil
|
|
62
|
+
@new_pipe = Async::Condition.new
|
|
63
|
+
@routing = yield(self)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# @return [Routing strategy]
|
|
68
|
+
attr_reader :routing
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# @return [Async::Task, nil]
|
|
72
|
+
def parent_task = @lifecycle.parent_task
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def closed? = @lifecycle.closed?
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Stores the parent Async task that long-lived NNQ fibers will
|
|
79
|
+
# attach to. The caller (Socket) is responsible for picking the
|
|
80
|
+
# right one (the user's current task, or Reactor.root_task).
|
|
81
|
+
def capture_parent_task(task)
|
|
82
|
+
on_io_thread = task.equal?(Reactor.root_task)
|
|
83
|
+
@lifecycle.capture_parent_task(task, on_io_thread: on_io_thread)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Binds to +endpoint+. Synchronous: errors propagate.
|
|
88
|
+
def bind(endpoint)
|
|
89
|
+
transport = transport_for(endpoint)
|
|
90
|
+
listener = transport.bind(endpoint, self)
|
|
91
|
+
listener.start_accept_loop(@lifecycle.parent_task) do |io, framing = :tcp|
|
|
92
|
+
handle_accepted(io, endpoint: endpoint, framing: framing)
|
|
93
|
+
end
|
|
94
|
+
@listeners << listener
|
|
95
|
+
@last_endpoint = listener.endpoint
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Connects to +endpoint+. Synchronous on first attempt; reconnect
|
|
100
|
+
# is wired in Phase 1.1.
|
|
101
|
+
def connect(endpoint)
|
|
102
|
+
transport = transport_for(endpoint)
|
|
103
|
+
transport.connect(endpoint, self)
|
|
104
|
+
@last_endpoint = endpoint
|
|
105
|
+
rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
|
|
106
|
+
raise Error, "could not connect to #{endpoint}: #{e.class}: #{e.message}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# Called by transports for each accepted client connection.
|
|
111
|
+
def handle_accepted(io, endpoint:, framing: :tcp)
|
|
112
|
+
lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, framing: framing)
|
|
113
|
+
lifecycle.handshake!(io)
|
|
114
|
+
spawn_recv_loop(lifecycle.conn) if @routing.respond_to?(:enqueue) && @connections.key?(lifecycle.conn)
|
|
115
|
+
rescue ConnectionRejected
|
|
116
|
+
# routing rejected this peer (e.g. PAIR already bonded) — lifecycle cleaned up
|
|
117
|
+
rescue => e
|
|
118
|
+
warn("nnq: handshake failed for #{endpoint}: #{e.class}: #{e.message}") if $DEBUG
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Called by transports for each dialed connection.
|
|
123
|
+
def handle_connected(io, endpoint:, framing: :tcp)
|
|
124
|
+
lifecycle = ConnectionLifecycle.new(self, endpoint: endpoint, framing: framing)
|
|
125
|
+
lifecycle.handshake!(io)
|
|
126
|
+
spawn_recv_loop(lifecycle.conn) if @routing.respond_to?(:enqueue) && @connections.key?(lifecycle.conn)
|
|
127
|
+
rescue ConnectionRejected
|
|
128
|
+
# unusual on connect side, but handled identically
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Spawns a task under the socket's parent task. Used by routing
|
|
133
|
+
# strategies (e.g. PUSH send pump) to attach long-lived fibers to
|
|
134
|
+
# the engine's lifecycle without going through transient: true.
|
|
135
|
+
def spawn_task(annotation:, &block)
|
|
136
|
+
@lifecycle.parent_task.async(annotation: annotation, &block)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# Closes the engine: stops listeners, drains the send queue subject
|
|
141
|
+
# to linger, stops routing pumps (which by now are parked on the
|
|
142
|
+
# empty queue), then tears down every connection's lifecycle. Order
|
|
143
|
+
# matters — closing connections first would force mid-flush pumps
|
|
144
|
+
# to abort with IOError.
|
|
145
|
+
def close
|
|
146
|
+
return unless @lifecycle.alive?
|
|
147
|
+
@lifecycle.start_closing!
|
|
148
|
+
@listeners.each(&:stop)
|
|
149
|
+
drain_send_queue(@options.linger)
|
|
150
|
+
@routing.close if @routing.respond_to?(:close)
|
|
151
|
+
# Tear down each remaining connection via its lifecycle. The
|
|
152
|
+
# collection mutates during iteration, so snapshot the values.
|
|
153
|
+
@connections.values.each(&:close!)
|
|
154
|
+
@lifecycle.finish_closing!
|
|
155
|
+
@new_pipe.signal
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Called by routing pumps (or the recv loop) when their connection
|
|
160
|
+
# has died. Idempotent via the lifecycle state guard.
|
|
161
|
+
def handle_connection_lost(conn)
|
|
162
|
+
@connections[conn]&.lost!
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def drain_send_queue(timeout)
|
|
169
|
+
return unless @routing.respond_to?(:send_queue_drained?)
|
|
170
|
+
return if @connections.empty?
|
|
171
|
+
deadline = timeout ? Async::Clock.now + timeout : nil
|
|
172
|
+
until @routing.send_queue_drained?
|
|
173
|
+
break if deadline && (deadline - Async::Clock.now) <= 0
|
|
174
|
+
sleep 0.001
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def spawn_recv_loop(conn)
|
|
180
|
+
@lifecycle.parent_task.async(annotation: "nnq recv #{conn.endpoint}") do
|
|
181
|
+
loop do
|
|
182
|
+
body = conn.receive_message
|
|
183
|
+
@routing.enqueue(body, conn)
|
|
184
|
+
rescue EOFError, IOError, Errno::ECONNRESET, Async::Stop
|
|
185
|
+
break
|
|
186
|
+
end
|
|
187
|
+
ensure
|
|
188
|
+
handle_connection_lost(conn)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def transport_for(endpoint)
|
|
194
|
+
scheme = endpoint[/\A([a-z+]+):\/\//i, 1] or raise Error, "no scheme: #{endpoint}"
|
|
195
|
+
TRANSPORTS[scheme] or raise Error, "unsupported transport: #{scheme}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
data/lib/nnq/error.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NNQ
|
|
4
|
+
class Error < RuntimeError; end
|
|
5
|
+
class ClosedError < Error; end
|
|
6
|
+
class ProtocolError < Error; end
|
|
7
|
+
class TimeoutError < Error; end
|
|
8
|
+
class RequestCancelled < Error; end
|
|
9
|
+
class ConnectionRejected < Error; end
|
|
10
|
+
end
|
data/lib/nnq/options.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NNQ
|
|
4
|
+
# Per-socket configuration. Deliberately tiny — no conflate, no heartbeat
|
|
5
|
+
# command (nng uses TCP keepalive instead). New options should match nng
|
|
6
|
+
# socket option names where they exist (`recv_max_size`, `reconnect_time`,
|
|
7
|
+
# `send_buf`, etc.).
|
|
8
|
+
#
|
|
9
|
+
# `send_hwm` bounds *one shared queue per socket*, not per peer. See
|
|
10
|
+
# DESIGN.md "Per-socket HWM".
|
|
11
|
+
#
|
|
12
|
+
class Options
|
|
13
|
+
DEFAULT_HWM = 1000
|
|
14
|
+
|
|
15
|
+
attr_accessor :linger
|
|
16
|
+
attr_accessor :read_timeout
|
|
17
|
+
attr_accessor :write_timeout
|
|
18
|
+
attr_accessor :reconnect_interval
|
|
19
|
+
attr_accessor :max_message_size
|
|
20
|
+
attr_accessor :send_hwm
|
|
21
|
+
|
|
22
|
+
def initialize(linger: nil, send_hwm: DEFAULT_HWM)
|
|
23
|
+
@linger = linger
|
|
24
|
+
@read_timeout = nil
|
|
25
|
+
@write_timeout = nil
|
|
26
|
+
@reconnect_interval = 0.1
|
|
27
|
+
@max_message_size = nil
|
|
28
|
+
@send_hwm = send_hwm
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/nnq/pair.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "socket"
|
|
4
|
+
require_relative "routing/pair"
|
|
5
|
+
|
|
6
|
+
module NNQ
|
|
7
|
+
# PAIR (nng pair0): exclusive bidirectional channel with a single
|
|
8
|
+
# peer. First peer to connect wins; subsequent peers are dropped
|
|
9
|
+
# until the current one disconnects. No SP header on the wire.
|
|
10
|
+
#
|
|
11
|
+
class PAIR < Socket
|
|
12
|
+
def send(body)
|
|
13
|
+
Reactor.run { @engine.routing.send(body) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def receive
|
|
18
|
+
Reactor.run { @engine.routing.receive }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def protocol
|
|
25
|
+
Protocol::SP::Protocols::PAIR_V0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def build_routing(engine)
|
|
30
|
+
Routing::Pair.new(engine)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|