omq 0.2.0 → 0.2.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: fb02b0e08db21f4d0db4bb376a35759ca709ef80d175a306179c3710e8048e9d
4
- data.tar.gz: 41bfa63e9868e6a5d10c97e9751ebe227f8937be734d9f9bc5c618619cb1906c
3
+ metadata.gz: 02d85dfeec36f9d537cdfac7512a4a5edbfbe95145ae9583e8f5428a43a2548d
4
+ data.tar.gz: 1598f1cdecdc0523cf4263a72318f0500b1bd9dccb46da703817fd0b30c74315
5
5
  SHA512:
6
- metadata.gz: cf95700a644923f1598944501e10775de5ba0cfd277c375f3701c93e09a5e1928e495acea2e09eb5224f87143e55526f851852c2ba67d92409a5e0da62ec5266
7
- data.tar.gz: 9b299ca752fee2e1cba919d65bd7a377494e8067d111f27ac53c8dc2a35b3c9c3d0547386e38417656f58b746ea3dda6ea5b8292e1d9f9230736150561aad1b8
6
+ metadata.gz: ee523b073066febf851dc2427b8ddd86c62bc44580a3a9c4283a4d72345ee52048e07a396da1db0de7265f7a641ac6aafe02827542a2786833c8e78b0a25bebf
7
+ data.tar.gz: 88933632f2e57c092090af0a322772db4431af55f85f4e9f41c21df2aa88d68bc9641ef3691a6826335b89b8b030275576b978ebf9f558a22ab4a329da5c3bbe
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2 — 2026-03-26
4
+
5
+ ### Added
6
+
7
+ - `ØMQ` alias for `OMQ` — because Ruby can
8
+
9
+ ## 0.2.1 — 2026-03-26
10
+
11
+ ### Improved
12
+
13
+ - Replace `IO::Buffer` with `pack`/`unpack1`/`getbyte`/`byteslice` in
14
+ frame, command, and greeting codecs — up to 68% higher throughput for
15
+ large messages, 21% lower TCP latency
16
+
3
17
  ## 0.2.0 — 2026-03-26
4
18
 
5
19
  ### Changed
data/README.md CHANGED
@@ -7,7 +7,11 @@
7
7
 
8
8
  Pure Ruby implementation of the [ZMTP 3.1](https://rfc.zeromq.org/spec/23/) wire protocol ([ZeroMQ](https://zeromq.org/)) using the [Async](https://github.com/socketry/async) gem. No native libraries required.
9
9
 
10
- > **186k msg/s** inproc throughput | **12 µs** req/rep roundtrip latency | pure Ruby + YJIT
10
+ > **167k msg/s** inproc | **42k msg/s** ipc | **32k msg/s** tcp
11
+ >
12
+ > **15 µs** inproc latency | **62 µs** ipc | **88 µs** tcp
13
+ >
14
+ > Ruby 4.0 + YJIT on a Linux VM on a 2019 MacBook Pro (Intel)
11
15
 
12
16
  ---
13
17
 
@@ -25,7 +29,7 @@ Modern Ruby has closed the gap:
25
29
 
26
30
  - **YJIT** — JIT-compiled hot paths close the throughput gap with C extensions
27
31
  - **Fiber Scheduler** — non-blocking I/O without callbacks or threads (`Async` builds on this)
28
- - **`IO::Buffer`** — zero-copy binary reads/writes, no manual `String#b` packing
32
+ - **`io-stream`** — buffered I/O with read-ahead, from the Async ecosystem
29
33
 
30
34
  When [CZTop](https://github.com/paddor/cztop) was written, none of this existed. Today, a pure Ruby ZMTP implementation is fast enough for production use — and you get `gem install` with no compiler toolchain, no system packages, and no segfaults.
31
35
 
@@ -109,7 +113,11 @@ end
109
113
  | Routing | `DEALER`, `ROUTER` | bidirectional |
110
114
  | Exclusive pair | `PAIR` | bidirectional |
111
115
 
112
- All classes live under `OMQ::`.
116
+ All classes live under `OMQ::`. For the purists, `ØMQ` is an alias:
117
+
118
+ ```ruby
119
+ req = ØMQ::REQ.new(">tcp://localhost:5555")
120
+ ```
113
121
 
114
122
  ## Performance
115
123
 
data/lib/omq/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OMQ
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.2"
5
5
  end
@@ -39,11 +39,7 @@ module OMQ
39
39
  #
40
40
  def to_body
41
41
  name_bytes = @name.b
42
- buf = IO::Buffer.new(1 + name_bytes.bytesize + @data.bytesize)
43
- buf.set_value(:U8, 0, name_bytes.bytesize)
44
- buf.set_string(name_bytes, 1)
45
- buf.set_string(@data, 1 + name_bytes.bytesize)
46
- buf.get_string(0, buf.size, Encoding::BINARY)
42
+ name_bytes.bytesize.chr.b + name_bytes + @data
47
43
  end
48
44
 
49
45
  # Encodes as a complete command Frame.
@@ -64,12 +60,11 @@ module OMQ
64
60
  body = body.b
65
61
  raise ProtocolError, "command body too short" if body.bytesize < 1
66
62
 
67
- buf = IO::Buffer.for(body)
68
- name_len = buf.get_value(:U8, 0)
63
+ name_len = body.getbyte(0)
69
64
 
70
65
  raise ProtocolError, "command name truncated" if body.bytesize < 1 + name_len
71
66
 
72
- name = buf.get_string(1, name_len, Encoding::BINARY)
67
+ name = body.byteslice(1, name_len)
73
68
  data = body.byteslice(1 + name_len..)
74
69
  new(name, data)
75
70
  end
@@ -113,12 +108,8 @@ module OMQ
113
108
  # @return [Command]
114
109
  #
115
110
  def self.ping(ttl: 0, context: "".b)
116
- # TTL is encoded as 2-byte big-endian value in tenths of a second
117
111
  ttl_ds = (ttl * 10).to_i
118
- buf = IO::Buffer.new(2 + context.bytesize)
119
- buf.set_value(:U16, 0, ttl_ds)
120
- buf.set_string(context.b, 2) if context.bytesize > 0
121
- new("PING", buf.get_string(0, buf.size, Encoding::BINARY))
112
+ new("PING", [ttl_ds].pack("n") + context.b)
122
113
  end
123
114
 
124
115
  # Builds a PONG command.
@@ -135,8 +126,7 @@ module OMQ
135
126
  # @return [Array(Numeric, String)] [ttl_seconds, context_bytes]
136
127
  #
137
128
  def ping_ttl_and_context
138
- buf = IO::Buffer.for(@data)
139
- ttl_ds = buf.get_value(:U16, 0)
129
+ ttl_ds = @data.unpack1("n")
140
130
  context = @data.bytesize > 2 ? @data.byteslice(2..) : "".b
141
131
  [ttl_ds / 10.0, context]
142
132
  end
@@ -157,17 +147,13 @@ module OMQ
157
147
  #
158
148
  def self.encode_properties(props)
159
149
  parts = props.map do |name, value|
160
- name_bytes = name.b
150
+ name_bytes = name.b
161
151
  value_bytes = value.b
162
- buf = IO::Buffer.new(1 + name_bytes.bytesize + 4 + value_bytes.bytesize)
163
- buf.set_value(:U8, 0, name_bytes.bytesize)
164
- buf.set_string(name_bytes, 1)
165
- buf.set_value(:U32, 1 + name_bytes.bytesize, value_bytes.bytesize) # big-endian
166
- buf.set_string(value_bytes, 1 + name_bytes.bytesize + 4)
167
- buf.get_string(0, buf.size, Encoding::BINARY)
152
+ name_bytes.bytesize.chr.b + name_bytes + [value_bytes.bytesize].pack("N") + value_bytes
168
153
  end
169
154
  parts.join
170
155
  end
156
+
171
157
  # Decodes a ZMTP property list from binary data.
172
158
  #
173
159
  # @param data [String] binary property list
@@ -176,24 +162,23 @@ module OMQ
176
162
  #
177
163
  def self.decode_properties(data)
178
164
  result = {}
179
- buf = IO::Buffer.for(data)
180
165
  offset = 0
181
166
 
182
167
  while offset < data.bytesize
183
168
  raise ProtocolError, "property name truncated" if offset + 1 > data.bytesize
184
- name_len = buf.get_value(:U8, offset)
169
+ name_len = data.getbyte(offset)
185
170
  offset += 1
186
171
 
187
172
  raise ProtocolError, "property name truncated" if offset + name_len > data.bytesize
188
- name = buf.get_string(offset, name_len, Encoding::BINARY)
173
+ name = data.byteslice(offset, name_len)
189
174
  offset += name_len
190
175
 
191
176
  raise ProtocolError, "property value length truncated" if offset + 4 > data.bytesize
192
- value_len = buf.get_value(:U32, offset)
177
+ value_len = data.byteslice(offset, 4).unpack1("N")
193
178
  offset += 4
194
179
 
195
180
  raise ProtocolError, "property value truncated" if offset + value_len > data.bytesize
196
- value = buf.get_string(offset, value_len, Encoding::BINARY)
181
+ value = data.byteslice(offset, value_len)
197
182
  offset += value_len
198
183
 
199
184
  result[name] = value
@@ -46,52 +46,37 @@ module OMQ
46
46
  # @return [String] binary wire representation (flags + size + body)
47
47
  #
48
48
  def to_wire
49
- size = @body.bytesize
49
+ size = @body.bytesize
50
50
  flags = 0
51
51
  flags |= FLAGS_MORE if @more
52
52
  flags |= FLAGS_COMMAND if @command
53
53
 
54
54
  if size > SHORT_MAX
55
- flags |= FLAGS_LONG
56
- buf = IO::Buffer.new(9 + size)
57
- buf.set_value(:U8, 0, flags)
58
- buf.set_value(:U64, 1, size) # big-endian
59
- buf.set_string(@body, 9)
60
- buf.get_string(0, 9 + size, Encoding::BINARY)
55
+ (flags | FLAGS_LONG).chr.b + [size].pack("Q>") + @body
61
56
  else
62
- buf = IO::Buffer.new(2 + size)
63
- buf.set_value(:U8, 0, flags)
64
- buf.set_value(:U8, 1, size)
65
- buf.set_string(@body, 2)
66
- buf.get_string(0, 2 + size, Encoding::BINARY)
57
+ flags.chr.b + size.chr.b + @body
67
58
  end
68
59
  end
69
60
 
70
61
  # Reads one frame from an IO-like object.
71
62
  #
72
- # @param io [#read] must support read(n) returning exactly n bytes
63
+ # @param io [#read_exactly] must support read_exactly(n)
73
64
  # @return [Frame]
74
65
  # @raise [ProtocolError] on invalid frame
75
66
  # @raise [EOFError] if the connection is closed
76
67
  #
77
68
  def self.read_from(io)
78
- flags_byte = io.read_exactly(1)
79
- flags_buf = IO::Buffer.for(flags_byte)
80
- flags = flags_buf.get_value(:U8, 0)
69
+ flags = io.read_exactly(1).getbyte(0)
81
70
 
82
- more = (flags & FLAGS_MORE) != 0
83
- long = (flags & FLAGS_LONG) != 0
71
+ more = (flags & FLAGS_MORE) != 0
72
+ long = (flags & FLAGS_LONG) != 0
84
73
  command = (flags & FLAGS_COMMAND) != 0
85
74
 
86
- if long
87
- size_bytes = io.read_exactly(8)
88
- size_buf = IO::Buffer.for(size_bytes)
89
- size = size_buf.get_value(:U64, 0) # big-endian
90
- else
91
- size_byte = io.read_exactly(1)
92
- size_buf = IO::Buffer.for(size_byte)
93
- size = size_buf.get_value(:U8, 0)
94
- end
75
+ size = if long
76
+ io.read_exactly(8).unpack1("Q>")
77
+ else
78
+ io.read_exactly(1).getbyte(0)
79
+ end
95
80
 
96
81
  body = size > 0 ? io.read_exactly(size) : "".b
97
82
 
@@ -33,26 +33,11 @@ module OMQ
33
33
  # @return [String] 64-byte binary greeting
34
34
  #
35
35
  def self.encode(mechanism: "NULL", as_server: false)
36
- buf = IO::Buffer.new(SIZE)
37
- buf.clear
38
-
39
- # Signature
40
- buf.set_value(:U8, 0, SIGNATURE_START)
41
- # bytes 1-8 are already 0x00
42
- buf.set_value(:U8, 9, SIGNATURE_END)
43
-
44
- # Version
45
- buf.set_value(:U8, 10, VERSION_MAJOR)
46
- buf.set_value(:U8, 11, VERSION_MINOR)
47
-
48
- # Mechanism (null-padded)
49
- buf.set_string(mechanism.b, MECHANISM_OFFSET)
50
-
51
- # As-server flag
52
- buf.set_value(:U8, AS_SERVER_OFFSET, as_server ? 1 : 0)
53
-
54
- # Filler bytes 33-63 are already 0x00
55
- buf.get_string(0, SIZE, Encoding::BINARY)
36
+ buf = "\xFF".b + ("\x00" * 8) + "\x7F".b
37
+ buf << [VERSION_MAJOR, VERSION_MINOR].pack("CC")
38
+ buf << mechanism.b.ljust(MECHANISM_LENGTH, "\x00")
39
+ buf << (as_server ? "\x01" : "\x00")
40
+ buf << ("\x00" * 31)
56
41
  end
57
42
 
58
43
  # Decodes a ZMTP greeting.
@@ -64,24 +49,21 @@ module OMQ
64
49
  def self.decode(data)
65
50
  raise ProtocolError, "greeting too short (#{data.bytesize} bytes)" if data.bytesize < SIZE
66
51
 
67
- buf = IO::Buffer.for(data.b)
52
+ data = data.b
68
53
 
69
- # Validate signature
70
- unless buf.get_value(:U8, 0) == SIGNATURE_START &&
71
- buf.get_value(:U8, 9) == SIGNATURE_END
54
+ unless data.getbyte(0) == SIGNATURE_START && data.getbyte(9) == SIGNATURE_END
72
55
  raise ProtocolError, "invalid greeting signature"
73
56
  end
74
57
 
75
- major = buf.get_value(:U8, 10)
76
- minor = buf.get_value(:U8, 11)
58
+ major = data.getbyte(10)
59
+ minor = data.getbyte(11)
77
60
 
78
61
  unless major >= 3
79
62
  raise ProtocolError, "unsupported ZMTP version #{major}.#{minor} (need >= 3.0)"
80
63
  end
81
64
 
82
- mechanism = buf.get_string(MECHANISM_OFFSET, MECHANISM_LENGTH, Encoding::BINARY)
83
- .delete("\x00")
84
- as_server = buf.get_value(:U8, AS_SERVER_OFFSET) == 1
65
+ mechanism = data.byteslice(MECHANISM_OFFSET, MECHANISM_LENGTH).delete("\x00")
66
+ as_server = data.getbyte(AS_SERVER_OFFSET) == 1
85
67
 
86
68
  {
87
69
  major: major,
data/lib/omq.rb CHANGED
@@ -17,3 +17,6 @@ require_relative "omq/router_dealer"
17
17
  require_relative "omq/pub_sub"
18
18
  require_relative "omq/push_pull"
19
19
  require_relative "omq/pair"
20
+
21
+ # For the purists.
22
+ ØMQ = OMQ
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger