nnq 0.7.0 → 0.8.2

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: c55340c77dffd3e4fdbc8419fb463e608a6e635adc577582e295b8af20fdc3e0
4
- data.tar.gz: 6bb76f07c3732a4e69ad9a8e93a6a852f5ca04aac40fedc0df346ec9a2d74297
3
+ metadata.gz: 559ecdf415aadfb477c89de0594cbaa5260a567664196c218c9315d2ad93e42c
4
+ data.tar.gz: a732f598f7558f0e174a30b2f146bed2a9287883bb0614418fdd9d60b0271df5
5
5
  SHA512:
6
- metadata.gz: 1cb190f96ace0a94363ea6a0b24b9d8b563363fbb3fb84e0bb4927a8ef818949270f20dec984828405bd15068400ace40aa5a9017d52321e2f9e10aa5235d973
7
- data.tar.gz: 036a2a8203a7a26afad8d67fd23d9606d4947561ec0d8d31893b6efb116b62f623560afc6a4293fdbbd8846c39fa76aa9885eb47c9ab11900d552f5bc4377ecd
6
+ metadata.gz: 511582392bd0d3a18b032158ab13215fe33f469e0b46d1d2ddc03b9f10cfdf433b37cb24dc1ba40a42721d34a774844a3a05e8eb68d463bfa9a8fde5204ae0ca
7
+ data.tar.gz: 9d40868d84ae3ccb1fd78aba9bf88c0f0fb3f4646a330b56a424e31d89d57358d686a3feb8df4e1f761519e80cd0c5ab3170a0c7e06019715e8ee817eb9d0b27
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.2 — 2026-04-20
4
+
5
+ - **`:message_received` verbose events carry `wire_size:`.** The
6
+ engine recv loop now reads `conn.last_wire_size_in` (duck-typed,
7
+ nil-safe) and passes it through `emit_verbose_msg_received` into
8
+ the monitor event detail, so compression decorators like nnq-zstd
9
+ can surface the compressed on-the-wire byte count to `-vvv`
10
+ traces. Plain transports are unaffected (`wire_size: nil`).
11
+
12
+ ## 0.8.1 — 2026-04-19
13
+
14
+ - **Fix close-race in `ConnectionLifecycle#tear_down!`.** The fd was
15
+ closed before sibling pumps were cancelled, which woke recv fibers
16
+ parked in `io_wait` with `IOError: stream closed in another thread`.
17
+ `@barrier.stop` now runs before `@conn.close`, so blocking reads
18
+ unwind via `Async::Stop` before the fd goes away.
19
+
20
+ ## 0.8.0 — 2026-04-19
21
+
22
+ - **Uniform frozen + `BINARY` message contract across transports.**
23
+ `Socket#coerce_binary` replaces the old `frozen_binary` + `.b.freeze`
24
+ copy on the hot send path. Every send method runs its body through
25
+ `coerce_binary`, which:
26
+ - coerces non-String bodies via `#to_str` (nil / `42` / `:foo` raise
27
+ `NoMethodError` instead of producing a zero-byte frame);
28
+ - re-tags unfrozen non-BINARY bodies to `Encoding::BINARY` in place —
29
+ a flag flip, no copy;
30
+ - freezes the body.
31
+
32
+ Receivers always see a frozen BINARY-tagged body: TCP/IPC get it via
33
+ the recv-pump freeze, inproc gets it via `Pipe#send_message`, which
34
+ only allocates for the pathological case of a frozen non-BINARY body
35
+ (the typical `# frozen_string_literal: true` UTF-8 literal). Bodies
36
+ returned by REP/REQ/SURVEYOR/RESPONDENT (cooked and raw) are frozen
37
+ by `parse_backtrace` and the REQ/SURVEYOR id-parsing paths. Mutation
38
+ bugs surface as `FrozenError` instead of silently corrupting a shared
39
+ reference on the inproc fast path. Inproc throughput pays ~20-30%
40
+ for the contract; TCP/IPC unaffected.
41
+
42
+ - **Benchmarks send fresh strings per iteration.** `BenchHelper.run`
43
+ passes an unfrozen `"x" * size` through to the burst closure; the
44
+ `measure` / `measure_roundtrip` bursts `.dup` it before each send.
45
+ More realistic than reusing one frozen payload and hitting every
46
+ fast path in `coerce_binary` + `Pipe#send_message`.
47
+
3
48
  ## 0.7.0 — 2026-04-18
4
49
 
5
50
  - **Inproc transport now uses a queue-based `Inproc::Pipe`** instead
@@ -20,15 +65,6 @@
20
65
  PAIR, SUB, REP, RESPONDENT, SURVEYOR, and the `*_raw` variants all
21
66
  implement the hook; REQ (promise-based) stays on the fiber path.
22
67
  Cuts three fiber hops to one on the steady-state recv path.
23
-
24
- Inproc PUSH/PULL single-peer throughput (Ruby 4.0.2):
25
-
26
- | Size | Before (no JIT) | After (no JIT) | After (+YJIT) |
27
- |---|---|---|---|
28
- | 128 B | 122k msg/s | 350k msg/s | 1,226k msg/s |
29
- | 2 KiB | 87k msg/s | 360k msg/s | 1,458k msg/s |
30
- | 32 KiB | 21k msg/s | 261k msg/s | 887k msg/s |
31
-
32
68
  - **Routing pumps shed their `@pump_tasks` bookkeeping.** `bus`, `pub`,
33
69
  `surveyor`, and `surveyor_raw` no longer track per-connection pump
34
70
  tasks in a hash. Pumps are spawned under
data/lib/nnq/bus.rb CHANGED
@@ -13,7 +13,7 @@ module NNQ
13
13
  #
14
14
  class BUS0 < Socket
15
15
  def send(body)
16
- body = frozen_binary(body)
16
+ body = coerce_binary(body)
17
17
  Reactor.run { @engine.routing.send(body) }
18
18
  end
19
19
 
@@ -151,6 +151,11 @@ module NNQ
151
151
  def tear_down!(reconnect: false)
152
152
  return if @state == :closed
153
153
  transition!(:closed)
154
+ # Cancel sibling pumps BEFORE closing the fd. If we close first,
155
+ # any pump still parked in io_wait wakes up with
156
+ # `IOError: stream closed in another thread`. The caller is the
157
+ # supervisor task, which is NOT in the barrier — no self-stop.
158
+ @barrier.stop
154
159
  if @conn
155
160
  @engine.connections.delete(@conn)
156
161
  @engine.routing.connection_removed(@conn) if @engine.routing.respond_to?(:connection_removed)
@@ -159,10 +164,6 @@ module NNQ
159
164
  @engine.resolve_all_peers_gone_if_empty
160
165
  end
161
166
  @engine.maybe_reconnect(@endpoint) if reconnect
162
- # Cancel every sibling pump of this connection. The caller is
163
- # the supervisor task, which is NOT in the barrier — so there
164
- # is no self-stop risk.
165
- @barrier.stop
166
167
  end
167
168
 
168
169
 
data/lib/nnq/engine.rb CHANGED
@@ -118,10 +118,12 @@ module NNQ
118
118
 
119
119
 
120
120
  # Emits a :message_received verbose event. Same early-return
121
- # discipline as {#emit_verbose_msg_sent}.
122
- def emit_verbose_msg_received(body)
121
+ # discipline as {#emit_verbose_msg_sent}. +wire_size+ is the
122
+ # on-the-wire byte count when the connection is wrapped by a
123
+ # compression decorator (nnq-zstd); nil for plain transports.
124
+ def emit_verbose_msg_received(body, wire_size: nil)
123
125
  return unless @verbose_monitor
124
- emit_monitor_event(:message_received, detail: { body: body })
126
+ emit_monitor_event(:message_received, detail: { body: body, wire_size: wire_size })
125
127
  end
126
128
 
127
129
 
@@ -382,10 +384,11 @@ module NNQ
382
384
 
383
385
  @connections[conn].barrier.async(annotation: "nnq recv #{conn.endpoint}") do
384
386
  loop do
385
- body = conn.receive_message
387
+ body = conn.receive_message.freeze
386
388
  if @verbose_monitor
387
- preview = @routing.respond_to?(:preview_body) ? @routing.preview_body(body) : body
388
- emit_verbose_msg_received(preview)
389
+ preview = @routing.respond_to?(:preview_body) ? @routing.preview_body(body) : body
390
+ wire_size = conn.respond_to?(:last_wire_size_in) ? conn.last_wire_size_in : nil
391
+ emit_verbose_msg_received(preview, wire_size: wire_size)
389
392
  end
390
393
  @routing.enqueue(body, conn)
391
394
  rescue *CONNECTION_LOST, Async::Stop
data/lib/nnq/pair.rb CHANGED
@@ -10,7 +10,7 @@ module NNQ
10
10
  #
11
11
  class PAIR0 < Socket
12
12
  def send(body)
13
- body = frozen_binary(body)
13
+ body = coerce_binary(body)
14
14
  Reactor.run { @engine.routing.send(body) }
15
15
  end
16
16
 
data/lib/nnq/pub_sub.rb CHANGED
@@ -12,7 +12,7 @@ module NNQ
12
12
  #
13
13
  class PUB0 < Socket
14
14
  def send(body)
15
- body = frozen_binary(body)
15
+ body = coerce_binary(body)
16
16
  Reactor.run { @engine.routing.send(body) }
17
17
  end
18
18
 
data/lib/nnq/push_pull.rb CHANGED
@@ -11,7 +11,7 @@ module NNQ
11
11
  #
12
12
  class PUSH0 < Socket
13
13
  def send(body)
14
- body = frozen_binary(body)
14
+ body = coerce_binary(body)
15
15
  Reactor.run { @engine.routing.send(body) }
16
16
  end
17
17
 
data/lib/nnq/req_rep.rb CHANGED
@@ -18,7 +18,7 @@ module NNQ
18
18
  # raw mode — use {#send} / {#receive} there.
19
19
  def send_request(body)
20
20
  raise Error, "REQ#send_request not available in raw mode" if raw?
21
- body = frozen_binary(body)
21
+ body = coerce_binary(body)
22
22
  Reactor.run { @engine.routing.send_request(body) }
23
23
  end
24
24
 
@@ -29,7 +29,7 @@ module NNQ
29
29
  # cooked mode.
30
30
  def send(body, header:)
31
31
  raise Error, "REQ#send not available in cooked mode" unless raw?
32
- body = frozen_binary(body)
32
+ body = coerce_binary(body)
33
33
  Reactor.run { @engine.routing.send(body, header: header) }
34
34
  end
35
35
 
@@ -74,7 +74,7 @@ module NNQ
74
74
  # came from. Raises in raw mode.
75
75
  def send_reply(body)
76
76
  raise Error, "REP#send_reply not available in raw mode" if raw?
77
- body = frozen_binary(body)
77
+ body = coerce_binary(body)
78
78
  Reactor.run { @engine.routing.send_reply(body) }
79
79
  end
80
80
 
@@ -84,7 +84,7 @@ module NNQ
84
84
  # tuple). Silent drop if +to+ is closed. Raises in cooked mode.
85
85
  def send(body, to:, header:)
86
86
  raise Error, "REP#send not available in cooked mode" unless raw?
87
- body = frozen_binary(body)
87
+ body = coerce_binary(body)
88
88
  Reactor.run { @engine.routing.send(body, to: to, header: header) }
89
89
  end
90
90
 
@@ -27,7 +27,7 @@ module NNQ
27
27
  hops += 1
28
28
 
29
29
  if word.getbyte(0) & 0x80 != 0
30
- return [body.byteslice(0, offset), body.byteslice(offset..)]
30
+ return [body.byteslice(0, offset).freeze, body.byteslice(offset..).freeze]
31
31
  end
32
32
  end
33
33
 
@@ -71,7 +71,7 @@ module NNQ
71
71
  def enqueue(body, _conn)
72
72
  return if body.bytesize < 4
73
73
  id = body.unpack1("N")
74
- payload = body.byteslice(4..)
74
+ payload = body.byteslice(4..).freeze
75
75
 
76
76
  @mutex.synchronize do
77
77
  if @outstanding && @outstanding[0] == id
@@ -67,7 +67,7 @@ module NNQ
67
67
  return if body.bytesize < 4
68
68
 
69
69
  id = body.unpack1("N")
70
- payload = body.byteslice(4..)
70
+ payload = body.byteslice(4..).freeze
71
71
 
72
72
  @mutex.synchronize do
73
73
  return unless @current_id == id
@@ -84,7 +84,7 @@ module NNQ
84
84
  transform = lambda do |body|
85
85
  next nil if body.bytesize < 4
86
86
  id = body.unpack1("N")
87
- payload = body.byteslice(4..)
87
+ payload = body.byteslice(4..).freeze
88
88
  match = mutex.synchronize { @current_id == id }
89
89
  match ? payload : nil
90
90
  end
data/lib/nnq/socket.rb CHANGED
@@ -135,16 +135,21 @@ module NNQ
135
135
  end
136
136
 
137
137
 
138
- # Coerces +body+ to a frozen binary string. Called by every send
139
- # method so a caller can't mutate the string after it's been
140
- # enqueued (the body sits in a send queue or per-peer queue until
141
- # the pump writes it, and an unfrozen caller-owned buffer could be
142
- # appended to mid-flight).
138
+ # Coerces +body+ to a frozen `Encoding::BINARY`-tagged String and
139
+ # returns it. Every send method runs its body through this so the
140
+ # receiver sees a uniform frozen+BINARY contract across transports
141
+ # (mutation bugs raise `FrozenError` instead of silently corrupting
142
+ # a shared reference on the inproc fast path).
143
143
  #
144
- # Fast-path: already frozen + binary returned as-is.
145
- def frozen_binary(body)
146
- return body if body.frozen? && body.encoding == Encoding::BINARY
147
- body.b.freeze
144
+ # Fast-path: unfrozen non-BINARY strings are re-tagged in place
145
+ # (force_encoding is a flag flip, no copy). The pathological case
146
+ # of a frozen non-BINARY body (e.g. a `# frozen_string_literal: true`
147
+ # literal) can't be re-tagged in place — the inproc {Pipe} handles
148
+ # that with a copy so the receive contract stays uniform.
149
+ def coerce_binary(body)
150
+ body = body.to_str unless body.is_a?(String)
151
+ body.force_encoding(Encoding::BINARY) unless body.frozen? || body.encoding == Encoding::BINARY
152
+ body.freeze
148
153
  end
149
154
 
150
155
 
@@ -16,7 +16,7 @@ module NNQ
16
16
  # Cooked: broadcasts +body+ as a survey to all connected respondents.
17
17
  def send_survey(body)
18
18
  raise Error, "SURVEYOR#send_survey not available in raw mode" if raw?
19
- body = frozen_binary(body)
19
+ body = coerce_binary(body)
20
20
  Reactor.run { @engine.routing.send_survey(body) }
21
21
  end
22
22
 
@@ -26,7 +26,7 @@ module NNQ
26
26
  # protocol-sp header kwarg — no concat). Raises in cooked mode.
27
27
  def send(body, header:)
28
28
  raise Error, "SURVEYOR#send not available in cooked mode" unless raw?
29
- body = frozen_binary(body)
29
+ body = coerce_binary(body)
30
30
  Reactor.run { @engine.routing.send(body, header: header) }
31
31
  end
32
32
 
@@ -70,7 +70,7 @@ module NNQ
70
70
  # recent survey. Raises in raw mode.
71
71
  def send_reply(body)
72
72
  raise Error, "RESPONDENT#send_reply not available in raw mode" if raw?
73
- body = frozen_binary(body)
73
+ body = coerce_binary(body)
74
74
  Reactor.run { @engine.routing.send_reply(body) }
75
75
  end
76
76
 
@@ -78,7 +78,7 @@ module NNQ
78
78
  # Raw: writes +body+ with +header+ back to +to+. Raises in cooked mode.
79
79
  def send(body, to:, header:)
80
80
  raise Error, "RESPONDENT#send not available in cooked mode" unless raw?
81
- body = frozen_binary(body)
81
+ body = coerce_binary(body)
82
82
  Reactor.run { @engine.routing.send(body, to: to, header: header) }
83
83
  end
84
84
 
@@ -69,7 +69,14 @@ module NNQ
69
69
 
70
70
  def send_message(body, header: nil)
71
71
  raise ClosedError, "connection closed" if @closed
72
- wire = header ? header + body : body
72
+
73
+ # Socket#coerce_binary tags mutable bodies BINARY in place;
74
+ # the pathological case of a frozen non-BINARY body (e.g. a
75
+ # `# frozen_string_literal: true` literal) can't be re-tagged
76
+ # in place, so copy it here to keep the receiver contract
77
+ # uniform with TCP/IPC.
78
+ body = body.b.freeze if body.encoding != Encoding::BINARY
79
+ wire = header ? (header + body).freeze : body
73
80
 
74
81
  if (q = @direct_recv_queue)
75
82
  item = @direct_recv_transform ? @direct_recv_transform.call(wire) : wire
@@ -86,6 +93,10 @@ module NNQ
86
93
  def write_messages(bodies)
87
94
  raise ClosedError, "connection closed" if @closed
88
95
 
96
+ if bodies.any? { |b| b.encoding != Encoding::BINARY }
97
+ bodies = bodies.map { |b| b.encoding == Encoding::BINARY ? b : b.b.freeze }
98
+ end
99
+
89
100
  if (q = @direct_recv_queue)
90
101
  transform = @direct_recv_transform
91
102
  bodies.each do |body|
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.7.0"
4
+ VERSION = "0.8.2"
5
5
  end
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.7.0
4
+ version: 0.8.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -41,16 +41,16 @@ dependencies:
41
41
  name: protocol-sp
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
- - - ">="
44
+ - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.3'
46
+ version: '0.4'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
- - - ">="
51
+ - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.3'
53
+ version: '0.4'
54
54
  description: Pure Ruby implementation of nanomsg's Scalability Protocols (SP) on top
55
55
  of async + io-stream. Per-socket HWM, opportunistic batching, wire-compatible with
56
56
  libnng over inproc/ipc/tcp.