omq-zstd 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.
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "delegate"
4
+
5
+ module OMQ
6
+ module Transport
7
+ module ZstdTcp
8
+ class ZstdConnection < SimpleDelegator
9
+ # @return [Integer, nil] wire bytesize of the last received message
10
+ #
11
+ attr_reader :last_wire_size_in
12
+
13
+
14
+ def initialize(conn, codec)
15
+ super(conn)
16
+ @codec = codec
17
+ @dict_shipped = false
18
+ @recv_dict = nil
19
+ @last_wire_size_in = nil
20
+ end
21
+
22
+
23
+ def send_message(parts)
24
+ compressed = @codec.compress_parts(parts)
25
+ ship_dict!
26
+ __getobj__.send_message(compressed)
27
+ end
28
+
29
+
30
+ def write_message(parts)
31
+ compressed = @codec.compress_parts(parts)
32
+ ship_dict!
33
+ __getobj__.write_message(compressed)
34
+ end
35
+
36
+
37
+ def write_messages(messages)
38
+ compressed = messages.map { |parts| @codec.compress_parts(parts) }
39
+ ship_dict!
40
+ __getobj__.write_messages(compressed)
41
+ end
42
+
43
+
44
+ def receive_message
45
+ loop do
46
+ parts = __getobj__.receive_message
47
+ decoded = decode_parts(parts)
48
+ if decoded
49
+ @last_wire_size_in = parts.sum { |p| p.bytesize }
50
+ return decoded
51
+ end
52
+ end
53
+ end
54
+
55
+
56
+ def respond_to?(name, include_private = false)
57
+ return false if name == :write_wire
58
+ super
59
+ end
60
+
61
+
62
+ private
63
+
64
+
65
+ def ship_dict!
66
+ return if @dict_shipped
67
+
68
+ dict_bytes = @codec.send_dict_bytes
69
+ return unless dict_bytes
70
+
71
+ __getobj__.write_message([dict_bytes])
72
+ @dict_shipped = true
73
+ end
74
+
75
+
76
+ def decode_parts(parts)
77
+ budget = @codec.max_message_size
78
+ decoded = []
79
+ all_dicts = true
80
+
81
+ parts.each do |wire|
82
+ plaintext = decode_part(wire, budget)
83
+ if plaintext
84
+ all_dicts = false
85
+ budget -= plaintext.bytesize if budget
86
+ decoded << plaintext
87
+ end
88
+ end
89
+
90
+ all_dicts ? nil : decoded
91
+ end
92
+
93
+
94
+ def decode_part(wire, budget)
95
+ raise ProtocolError, "short frame" if wire.bytesize < 4
96
+
97
+ head = wire.byteslice(0, 4)
98
+
99
+ case head
100
+ when Codec::NUL_PREAMBLE
101
+ plaintext = wire.byteslice(4, wire.bytesize - 4) || "".b
102
+ enforce_budget!(plaintext.bytesize, budget)
103
+ plaintext
104
+ when Codec::ZSTD_MAGIC
105
+ decode_zstd_frame(wire, budget)
106
+ when Codec::ZDICT_MAGIC
107
+ install_recv_dict(wire)
108
+ nil
109
+ else
110
+ raise ProtocolError, "unrecognized preamble: #{head.unpack1('H*')}"
111
+ end
112
+ end
113
+
114
+
115
+ def decode_zstd_frame(wire, budget)
116
+ fcs = @codec.parse_frame_content_size(wire)
117
+ raise ProtocolError, "Zstd frame missing Frame_Content_Size" if fcs.nil?
118
+
119
+ if budget && fcs > budget
120
+ raise ProtocolError, "declared FCS #{fcs} exceeds limit #{budget}"
121
+ end
122
+
123
+ decompress_opts = budget ? { max_output_size: budget } : {}
124
+
125
+ if @recv_dict
126
+ @recv_dict.decompress(wire, **decompress_opts)
127
+ else
128
+ RZstd.decompress(wire, **decompress_opts)
129
+ end
130
+ rescue RZstd::DecompressError => e
131
+ raise ProtocolError, "decompression failed: #{e.message}"
132
+ rescue RZstd::MissingContentSizeError => e
133
+ raise ProtocolError, "Zstd frame missing Frame_Content_Size (#{e.message})"
134
+ rescue RZstd::OutputSizeLimitError => e
135
+ raise ProtocolError, "declared FCS exceeds limit (#{e.message})"
136
+ end
137
+
138
+
139
+ def install_recv_dict(wire)
140
+ if wire.bytesize < 8
141
+ raise ProtocolError, "dict frame too short"
142
+ end
143
+
144
+ if wire.bytesize > Codec::MAX_DICT_SIZE
145
+ raise ProtocolError, "dict exceeds #{Codec::MAX_DICT_SIZE} bytes"
146
+ end
147
+
148
+ @recv_dict = RZstd::Dictionary.new(wire.b)
149
+ end
150
+
151
+
152
+ def enforce_budget!(size, budget)
153
+ return if budget.nil?
154
+ return if size <= budget
155
+
156
+ raise ProtocolError, "decompressed message size exceeds maximum"
157
+ end
158
+
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+ require "io/stream"
6
+
7
+ module OMQ
8
+ module Transport
9
+ module ZstdTcp
10
+ SCHEME = "zstd+tcp"
11
+
12
+
13
+ # WeakKeyMap keyed by Engine — the Codec for a socket lives as long
14
+ # as the Engine (and therefore the socket) lives. When the socket is
15
+ # closed and the Engine is GC'd, the entry disappears automatically.
16
+ #
17
+ @codecs = ObjectSpace::WeakKeyMap.new
18
+
19
+
20
+ class << self
21
+ # Creates a bound zstd+tcp listener.
22
+ #
23
+ # @param endpoint [String] e.g. "zstd+tcp://127.0.0.1:5555"
24
+ # @param engine [Engine]
25
+ # @param level [Integer] Zstd compression level
26
+ # @param dict [String, nil] user-supplied dictionary bytes
27
+ # @return [Listener]
28
+ #
29
+ def listener(endpoint, engine, level: -3, dict: nil, **)
30
+ codec = codec_for(engine, level: level, dict: dict)
31
+
32
+ host, port = parse_endpoint(endpoint)
33
+ host = normalize_bind_host(host)
34
+ servers = ::Socket.tcp_server_sockets(host, port)
35
+
36
+ if servers.empty?
37
+ raise ::Socket::ResolutionError, "no addresses for #{host.inspect}"
38
+ end
39
+
40
+ actual_port = servers.first.local_address.ip_port
41
+ display_host = host || "*"
42
+ host_part = display_host.include?(":") ? "[#{display_host}]" : display_host
43
+ resolved = "#{SCHEME}://#{host_part}:#{actual_port}"
44
+
45
+ Listener.new(resolved, servers, actual_port, engine, codec)
46
+ end
47
+
48
+
49
+ # Creates a zstd+tcp dialer for an endpoint.
50
+ #
51
+ # @param endpoint [String] e.g. "zstd+tcp://127.0.0.1:5555"
52
+ # @param engine [Engine]
53
+ # @param level [Integer] Zstd compression level
54
+ # @param dict [String, nil] user-supplied dictionary bytes
55
+ # @return [Dialer]
56
+ #
57
+ def dialer(endpoint, engine, level: -3, dict: nil, **)
58
+ codec = codec_for(engine, level: level, dict: dict)
59
+ Dialer.new(endpoint, engine, codec)
60
+ end
61
+
62
+
63
+ def validate_endpoint!(endpoint)
64
+ host, _port = parse_endpoint(endpoint)
65
+ host = normalize_connect_host(host)
66
+ Addrinfo.getaddrinfo(host, nil, nil, :STREAM) if host
67
+ end
68
+
69
+
70
+ def parse_endpoint(endpoint)
71
+ uri = URI.parse(endpoint)
72
+ [uri.hostname, uri.port]
73
+ end
74
+
75
+
76
+ def normalize_bind_host(host)
77
+ return nil if host == "*"
78
+ host
79
+ end
80
+
81
+
82
+ def normalize_connect_host(host)
83
+ host == "*" ? "127.0.0.1" : host
84
+ end
85
+
86
+
87
+ def connect_timeout(options)
88
+ ri = options.reconnect_interval
89
+ ri.is_a?(Range) ? ri.end : [ri * 10, 30].min
90
+ end
91
+
92
+
93
+ private
94
+
95
+
96
+ # Returns the shared Codec for this engine (socket), creating one
97
+ # on first call. Stored in a WeakKeyMap keyed by engine — the
98
+ # entry is automatically removed when the engine is GC'd.
99
+ #
100
+ # Inside a Ractor the module-level @codecs ivar is inaccessible,
101
+ # so we fall back to creating a fresh Codec per call. This is
102
+ # fine: each Ractor worker owns a single socket with one endpoint,
103
+ # so there is nothing to share.
104
+ #
105
+ # @param engine [Engine]
106
+ # @param level [Integer]
107
+ # @param dict [String, nil]
108
+ # @return [Codec]
109
+ #
110
+ def codec_for(engine, level:, dict:)
111
+ @codecs[engine] ||= Codec.new level: level, dict: dict,
112
+ max_message_size: engine.options.max_message_size
113
+
114
+ rescue Ractor::IsolationError
115
+ Codec.new level: level, dict: dict,
116
+ max_message_size: engine.options.max_message_size
117
+ end
118
+ end
119
+
120
+
121
+ # A zstd+tcp dialer — stateful factory for outgoing connections.
122
+ #
123
+ # Holds the Codec (compression cache, training state, dict) and
124
+ # wraps new connections with {ZstdConnection}.
125
+ #
126
+ class Dialer
127
+ # @return [String] the endpoint this dialer connects to
128
+ #
129
+ attr_reader :endpoint
130
+
131
+
132
+ # @param endpoint [String]
133
+ # @param engine [Engine]
134
+ # @param codec [Codec]
135
+ #
136
+ def initialize(endpoint, engine, codec)
137
+ @endpoint = endpoint
138
+ @engine = engine
139
+ @codec = codec
140
+ end
141
+
142
+
143
+ # Establishes a TCP connection to the endpoint.
144
+ #
145
+ # @return [void]
146
+ #
147
+ def connect
148
+ host, port = ZstdTcp.parse_endpoint(@endpoint)
149
+ host = ZstdTcp.normalize_connect_host(host)
150
+ sock = ::Socket.tcp host, port, connect_timeout: ZstdTcp.connect_timeout(@engine.options)
151
+
152
+ TCP.apply_buffer_sizes sock, @engine.options
153
+
154
+ @engine.handle_connected IO::Stream::Buffered.wrap(sock), endpoint: @endpoint
155
+ rescue
156
+ sock&.close
157
+ raise
158
+ end
159
+
160
+
161
+ # Wraps a raw ZMTP connection with Zstd compression.
162
+ #
163
+ # @param conn [Protocol::ZMTP::Connection]
164
+ # @return [ZstdConnection]
165
+ #
166
+ def wrap_connection(conn)
167
+ ZstdConnection.new(conn, @codec)
168
+ end
169
+
170
+ end
171
+
172
+
173
+ # A bound zstd+tcp listener.
174
+ #
175
+ class Listener
176
+ # @return [String] resolved endpoint with actual port
177
+ #
178
+ attr_reader :endpoint
179
+
180
+
181
+ # @return [Integer] bound port
182
+ #
183
+ attr_reader :port
184
+
185
+
186
+ # @param endpoint [String] resolved endpoint URI
187
+ # @param servers [Array<Socket>]
188
+ # @param port [Integer] bound port number
189
+ # @param engine [Engine]
190
+ # @param codec [Codec]
191
+ #
192
+ def initialize(endpoint, servers, port, engine, codec)
193
+ @endpoint = endpoint
194
+ @servers = servers
195
+ @port = port
196
+ @engine = engine
197
+ @codec = codec
198
+ @tasks = []
199
+ end
200
+
201
+
202
+ # Wraps a raw ZMTP connection with Zstd compression.
203
+ #
204
+ # @param conn [Protocol::ZMTP::Connection]
205
+ # @return [ZstdConnection]
206
+ #
207
+ def wrap_connection(conn)
208
+ ZstdConnection.new(conn, @codec)
209
+ end
210
+
211
+
212
+ # Spawns accept loop tasks under +parent_task+.
213
+ #
214
+ # @param parent_task [Async::Task]
215
+ # @yieldparam io [IO::Stream::Buffered]
216
+ #
217
+ def start_accept_loops(parent_task, &on_accepted)
218
+ @tasks = @servers.map do |server|
219
+ parent_task.async(transient: true, annotation: "zstd+tcp accept #{@endpoint}") do
220
+ loop do
221
+ client, _addr = server.accept
222
+
223
+ Async::Task.current.defer_stop do
224
+ TCP.apply_buffer_sizes(client, @engine.options)
225
+
226
+ stream = IO::Stream::Buffered.wrap(client)
227
+
228
+ on_accepted.call stream
229
+ end
230
+ end
231
+ rescue Async::Stop
232
+ rescue IOError
233
+ ensure
234
+ server.close rescue nil
235
+ end
236
+ end
237
+ end
238
+
239
+
240
+ # Stops the listener and closes all server sockets.
241
+ #
242
+ # @return [void]
243
+ #
244
+ def stop
245
+ @tasks.each(&:stop)
246
+ @servers.each { |s| s.close rescue nil }
247
+ end
248
+
249
+ end
250
+
251
+ end
252
+ end
253
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ Zstd+TCP transport — adds zstd+tcp:// endpoint support.
4
+ #
5
+ # Usage:
6
+ # require "omq/zstd"
7
+ #
8
+ # push = OMQ::PUSH.new
9
+ # push.connect("zstd+tcp://127.0.0.1:5555", level: -3)
10
+
11
+ require "omq"
12
+ require "rzstd"
13
+
14
+ require_relative "zstd_tcp/codec"
15
+ require_relative "zstd_tcp/connection"
16
+ require_relative "zstd_tcp/transport"
17
+
18
+ module OMQ
19
+ module Transport
20
+ module ZstdTcp
21
+ class ProtocolError < StandardError; end
22
+ end
23
+ end
24
+ end
25
+
26
+ OMQ::Engine.transports["zstd+tcp"] = OMQ::Transport::ZstdTcp
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module Zstd
5
+ VERSION = "0.4.0"
6
+ end
7
+ end
data/lib/omq/zstd.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "zstd/version"
4
+ require_relative "transport/zstd_tcp"
metadata ADDED
@@ -0,0 +1,79 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omq-zstd
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.4.0
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: omq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.23'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.23'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rzstd
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ description: Adds zstd+tcp:// endpoint support to OMQ with per-frame Zstd compression,
41
+ bounded decompression, in-band dictionary shipping, and sender-side dictionary training.
42
+ email:
43
+ - paddor@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - DESIGN.md
49
+ - LICENSE
50
+ - README.md
51
+ - RFC.md
52
+ - lib/omq/transport/zstd_tcp.rb
53
+ - lib/omq/transport/zstd_tcp/codec.rb
54
+ - lib/omq/transport/zstd_tcp/connection.rb
55
+ - lib/omq/transport/zstd_tcp/transport.rb
56
+ - lib/omq/zstd.rb
57
+ - lib/omq/zstd/version.rb
58
+ homepage: https://github.com/paddor/omq-zstd
59
+ licenses:
60
+ - ISC
61
+ metadata: {}
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.3'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.6
77
+ specification_version: 4
78
+ summary: Zstd+TCP transport for OMQ
79
+ test_files: []