nnq 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d4408e132c91a9ebf74a96a8c14f6004c5e05c4b9abdc959a798b98f7adbf32
4
- data.tar.gz: ebc0be58942030dcd326664ddde9286d948e074c953ec06f9c238a59b6f3b02a
3
+ metadata.gz: ed71a88238bae0611223d36bb3ec0a39795388d7b90227fb690cfc924cf85198
4
+ data.tar.gz: cda3bfff65005960b91672cd1c6e2a61e8fce104e202ed928932e7b0404d7eda
5
5
  SHA512:
6
- metadata.gz: dc12b86758f90e1c36aba100962fc89d8cabbc93d72e9e28d4f7973a90cb1b9fab1ab38908cd3988f1b264f3bff2de34818043e5026cdc5ad655312e374fd150
7
- data.tar.gz: 7679994454867fa4fcc35d3c16e7c49ffe275ba9cdaf66fbcd428e3fef8b0e89df440918232b0968e38448033198be6f91616aac678ed65bd9c5158a9cda4702
6
+ metadata.gz: 87ddb00a39836f3699dbd5f17a18b34dbd1c0c18d284ad1cf0b14d6271e565444ffce926ce2514c912423f943ef9a994ebb79eb3620b238f872f840124e3ad38
7
+ data.tar.gz: 193a8a3a1830f18aba6399990a7f1ea81639165c6ffc072691874ca972e21732f4edaf6fff3a82c3bf40b1d3a117ff04adc381e1a3f65d2f2ca677b091ae9d9d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,46 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-04-09
4
+
5
+ - `Socket#all_peers_gone` — `Async::Promise` resolving the first time
6
+ the connection set becomes empty after at least one peer connected.
7
+ Edge-triggered, ported from OMQ.
8
+ - `Socket#close_read` — closes the recv side only. Buffered messages
9
+ drain, then `#receive` returns `nil`. Send side stays operational.
10
+ - `Socket#reconnect_enabled` / `#reconnect_enabled=` — flipped by
11
+ transient-mode consumers before draining to prevent the background
12
+ reconnect loop from revivifying a dying socket.
13
+ - `Socket#monitor` / `NNQ::MonitorEvent` — lifecycle event stream
14
+ emitting `:listening`, `:connect_delayed`, `:connect_retried`,
15
+ `:connected`, `:handshake_succeeded`/`_failed`, `:disconnected`,
16
+ `:closed`, and (when `verbose: true`) `:message_sent` /
17
+ `:message_received`. Ported from OMQ, minus the heartbeat/mechanism
18
+ events nnq doesn't have.
19
+ - Background reconnect — `NNQ::Engine::Reconnect` runs a `transient: true`
20
+ task per dialed endpoint, retrying with exponential back-off bounded
21
+ by `options.reconnect_interval` (Numeric or Range). `connect` becomes
22
+ non-blocking for `tcp://` and `ipc://`; `inproc://` stays synchronous.
23
+ `CONNECTION_FAILED` / `CONNECTION_LOST` mutable-at-load-time registries
24
+ let plugins append transport-specific error classes.
25
+ - `NNQ::PULL#receive` honors `options.read_timeout` via
26
+ `Fiber.scheduler.with_timeout`. Previously the option was declared
27
+ but inert.
28
+ - `NNQ.freeze_for_ractors!` — freezes `Engine::CONNECTION_FAILED`,
29
+ `Engine::CONNECTION_LOST`, and `Engine::TRANSPORTS` so NNQ sockets
30
+ can be used from non-main Ractors. Required for nnq-cli's `pipe -P N`
31
+ parallel worker mode.
32
+
33
+ ## 0.3.0 — 2026-04-09
34
+
35
+ - `Socket#peer_connected` — `Async::Promise` that resolves with the
36
+ first connected peer (or `nil` on close without any peers). Ported
37
+ from OMQ. Held on `SocketLifecycle`, resolved by `ConnectionLifecycle`
38
+ on first `ready!`, and edge-triggered so callers don't need to poll.
39
+ - `bench/` — main throughput suite ported from OMQ. Four patterns
40
+ (push/pull, req/rep, pair, pub/sub) across inproc, ipc, and tcp.
41
+ Calibration-driven burst sizing, fastest-of-3 reporting, regression
42
+ report with `--update-readme` to regenerate README tables.
43
+
3
44
  ## 0.2.0 — 2026-04-09
4
45
 
5
46
  - `NNQ::PUB` / `NNQ::SUB` with local prefix filtering (pub0/sub0).
@@ -71,23 +71,26 @@ module NNQ
71
71
  sp.handshake!
72
72
  ready!(NNQ::Connection.new(sp, endpoint: @endpoint))
73
73
  @conn
74
- rescue
74
+ rescue => e
75
+ @engine.emit_monitor_event(:handshake_failed, endpoint: @endpoint, detail: { error: e })
75
76
  io.close rescue nil
76
77
  transition!(:closed) unless @state == :closed
77
78
  raise
78
79
  end
79
80
 
80
81
 
81
- # Transitions to :closed, removing the connection from the engine
82
- # and notifying the routing strategy. Idempotent.
82
+ # Unexpected loss of an established connection. Tears down and
83
+ # asks the engine to schedule a reconnect (if the endpoint is in
84
+ # the dialed set and reconnect is still enabled).
83
85
  def lost!
86
+ ep = @endpoint
84
87
  tear_down!
88
+ @engine.maybe_reconnect(ep)
85
89
  end
86
90
 
87
91
 
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.
92
+ # Deliberate close (engine shutdown or routing eviction). Does
93
+ # not trigger reconnect.
91
94
  def close!
92
95
  tear_down!
93
96
  end
@@ -102,9 +105,13 @@ module NNQ
102
105
  begin
103
106
  @engine.routing.connection_added(conn) if @engine.routing.respond_to?(:connection_added)
104
107
  rescue ConnectionRejected
108
+ @engine.emit_monitor_event(:connection_rejected, endpoint: @endpoint)
105
109
  tear_down!
106
110
  raise
107
111
  end
112
+ @engine.lifecycle.peer_connected.resolve(conn) unless @engine.lifecycle.peer_connected.resolved?
113
+ @engine.emit_monitor_event(:handshake_succeeded, endpoint: @endpoint)
114
+ @engine.emit_monitor_event(:connected, endpoint: @endpoint)
108
115
  @engine.new_pipe.signal
109
116
  end
110
117
 
@@ -116,6 +123,8 @@ module NNQ
116
123
  @engine.connections.delete(@conn)
117
124
  @engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
118
125
  @conn.close rescue nil
126
+ @engine.emit_monitor_event(:disconnected, endpoint: @endpoint)
127
+ @engine.resolve_all_peers_gone_if_empty
119
128
  end
120
129
  end
121
130
 
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ class Engine
5
+ # Connection errors that should trigger a reconnect retry rather
6
+ # than propagate. Mutable at load time so plugins (e.g. a future
7
+ # TLS transport) can append their own error classes; frozen on
8
+ # first {Engine#connect}.
9
+ CONNECTION_FAILED = [
10
+ Errno::ECONNREFUSED,
11
+ Errno::EHOSTUNREACH,
12
+ Errno::ENETUNREACH,
13
+ Errno::ENOENT,
14
+ Errno::EPIPE,
15
+ Errno::ETIMEDOUT,
16
+ Socket::ResolutionError,
17
+ ]
18
+
19
+ # Errors that indicate an established connection went away. Used
20
+ # by the recv loop and pumps to silently terminate (the connection
21
+ # lifecycle's #lost! handler decides whether to reconnect).
22
+ CONNECTION_LOST = [
23
+ EOFError,
24
+ IOError,
25
+ Errno::ECONNRESET,
26
+ Errno::EPIPE,
27
+ ]
28
+
29
+
30
+ # Schedules reconnect attempts with exponential back-off.
31
+ #
32
+ # Runs a background task that loops until a connection is
33
+ # established or the engine is closed. Caller is non-blocking:
34
+ # {Engine#connect} returns immediately and the actual dial happens
35
+ # inside the task.
36
+ #
37
+ class Reconnect
38
+ # @param endpoint [String]
39
+ # @param options [Options]
40
+ # @param parent_task [Async::Task]
41
+ # @param engine [Engine]
42
+ # @param delay [Numeric, nil] initial delay (defaults to reconnect_interval)
43
+ def self.schedule(endpoint, options, parent_task, engine, delay: nil)
44
+ new(engine, endpoint, options).run(parent_task, delay: delay)
45
+ end
46
+
47
+
48
+ def initialize(engine, endpoint, options)
49
+ @engine = engine
50
+ @endpoint = endpoint
51
+ @options = options
52
+ end
53
+
54
+
55
+ def run(parent_task, delay: nil)
56
+ delay, max_delay = init_delay(delay)
57
+
58
+ task = parent_task.async(transient: true, annotation: "nnq reconnect #{@endpoint}") do
59
+ loop do
60
+ break if @engine.closed?
61
+ sleep delay if delay > 0
62
+ break if @engine.closed?
63
+ begin
64
+ @engine.transport_for(@endpoint).connect(@endpoint, @engine)
65
+ break
66
+ rescue *CONNECTION_FAILED, *CONNECTION_LOST => e
67
+ delay = next_delay(delay, max_delay)
68
+ @engine.emit_monitor_event(:connect_retried, endpoint: @endpoint, detail: { interval: delay, error: e })
69
+ end
70
+ end
71
+ rescue Async::Stop
72
+ end
73
+ @engine.tasks << task
74
+ end
75
+
76
+
77
+ private
78
+
79
+
80
+ def init_delay(delay)
81
+ ri = @options.reconnect_interval
82
+ if ri.is_a?(Range)
83
+ [delay || ri.begin, ri.end]
84
+ else
85
+ [delay || ri, nil]
86
+ end
87
+ end
88
+
89
+
90
+ def next_delay(delay, max_delay)
91
+ ri = @options.reconnect_interval
92
+ if ri.is_a?(Range)
93
+ delay = delay * 2
94
+ delay = [delay, max_delay].min if max_delay
95
+ delay = ri.begin if delay == 0
96
+ delay
97
+ else
98
+ ri
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "async/promise"
4
+
3
5
  module NNQ
4
6
  class Engine
5
7
  # Owns the socket-level state: `:new → :open → :closing → :closed`
@@ -31,11 +33,28 @@ module NNQ
31
33
  # @return [Boolean] true if parent_task is the shared Reactor thread
32
34
  attr_reader :on_io_thread
33
35
 
36
+ # @return [Async::Promise] resolves with the first connected peer
37
+ # (or nil if the socket closes before anyone connects)
38
+ attr_reader :peer_connected
39
+
40
+ # @return [Async::Promise] resolves with true the first time the
41
+ # connection set becomes empty after at least one peer connected.
42
+ # Edge-triggered: does not re-arm on reconnect.
43
+ attr_reader :all_peers_gone
44
+
45
+ # @return [Boolean] when false, the engine must not schedule new
46
+ # reconnect attempts. Default true. nnq has no automatic
47
+ # reconnect loop yet, so this currently just records intent.
48
+ attr_accessor :reconnect_enabled
49
+
34
50
 
35
51
  def initialize
36
- @state = :new
37
- @parent_task = nil
38
- @on_io_thread = false
52
+ @state = :new
53
+ @parent_task = nil
54
+ @on_io_thread = false
55
+ @peer_connected = Async::Promise.new
56
+ @all_peers_gone = Async::Promise.new
57
+ @reconnect_enabled = true
39
58
  end
40
59
 
41
60
 
@@ -74,6 +93,16 @@ module NNQ
74
93
  end
75
94
 
76
95
 
96
+ # Resolves `all_peers_gone` if we had peers and now have none.
97
+ # Idempotent.
98
+ # @param connections [Hash] current connection map
99
+ def resolve_all_peers_gone_if_empty(connections)
100
+ return unless @peer_connected.resolved? && connections.empty?
101
+ return if @all_peers_gone.resolved?
102
+ @all_peers_gone.resolve(true)
103
+ end
104
+
105
+
77
106
  private
78
107
 
79
108
  def transition!(new_state)
data/lib/nnq/engine.rb CHANGED
@@ -2,12 +2,15 @@
2
2
 
3
3
  require "async"
4
4
  require "async/clock"
5
+ require "set"
5
6
  require "protocol/sp"
6
7
  require_relative "error"
7
8
  require_relative "connection"
9
+ require_relative "monitor_event"
8
10
  require_relative "reactor"
9
11
  require_relative "engine/socket_lifecycle"
10
12
  require_relative "engine/connection_lifecycle"
13
+ require_relative "engine/reconnect"
11
14
  require_relative "transport/tcp"
12
15
  require_relative "transport/ipc"
13
16
  require_relative "transport/inproc"
@@ -47,20 +50,54 @@ module NNQ
47
50
  # @return [Async::Condition] signaled when a new pipe is registered
48
51
  attr_reader :new_pipe
49
52
 
53
+ # @return [Set<String>] endpoints we have called #connect on; used
54
+ # to decide whether to schedule a reconnect after a connection
55
+ # is lost.
56
+ attr_reader :dialed
57
+
58
+ # @return [Array<Async::Task>] transient tasks owned by the engine
59
+ # (currently just background reconnect loops). Stopped at #close.
60
+ attr_reader :tasks
61
+
62
+
63
+ # @return [Async::Queue, nil] monitor event queue (set by Socket#monitor)
64
+ attr_accessor :monitor_queue
65
+ attr_accessor :verbose_monitor
66
+
50
67
 
51
68
  # @param protocol [Integer] our SP protocol id (e.g. Protocols::PUSH_V0)
52
69
  # @param options [Options]
53
70
  # @yieldparam engine [Engine] used by the caller to build a routing
54
71
  # strategy with access to the engine's connection map
55
72
  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)
73
+ @protocol = protocol
74
+ @options = options
75
+ @connections = {}
76
+ @listeners = []
77
+ @lifecycle = SocketLifecycle.new
78
+ @last_endpoint = nil
79
+ @new_pipe = Async::Condition.new
80
+ @monitor_queue = nil
81
+ @verbose_monitor = false
82
+ @dialed = Set.new
83
+ @tasks = []
84
+ @routing = yield(self)
85
+ end
86
+
87
+
88
+ # Emits a monitor event to the attached queue (if any).
89
+ def emit_monitor_event(type, endpoint: nil, detail: nil)
90
+ return unless @monitor_queue
91
+ @monitor_queue.enqueue(MonitorEvent.new(type: type, endpoint: endpoint, detail: detail))
92
+ rescue Async::Stop
93
+ end
94
+
95
+
96
+ # Emits a verbose-only monitor event. Only forwarded when
97
+ # {Socket#monitor} was called with +verbose: true+.
98
+ def emit_verbose_monitor_event(type, **detail)
99
+ return unless @verbose_monitor
100
+ emit_monitor_event(type, detail: detail)
64
101
  end
65
102
 
66
103
 
@@ -75,11 +112,45 @@ module NNQ
75
112
  def closed? = @lifecycle.closed?
76
113
 
77
114
 
115
+ # @return [Async::Promise] resolves with the first connected peer
116
+ def peer_connected = @lifecycle.peer_connected
117
+
118
+
119
+ # @return [Async::Promise] resolves when all peers have disconnected
120
+ # (edge-triggered, after at least one peer connected)
121
+ def all_peers_gone = @lifecycle.all_peers_gone
122
+
123
+
124
+ # Called by ConnectionLifecycle teardown. Resolves `all_peers_gone`
125
+ # if the connection set is now empty and we had peers.
126
+ def resolve_all_peers_gone_if_empty
127
+ @lifecycle.resolve_all_peers_gone_if_empty(@connections)
128
+ end
129
+
130
+
131
+ # @return [Boolean]
132
+ def reconnect_enabled = @lifecycle.reconnect_enabled
133
+
134
+
135
+ # Disables or re-enables automatic reconnect. nnq has no reconnect
136
+ # loop yet, so this is forward-looking — {TransientMonitor} flips
137
+ # it before draining.
138
+ def reconnect_enabled=(value)
139
+ @lifecycle.reconnect_enabled = value
140
+ end
141
+
142
+
143
+ # Closes only the recv side. Buffered messages drain, then
144
+ # {Socket#receive} returns nil. Send side remains operational.
145
+ def close_read
146
+ @routing.close_read if @routing.respond_to?(:close_read)
147
+ end
148
+
149
+
78
150
  # Stores the parent Async task that long-lived NNQ fibers will
79
151
  # attach to. The caller (Socket) is responsible for picking the
80
152
  # 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)
153
+ def capture_parent_task(task, on_io_thread:)
83
154
  @lifecycle.capture_parent_task(task, on_io_thread: on_io_thread)
84
155
  end
85
156
 
@@ -93,17 +164,42 @@ module NNQ
93
164
  end
94
165
  @listeners << listener
95
166
  @last_endpoint = listener.endpoint
167
+ emit_monitor_event(:listening, endpoint: @last_endpoint)
96
168
  end
97
169
 
98
170
 
99
- # Connects to +endpoint+. Synchronous on first attempt; reconnect
100
- # is wired in Phase 1.1.
171
+ # Connects to +endpoint+. Non-blocking for tcp:// and ipc:// — the
172
+ # actual dial happens inside a background reconnect task that
173
+ # retries with exponential back-off until the peer becomes
174
+ # reachable. Inproc connect is synchronous and instant.
101
175
  def connect(endpoint)
102
- transport = transport_for(endpoint)
103
- transport.connect(endpoint, self)
176
+ @dialed << endpoint
104
177
  @last_endpoint = endpoint
105
- rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH => e
106
- raise Error, "could not connect to #{endpoint}: #{e.class}: #{e.message}"
178
+ if endpoint.start_with?("inproc://")
179
+ transport_for(endpoint).connect(endpoint, self)
180
+ else
181
+ emit_monitor_event(:connect_delayed, endpoint: endpoint)
182
+ Reconnect.schedule(endpoint, @options, @lifecycle.parent_task, self, delay: 0)
183
+ end
184
+ end
185
+
186
+
187
+ # Schedules a reconnect for +endpoint+ if auto-reconnect is enabled
188
+ # and the endpoint is still in the dialed set. Called from the
189
+ # connection lifecycle's `lost!` path.
190
+ def maybe_reconnect(endpoint)
191
+ return unless endpoint && @dialed.include?(endpoint)
192
+ return unless @lifecycle.alive? && @lifecycle.reconnect_enabled
193
+ return if endpoint.start_with?("inproc://")
194
+ Reconnect.schedule(endpoint, @options, @lifecycle.parent_task, self)
195
+ end
196
+
197
+
198
+ # Public so {Reconnect} can dial directly without re-deriving the
199
+ # transport from the URL each iteration.
200
+ def transport_for(endpoint)
201
+ scheme = endpoint[/\A([a-z+]+):\/\//i, 1] or raise Error, "no scheme: #{endpoint}"
202
+ TRANSPORTS[scheme] or raise Error, "unsupported transport: #{scheme}"
107
203
  end
108
204
 
109
205
 
@@ -146,6 +242,8 @@ module NNQ
146
242
  return unless @lifecycle.alive?
147
243
  @lifecycle.start_closing!
148
244
  @listeners.each(&:stop)
245
+ @tasks.each { |t| t.stop rescue nil }
246
+ @tasks.clear
149
247
  drain_send_queue(@options.linger)
150
248
  @routing.close if @routing.respond_to?(:close)
151
249
  # Tear down each remaining connection via its lifecycle. The
@@ -153,6 +251,11 @@ module NNQ
153
251
  @connections.values.each(&:close!)
154
252
  @lifecycle.finish_closing!
155
253
  @new_pipe.signal
254
+ # Unblock anyone waiting on peer_connected when the socket is
255
+ # closed before a peer ever arrived.
256
+ @lifecycle.peer_connected.resolve(nil) unless @lifecycle.peer_connected.resolved?
257
+ emit_monitor_event(:closed)
258
+ close_monitor_queue
156
259
  end
157
260
 
158
261
 
@@ -165,6 +268,12 @@ module NNQ
165
268
 
166
269
  private
167
270
 
271
+ def close_monitor_queue
272
+ return unless @monitor_queue
273
+ @monitor_queue.enqueue(nil)
274
+ end
275
+
276
+
168
277
  def drain_send_queue(timeout)
169
278
  return unless @routing.respond_to?(:send_queue_drained?)
170
279
  return if @connections.empty?
@@ -180,19 +289,14 @@ module NNQ
180
289
  @lifecycle.parent_task.async(annotation: "nnq recv #{conn.endpoint}") do
181
290
  loop do
182
291
  body = conn.receive_message
292
+ emit_verbose_monitor_event(:message_received, body: body)
183
293
  @routing.enqueue(body, conn)
184
- rescue EOFError, IOError, Errno::ECONNRESET, Async::Stop
294
+ rescue *CONNECTION_LOST, Async::Stop
185
295
  break
186
296
  end
187
297
  ensure
188
298
  handle_connection_lost(conn)
189
299
  end
190
300
  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
301
  end
198
302
  end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NNQ
4
+ # Lifecycle event emitted by {Socket#monitor}.
5
+ #
6
+ # @!attribute [r] type
7
+ # @return [Symbol] event type (:listening, :connected, :disconnected, ...)
8
+ # @!attribute [r] endpoint
9
+ # @return [String, nil] the endpoint involved
10
+ # @!attribute [r] detail
11
+ # @return [Hash, nil] extra context
12
+ #
13
+ MonitorEvent = Data.define(:type, :endpoint, :detail) do
14
+ def initialize(type:, endpoint: nil, detail: nil) = super
15
+ end
16
+ end
data/lib/nnq/push_pull.rb CHANGED
@@ -34,7 +34,13 @@ module NNQ
34
34
  #
35
35
  class PULL < Socket
36
36
  def receive
37
- Reactor.run { @engine.routing.receive }
37
+ Reactor.run do
38
+ if (timeout = @engine.options.read_timeout)
39
+ Fiber.scheduler.with_timeout(timeout) { @engine.routing.receive }
40
+ else
41
+ @engine.routing.receive
42
+ end
43
+ end
38
44
  end
39
45
 
40
46
 
@@ -67,6 +67,12 @@ module NNQ
67
67
  super
68
68
  @recv_queue.enqueue(nil) # wake any waiter
69
69
  end
70
+
71
+
72
+ # Wake recv side without tearing down the send pump.
73
+ def close_read
74
+ @recv_queue.enqueue(nil)
75
+ end
70
76
  end
71
77
  end
72
78
  end
@@ -77,6 +77,7 @@ module NNQ
77
77
  loop do
78
78
  body = queue.dequeue
79
79
  conn.send_message(body)
80
+ @engine.emit_verbose_monitor_event(:message_sent, body: body)
80
81
  rescue EOFError, IOError, Errno::EPIPE, Errno::ECONNRESET
81
82
  break
82
83
  end
@@ -33,6 +33,14 @@ 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
36
44
  end
37
45
  end
38
46
  end
@@ -89,6 +89,11 @@ module NNQ
89
89
  end
90
90
 
91
91
 
92
+ def close_read
93
+ @recv_queue.enqueue(nil)
94
+ end
95
+
96
+
92
97
  private
93
98
 
94
99
  # Reads 4-byte BE words off the front of +body+, stopping at the
@@ -123,6 +123,7 @@ module NNQ
123
123
  conn.write_messages(batch)
124
124
  end
125
125
  conn.flush
126
+ batch.each { |body| @engine.emit_verbose_monitor_event(:message_sent, body: body) }
126
127
  end
127
128
  end
128
129
  end
@@ -48,6 +48,11 @@ module NNQ
48
48
  @queue.enqueue(nil)
49
49
  end
50
50
 
51
+
52
+ def close_read
53
+ @queue.enqueue(nil)
54
+ end
55
+
51
56
  private
52
57
 
53
58
  def matches?(body)
data/lib/nnq/socket.rb CHANGED
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "async/queue"
4
+
3
5
  require_relative "options"
4
6
  require_relative "engine"
7
+ require_relative "monitor_event"
5
8
  require_relative "reactor"
6
9
 
7
10
  module NNQ
@@ -57,6 +60,57 @@ module NNQ
57
60
  def connection_count = @engine.connections.size
58
61
 
59
62
 
63
+ # Resolves with the first connected peer (or nil on close without
64
+ # any peers). Block on `.wait` to wait until a connection is ready.
65
+ def peer_connected = @engine.peer_connected
66
+
67
+
68
+ # Resolves with `true` the first time all peers have disconnected
69
+ # (after at least one peer was connected). Edge-triggered.
70
+ def all_peers_gone = @engine.all_peers_gone
71
+
72
+
73
+ def reconnect_enabled = @engine.reconnect_enabled
74
+ def reconnect_enabled=(value)
75
+ @engine.reconnect_enabled = value
76
+ end
77
+
78
+
79
+ # Closes the recv side only. Buffered messages drain, then
80
+ # {#receive} returns nil. Send side stays open.
81
+ def close_read
82
+ Reactor.run { @engine.close_read }
83
+ nil
84
+ end
85
+
86
+
87
+ # Yields lifecycle events for this socket until it's closed or
88
+ # the returned task is stopped.
89
+ #
90
+ # @param verbose [Boolean] when true, also emits :message_sent /
91
+ # :message_received events
92
+ # @yield [event]
93
+ # @yieldparam event [MonitorEvent]
94
+ # @return [Async::Task]
95
+ def monitor(verbose: false, &block)
96
+ ensure_parent_task
97
+ queue = Async::Queue.new
98
+ @engine.monitor_queue = queue
99
+ @engine.verbose_monitor = verbose
100
+ Reactor.run do
101
+ @engine.spawn_task(annotation: "nnq monitor") do
102
+ while (event = queue.dequeue)
103
+ block.call(event)
104
+ end
105
+ rescue Async::Stop
106
+ ensure
107
+ @engine.monitor_queue = nil
108
+ block.call(MonitorEvent.new(type: :monitor_stopped))
109
+ end
110
+ end
111
+ end
112
+
113
+
60
114
  private
61
115
 
62
116
  def ensure_parent_task
@@ -65,9 +119,9 @@ module NNQ
65
119
  # that Reactor wraps each dispatched block in. Inside an Async
66
120
  # reactor, the current task is the right parent.
67
121
  if Async::Task.current?
68
- @engine.capture_parent_task(Async::Task.current)
122
+ @engine.capture_parent_task(Async::Task.current, on_io_thread: false)
69
123
  else
70
- @engine.capture_parent_task(Reactor.root_task)
124
+ @engine.capture_parent_task(Reactor.root_task, on_io_thread: true)
71
125
  end
72
126
  end
73
127
 
data/lib/nnq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NNQ
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/nnq.rb CHANGED
@@ -3,6 +3,14 @@
3
3
  require "protocol/sp"
4
4
 
5
5
  module NNQ
6
+ # Freezes module-level state so NNQ sockets can be used inside Ractors.
7
+ # Call this once before spawning any Ractors that create NNQ sockets.
8
+ #
9
+ def self.freeze_for_ractors!
10
+ Engine::CONNECTION_FAILED.freeze
11
+ Engine::CONNECTION_LOST.freeze
12
+ Engine::TRANSPORTS.freeze
13
+ end
6
14
  end
7
15
 
8
16
  require_relative "nnq/version"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nnq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -52,8 +52,8 @@ dependencies:
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.1'
54
54
  description: Pure Ruby implementation of nanomsg's Scalability Protocols (SP) on top
55
- of async + io-stream. No HWM, opportunistic batching, wire-compatible with libnng
56
- over inproc/ipc/tcp.
55
+ of async + io-stream. Per-socket HWM, opportunistic batching, wire-compatible with
56
+ libnng over inproc/ipc/tcp.
57
57
  email:
58
58
  - paddor@gmail.com
59
59
  executables: []
@@ -67,8 +67,10 @@ files:
67
67
  - lib/nnq/connection.rb
68
68
  - lib/nnq/engine.rb
69
69
  - lib/nnq/engine/connection_lifecycle.rb
70
+ - lib/nnq/engine/reconnect.rb
70
71
  - lib/nnq/engine/socket_lifecycle.rb
71
72
  - lib/nnq/error.rb
73
+ - lib/nnq/monitor_event.rb
72
74
  - lib/nnq/options.rb
73
75
  - lib/nnq/pair.rb
74
76
  - lib/nnq/pub_sub.rb