omq-lz4 0.3.0 → 0.3.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: 4217a939f757a07b0e3e2ad1a905d11ea3cd017e54baa2f059a75f304386bdab
4
- data.tar.gz: 1d0f4e4076e0cf02291b3f714fe20c721b8e05e0d4d17af4fbc8662e83897319
3
+ metadata.gz: 8b849f0045410a07f748583b8647d096110a7dbd632fcb9f3597e216c182cbdd
4
+ data.tar.gz: 94b0067640c5bef152f7381340b5e620ca1f2c66a42fc7b45de411c35f1ad2d1
5
5
  SHA512:
6
- metadata.gz: a963f09065ef7a019a8b766a00093e5ef84ba9489fed66d3524da5e8a0d8d956d27cd01534926ac1cb3f5c5088439da9e0db5c5f71c0b7b58b7b90db8ccfc8bf
7
- data.tar.gz: 86e7b5fe26c080a13a6b5c5c44b0e0a250bc45d650b2a9f76f4a80330209d6e24374b8a6412dd28118cb971619f56d3bede7a5ed937c91c5a80fd520ab17b5ac
6
+ metadata.gz: d5c76cf382c5b074d914f0f35cf02b6aef495dea590a6929c8fc41827e67cd07b1a40bec263c2b4ccc71f90510b467d37b638c009a9665a21a0ac6dbc9e154d9
7
+ data.tar.gz: 8ec8d5da7f5396197af9dfe5ebf903b25ab7463dc51d7f6415c859f02941367080f0adeaf308f4dbb73739c786936fb923e3a04941e3edf3f85bee219df8a2fa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## 0.3.2 - 2026-07-23
6
+
7
+ ### Changed
8
+
9
+ - Moved release source to the `zeromq/omq.rb` monorepo.
10
+ - Require `omq ~> 0.28` and Ruby >= 4.0.
11
+ - Require `lz4rip ~> 0.1.1`.
12
+
13
+ ## 0.3.1 (2026-05-28)
14
+
15
+ ### Changed
16
+
17
+ - `MIN_COMPRESS_WITH_DICT`: raised from 32 to 128. The previous value
18
+ was too aggressive; 128 leaves a safer margin above the measured
19
+ crossover.
20
+
3
21
  ## 0.3.0 (2026-05-11)
4
22
 
5
23
  ### Added
@@ -22,7 +40,7 @@
22
40
 
23
41
  ### Changed
24
42
 
25
- - use rlz4 ~> 0.5 (ported from lz4_flex to lz4_sys)
43
+ - use the Rust-backed LZ4 block codec with liblz4 bindings
26
44
 
27
45
  ## 0.1.0 (2026-04-23)
28
46
 
@@ -31,8 +49,8 @@
31
49
  - Gem skeleton: `omq-lz4.gemspec`, `lib/omq/lz4.rb`,
32
50
  `lib/omq/lz4/version.rb`, `lib/omq/lz4/errors.rb`, `Gemfile`,
33
51
  `Rakefile`, `.gitignore`, `LICENSE` (ISC), `README.md`, CI and
34
- release GitHub Actions workflows, minitest harness. Depends on
35
- `rlz4 ~> 0.3` (block API) and `omq ~> 0.23`.
52
+ release GitHub Actions workflows, minitest harness. Depends on a
53
+ Rust-backed LZ4 block API and `omq ~> 0.23`.
36
54
  - `require "omq/lz4"` succeeds and defines `OMQ::LZ4::VERSION` and
37
55
  `OMQ::LZ4::ProtocolError`. No transport behaviour yet — the
38
56
  `lz4+tcp://` scheme registration lands in a subsequent milestone.
@@ -128,7 +146,7 @@
128
146
  `MIN_COMPRESS_*` thresholds below.
129
147
  - `head_to_head.rb` — side-by-side microbenchmark (pure codec)
130
148
  and transport throughput of `omq-lz4` vs `omq-zstd`, with and
131
- without a shared dictionary. Requires `omq-zstd` + `rzstd` in
149
+ without a shared dictionary. Requires `omq-zstd` + `zrip` in
132
150
  the Gemfile's `:bench` group. Numbers land in README's
133
151
  "Head-to-head vs omq-zstd" section.
134
152
  - **Compression thresholds tuned** from the sweep:
data/README.md CHANGED
@@ -2,21 +2,21 @@
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/omq-lz4?color=e9573f)](https://rubygems.org/gems/omq-lz4)
4
4
  [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
5
- [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
5
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%204.0-CC342D?logo=ruby&logoColor=white)](https://www.ruby-lang.org)
6
6
 
7
- > [RFC.md](RFC.md) for the wire-format spec and
8
- > [CHANGELOG.md](CHANGELOG.md) for what's in.
7
+ LZ4-compressed TCP transport for [OMQ](https://github.com/zeromq/omq.rb).
8
+ Pick `lz4+tcp://` instead of `tcp://` when you want cheap per-message
9
+ compression with a small per-connection footprint. `omq-lz4` is the
10
+ preferred compressed TCP transport. `omq-zstd` is experimental.
9
11
 
10
- LZ4-compressed TCP transport for [OMQ](https://github.com/paddor/omq),
11
- complementary to [`omq-zstd`](https://github.com/paddor/omq-zstd).
12
- Pick `lz4+tcp://` instead of `tcp://` or `zstd+tcp://` when you want
13
- cheap per-message compression with a small per-connection footprint.
12
+ See [RFC.md](RFC.md) for the wire-format specification and
13
+ [CHANGELOG.md](CHANGELOG.md) for release history.
14
14
 
15
- ## When to pick `lz4+tcp://` over `zstd+tcp://`
15
+ ## Why `lz4+tcp://`
16
16
 
17
17
  LZ4 has no entropy stage (no Huffman, no FSE), ~16 KiB of encoder state
18
18
  per connection, and trades a **worse compression ratio** for
19
- **~4–8× faster encode** and **~ less memory per connection**.
19
+ **~4-8x faster encode** and **~3x less memory per connection**.
20
20
 
21
21
  | | `zstd+tcp://` | `lz4+tcp://` |
22
22
  |---|---|---|
@@ -25,12 +25,14 @@ per connection, and trades a **worse compression ratio** for
25
25
  | Memory per connection | ~256 KiB | ~16 KiB + dict |
26
26
  | Ratio, 1 KiB JSON no dict | ~45% | ~65% |
27
27
  | Ratio, 1 KiB JSON with dict | ~20% | ~35% |
28
- | Auto-trained dictionaries | | (user-supplied only) |
28
+ | Auto-trained dictionaries | yes | yes |
29
29
 
30
30
  Pick `omq-lz4` for CPU- or memory-scarce deployments (edge gateways,
31
31
  IoT concentrators, high-fanout scenarios where per-connection state
32
- matters more than ratio). Pick `omq-zstd` for bandwidth-bound
33
- deployments where CPU is cheap.
32
+ matters more than ratio). It is also the default choice for bandwidth
33
+ bound deployments now that lz4rip supports dictionary training.
34
+ `omq-zstd` remains useful for experiments where the tighter zstd ratio
35
+ is worth extra transport complexity.
34
36
 
35
37
  ## Install
36
38
 
@@ -60,27 +62,30 @@ pull.receive # => ["hello, compressed world"]
60
62
  ```
61
63
 
62
64
  Both peers must use `lz4+tcp://`. A `tcp://` peer cannot talk to an
63
- `lz4+tcp://` peer they speak different transports.
65
+ `lz4+tcp://` peer. They speak different transports.
64
66
 
65
67
  ### Dictionary compression
66
68
 
67
69
  Small messages don't compress well on their own. A shared dictionary
68
- gives 2–5× better ratios on payloads with a common prefix. Supply a
69
- user-trained dictionary (LZ4 has no auto-training — use `omq-zstd`
70
- for that):
70
+ gives 2-5x better ratios on payloads with a common prefix. Supply a
71
+ user-trained dictionary:
71
72
 
72
73
  ```ruby
73
74
  dict = File.binread("schema.dict")
74
75
  push.connect("lz4+tcp://127.0.0.1:5555", dict: dict)
75
76
  ```
76
77
 
78
+ Or let lz4rip train one from early traffic:
79
+
80
+ ```ruby
81
+ push.connect("lz4+tcp://127.0.0.1:5555", auto_dict: true)
82
+ ```
83
+
77
84
  The sender ships the dictionary to the receiver in-band, prefixed
78
85
  with the dictionary sentinel (`4C 5A 34 44`, "LZ4D" in ASCII), on
79
86
  the first outgoing message. The receiver installs the dictionary
80
87
  and decompresses subsequent messages against it. Dictionary size
81
- is capped at **8 KiB** tighter than `omq-zstd`'s 64 KiB cap, to
82
- let constrained peers accept shipments without allocating tens of
83
- KB of scratch.
88
+ is capped at **8 KiB** (same cap as `omq-zstd`).
84
89
 
85
90
  ### Compression thresholds
86
91
 
@@ -89,7 +94,7 @@ To avoid pessimizing tiny frames, the sender skips compression below:
89
94
  | Mode | Threshold |
90
95
  |-----------------|-----------|
91
96
  | No dictionary | 512 B |
92
- | With dictionary | 32 B |
97
+ | With dictionary | 128 B |
93
98
 
94
99
  Below the threshold the part is sent uncompressed (4-byte zero
95
100
  sentinel + plaintext).
@@ -99,14 +104,54 @@ sentinel + plaintext).
99
104
  The receiver bounds decompression by the socket's `max_message_size`
100
105
  (the same knob you'd use on a plain `tcp://` socket). It caps the
101
106
  **total decompressed size of all parts in a single message**. A peer
102
- attempting to send an over-budget message drops the connection
107
+ attempting to send an over-budget message drops the connection.
103
108
  `OMQ::SocketDeadError` surfaces on the next `receive`.
104
109
 
105
- Independent of that, the dictionary itself is capped at 8 KiB; a
110
+ Independent of that, the dictionary itself is capped at 8 KiB. A
106
111
  larger shipment drops the connection.
107
112
 
108
- See the plan roadmap ([../OMQ-LZ4.plan](../OMQ-LZ4.plan)) for
109
- history and open questions.
113
+ ## Wire format
114
+
115
+ Every post-handshake ZMTP message part starts with a 4-byte sentinel:
116
+
117
+ | Sentinel (hex) | ASCII | Meaning |
118
+ |---|---|---|
119
+ | `00 00 00 00` | (none) | Uncompressed plaintext |
120
+ | `4C 5A 34 42` | `LZ4B` | LZ4-compressed single block |
121
+ | `4C 5A 34 4D` | `LZ4M` | LZ4-compressed multi-block |
122
+ | `4C 5A 34 44` | `LZ4D` | Dictionary shipment |
123
+
124
+ **Single-block** (`LZ4B`): `sentinel (4) || decompressed_size u64 LE (8) || LZ4 block bytes`.
125
+ 12-byte envelope. Raw LZ4 block format (no magic, no descriptor, no
126
+ checksum). `decompressed_size` is required because LZ4 block format
127
+ carries no length prefix. The receiver pre-sizes its output buffer.
128
+
129
+ **Multi-block** (`LZ4M`): same header, followed by a sequence of
130
+ `u32 LE compressed_block_len || LZ4 block bytes` pairs. Each block
131
+ decompresses independently at up to 1 GiB. Used for parts exceeding
132
+ the single-block size cap.
133
+
134
+ **Dictionary shipment** (`LZ4D`): `sentinel (4) || dict bytes (1..8192)`.
135
+ Single-part ZMTP message consumed by the transport, not delivered
136
+ to the application. At most one per direction per connection.
137
+
138
+ Any other leading 4 bytes close the connection.
139
+
140
+ ## Constants
141
+
142
+ | Constant | Value |
143
+ |---|---|
144
+ | Scheme | `lz4+tcp` |
145
+ | Uncompressed sentinel | `00 00 00 00` |
146
+ | Single-block sentinel | `4C 5A 34 42` (`LZ4B`) |
147
+ | Multi-block sentinel | `4C 5A 34 4D` (`LZ4M`) |
148
+ | Dictionary sentinel | `4C 5A 34 44` (`LZ4D`) |
149
+ | LZ4M block size | 1 GiB (`0x40000000`) |
150
+ | Max dictionary size | 8 KiB |
151
+ | Min compress, no dict | 512 B |
152
+ | Min compress, with dict | 128 B |
153
+ | LZ4B envelope | 12 B (4 sentinel + 8 size) |
154
+ | Uncompressed envelope | 4 B (sentinel only) |
110
155
 
111
156
  ## Performance
112
157
 
@@ -123,7 +168,7 @@ Lorem ipsum prefix) input.
123
168
  | 16 KiB | ~3.2 µs | ~2.4 µs | ~3.9 µs | ~3.0 µs |
124
169
  | 1 MiB | ~89 µs | ~87 µs | ~173 µs | ~303 µs |
125
170
 
126
- **End-to-end PUSH PULL over `lz4+tcp://` (loopback):**
171
+ **End-to-end PUSH -> PULL over `lz4+tcp://` (loopback):**
127
172
 
128
173
  | Message size | Throughput |
129
174
  |--------------|-----------:|
@@ -141,8 +186,8 @@ OMQ_DEV=1 bundle exec ruby --yjit bench/head_to_head.rb # lz4 vs zstd
141
186
 
142
187
  ### Head-to-head vs `omq-zstd` and plain `tcp`
143
188
 
144
- End-to-end PUSH PULL throughput, Ruby 4.0 + YJIT. Input:
145
- UUID-sprinkled Lorem ipsum a fresh UUID between each Lorem
189
+ End-to-end PUSH -> PULL throughput, Ruby 4.0 + YJIT. Input:
190
+ UUID-sprinkled Lorem ipsum, a fresh UUID between each Lorem
146
191
  paragraph. Approximates realistic workloads where a schema
147
192
  repeats but values vary (event logs, protobuf records, JSON
148
193
  events), so a fraction of every message is mandatorily
@@ -160,27 +205,27 @@ across three bandwidth regimes.
160
205
  | Link | Metric | tcp | lz4+tcp | zstd -3 | zstd 3 |
161
206
  |---------------------|----------|------:|--------:|--------:|-------:|
162
207
  | **100 Mbit** | plain | 11.8 | 105 | 114 | **197**|
163
- | (cap 12 MiB/s) | wire | 11.8 | 12 | 12 | 12 |
164
- | | speedup | 1.00× | 8.89× | 9.70× |**16.74×**|
208
+ | (cap ~12 MiB/s) | wire | 11.8 | 12 | 12 | 12 |
209
+ | | speedup | 1.00x | 8.89x | 9.70x |**16.74x**|
165
210
  | **1 Gbit** | plain | 117 | 794 | **900** | 603 |
166
- | (cap 125 MiB/s) | wire | 117 | 93 | 94 | 36 |
167
- | | speedup | 1.00× | 6.81× |**7.73×**| 5.17× |
211
+ | (cap ~125 MiB/s) | wire | 117 | 93 | 94 | 36 |
212
+ | | speedup | 1.00x | 6.81x |**7.73x**| 5.17x |
168
213
  | **Unlimited loopback** | plain | **1 064** | 869 | 972 | 626 |
169
214
  | (kernel-copy-bound) | wire | 1 064 | 99 | 101 | 37 |
170
- | | speedup | 1.00× | 0.82× | 0.91× | 0.59× |
215
+ | | speedup | 1.00x | 0.82x | 0.91x | 0.59x |
171
216
 
172
217
  Three regimes visible:
173
218
 
174
- - **100 Mbit** all compressed transports saturate wire at
175
- ~12 MiB/s. Plaintext = wire-cap × (1 / compression-ratio). The
219
+ - **100 Mbit**: all compressed transports saturate wire at
220
+ ~12 MiB/s. Plaintext = wire-cap x (1 / compression-ratio). The
176
221
  tighter the ratio, the bigger the win: `zstd 3`'s 3% wire ratio
177
- translates to a **~17× throughput multiplier** over plain tcp.
178
- - **1 Gbit** compressed transports shift from wire-saturated to
179
- CPU-limited. `zstd -3` reaches ~75% of wire cap; `zstd 3` only
222
+ translates to a **~17x throughput multiplier** over plain tcp.
223
+ - **1 Gbit**: compressed transports shift from wire-saturated to
224
+ CPU-limited. `zstd -3` reaches ~75% of wire cap. `zstd 3` only
180
225
  29% (deep CPU-bound). Both beat plain tcp (which is pinned at
181
- the wire cap) by **6–8×**. `zstd 3`'s tighter wire no longer
182
- helps there's no wire saturation to trade CPU for.
183
- - **Unlimited loopback** no wire cap. All three are
226
+ the wire cap) by **6-8x**. `zstd 3`'s tighter wire no longer
227
+ helps. There is no wire saturation to trade CPU for.
228
+ - **Unlimited loopback**: no wire cap. All three are
184
229
  CPU-limited. Plain tcp doesn't pay compression CPU, so **skip
185
230
  compression on loopback**.
186
231
 
@@ -196,25 +241,26 @@ Or use a `veth` pair in a network namespace so shaping doesn't
196
241
  touch your host's real loopback (see `tc-netem(8)`, `ip-netns(8)`).
197
242
 
198
243
  Full sweeps (8 sizes from 256 B to 512 KiB) for each regime live
199
- in `bench/head_to_head.rb` output run it yourself; the
244
+ in `bench/head_to_head.rb` output. Run it yourself. The
200
245
  headline numbers above are stable across repeats but small sizes
201
246
  and very large sizes vary a bit run-to-run.
202
247
 
203
248
  **Takeaway:**
204
249
 
205
250
  - Pick **`lz4+tcp://`** for bandwidth-limited links (any real
206
- network even 1 Gbit LAN). 6–9× throughput multiplier over
251
+ network, even 1 Gbit LAN). 6-9x throughput multiplier over
207
252
  plain `tcp`, minimal memory (~16 KiB/connection), modest CPU.
208
- Ties or beats `zstd -3` at 1 Gbit; loses the ratio race to
253
+ Ties or beats `zstd -3` at 1 Gbit. Loses the ratio race to
209
254
  `zstd 3` at 100 Mbit and below.
210
- - Pick **`zstd+tcp://` (level 3)** when the wire is the
211
- precious resource (100 Mbit links, WAN, or you're paying for
212
- egress). **~17× throughput multiplier at 100 Mbit** for 128 KiB
213
- messages is hard to argue with.
255
+ - Pick **`zstd+tcp://` (level >= 3)** when the wire is the
256
+ precious resource (100 Mbit links or slower, WAN, or you're
257
+ paying for egress). **~17x throughput multiplier at 100 Mbit**
258
+ for 128 KiB messages is hard to argue with. Treat this as
259
+ experimental in Ruby OMQ.
214
260
  - Pick **plain `tcp://`** when the link is *not* the bottleneck
215
261
  (localhost IPC, loopback, datacenter-fast inter-host
216
262
  connections where the bandwidth ceiling is above the CPU's
217
- compress/decompress speed typically 10+ Gbit), or when the
263
+ compress/decompress speed, typically 10+ Gbit), or when the
218
264
  payload is already high-entropy (encrypted, already compressed,
219
265
  random binary) and compression only adds overhead.
220
266
 
data/lib/omq/lz4/codec.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "rlz4"
3
+ require "lz4rip"
4
4
 
5
5
  require_relative "errors"
6
6
 
@@ -41,7 +41,7 @@ module OMQ
41
41
  # passthrough anyway. Below the threshold, `encode_part` emits
42
42
  # UNCOMPRESSED directly without touching the compressor.
43
43
  MIN_COMPRESS_NO_DICT = 512
44
- MIN_COMPRESS_WITH_DICT = 32
44
+ MIN_COMPRESS_WITH_DICT = 128
45
45
 
46
46
  # Maximum dictionary size on the wire. A policy choice, not a
47
47
  # protocol limit; tight enough that constrained peers can accept
@@ -62,7 +62,7 @@ module OMQ
62
62
  # back to passthrough when compression wouldn't save at least the
63
63
  # envelope overhead.
64
64
  #
65
- # `block_codec` is an RLZ4::BlockCodec, optionally constructed with
65
+ # `block_codec` is an Lz4rip::BlockCodec, optionally constructed with
66
66
  # `dict: bytes`. The codec's dict presence is detected via
67
67
  # `#has_dict?` to pick the min-size threshold.
68
68
  #
@@ -120,7 +120,7 @@ module OMQ
120
120
  block = wire_bytes.byteslice(12, wire_bytes.bytesize - 12)
121
121
  begin
122
122
  block_codec.decompress(block, decompressed_size: decompressed_size)
123
- rescue RLZ4::DecompressError => e
123
+ rescue Lz4rip::DecompressError => e
124
124
  raise ProtocolError, "LZ4B decode failed: #{e.message}"
125
125
  end
126
126
  when LZ4M_SENTINEL
@@ -213,7 +213,7 @@ module OMQ
213
213
  block_decompressed_size = [block_size, remaining].min
214
214
  begin
215
215
  output << block_codec.decompress(block_data, decompressed_size: block_decompressed_size)
216
- rescue RLZ4::DecompressError => e
216
+ rescue Lz4rip::DecompressError => e
217
217
  raise ProtocolError, "LZ4M block decode failed: #{e.message}"
218
218
  end
219
219
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OMQ
4
4
  module LZ4
5
- VERSION = "0.3.0"
5
+ VERSION = "0.3.2"
6
6
  end
7
7
  end
@@ -12,30 +12,44 @@ module OMQ
12
12
  # receive_message route through compression, but everything else
13
13
  # (write_command, close, etc.) passes through untouched.
14
14
  class Lz4Connection < SimpleDelegator
15
+ DEFAULT_DICT_CAPACITY = 2048
16
+ DEFAULT_TRAIN_TRIGGER = 100
17
+
15
18
  # @return [Integer, nil] wire bytesize of the last received
16
19
  # message (sum across parts, compressed sentinel included).
17
20
  attr_reader :last_wire_size_in
18
21
 
19
- def initialize(conn, send_dict_bytes:, max_message_size:)
22
+ def initialize(conn, send_dict_bytes:, max_message_size:, auto_dict: nil)
20
23
  super(conn)
21
- @max_message_size = max_message_size
22
-
23
- # Per-direction state. The send codec is built at construction
24
- # with the send-side dictionary (if any) baked in. The receive
25
- # codec starts dict-less; if/when a dict shipment arrives on
26
- # this direction, it is replaced with a dict-bound codec.
27
- @send_dict_bytes = send_dict_bytes&.b
28
- @send_codec = build_block_codec(@send_dict_bytes)
29
- @send_dict_shipped = @send_dict_bytes.nil? # nothing to ship => "already shipped"
30
-
31
- @recv_codec = build_block_codec(nil)
32
- @recv_dict_bytes = nil
24
+ @max_message_size = max_message_size
33
25
 
26
+ @recv_codec = build_block_codec(nil)
27
+ @recv_dict_bytes = nil
34
28
  @last_wire_size_in = nil
29
+
30
+ if send_dict_bytes
31
+ @send_dict_bytes = send_dict_bytes.b
32
+ @send_codec = build_block_codec(@send_dict_bytes)
33
+ @send_dict_shipped = false
34
+ @trainer = nil
35
+ elsif auto_dict
36
+ @send_dict_bytes = nil
37
+ @send_codec = build_block_codec(nil)
38
+ @send_dict_shipped = true
39
+ @trainer = Lz4rip::DictTrainer.new(auto_dict[:capacity])
40
+ @train_trigger = auto_dict[:trigger]
41
+ @train_msg_count = 0
42
+ else
43
+ @send_dict_bytes = nil
44
+ @send_codec = build_block_codec(nil)
45
+ @send_dict_shipped = true
46
+ @trainer = nil
47
+ end
35
48
  end
36
49
 
37
50
 
38
51
  def send_message(parts)
52
+ maybe_train!(parts)
39
53
  wire = encode_parts(parts)
40
54
  ship_send_dict!
41
55
  __getobj__.send_message(wire)
@@ -43,6 +57,7 @@ module OMQ
43
57
 
44
58
 
45
59
  def write_message(parts)
60
+ maybe_train!(parts)
46
61
  wire = encode_parts(parts)
47
62
  ship_send_dict!
48
63
  __getobj__.write_message(wire)
@@ -50,6 +65,7 @@ module OMQ
50
65
 
51
66
 
52
67
  def write_messages(messages)
68
+ messages.each { |parts| maybe_train!(parts) }
53
69
  wires = messages.map { |parts| encode_parts(parts) }
54
70
  ship_send_dict!
55
71
  __getobj__.write_messages(wires)
@@ -76,10 +92,36 @@ module OMQ
76
92
 
77
93
  def build_block_codec(dict_bytes)
78
94
  if dict_bytes
79
- RLZ4::BlockCodec.new(dict: dict_bytes)
95
+ Lz4rip::BlockCodec.new(dict: dict_bytes)
80
96
  else
81
- RLZ4::BlockCodec.new
97
+ Lz4rip::BlockCodec.new
98
+ end
99
+ end
100
+
101
+
102
+ def maybe_train!(parts)
103
+ return unless @trainer
104
+
105
+ parts.each do |pt|
106
+ bytes = pt.is_a?(String) && pt.encoding == Encoding::BINARY ? pt : pt.to_s.b
107
+ @trainer.add_sample(bytes)
82
108
  end
109
+ @train_msg_count += 1
110
+ return if @train_msg_count < @train_trigger
111
+
112
+ finish_training!
113
+ end
114
+
115
+
116
+ def finish_training!
117
+ dict_bytes = @trainer.train
118
+ @trainer = nil
119
+
120
+ return if dict_bytes.empty?
121
+
122
+ @send_dict_bytes = dict_bytes.b.freeze
123
+ @send_codec = build_block_codec(@send_dict_bytes)
124
+ @send_dict_shipped = false
83
125
  end
84
126
 
85
127
 
@@ -137,9 +179,9 @@ module OMQ
137
179
 
138
180
  dict_bytes = LZ4::Codec.decode_dict_shipment(wire)
139
181
  # Replace the no-dict recv codec with a dict-bound one. The
140
- # old codec is GC'd. Per rlz4: "fresh codec when dict is
141
- # needed" BlockCodec's dict is a permanent property.
142
- @recv_codec = RLZ4::BlockCodec.new(dict: dict_bytes)
182
+ # old codec is GC'd. Lz4rip treats dict as a permanent codec
183
+ # property, so install-time is always a fresh build.
184
+ @recv_codec = Lz4rip::BlockCodec.new(dict: dict_bytes)
143
185
  @recv_dict_bytes = dict_bytes
144
186
  end
145
187
 
@@ -29,9 +29,15 @@ module OMQ
29
29
  # ship on the first outgoing message. If nil, no dict is
30
30
  # shipped (payloads compress without dict or go plaintext
31
31
  # below the min-size threshold).
32
+ # @param auto_dict [true, Hash, nil] enable automatic dictionary
33
+ # training from early traffic (RFC §7.6). Pass `true` for
34
+ # defaults (capacity=2048, trigger=100) or a Hash with
35
+ # `:capacity` and/or `:trigger` keys. Mutually exclusive
36
+ # with `dict:`.
32
37
  # @return [Listener]
33
- def listener(endpoint, engine, dict: nil, **)
38
+ def listener(endpoint, engine, dict: nil, auto_dict: nil, **)
34
39
  validate_dict!(dict)
40
+ validate_auto_dict!(auto_dict, dict)
35
41
 
36
42
  host, port = parse_endpoint(endpoint)
37
43
  lookup_host = normalize_bind_host(host)
@@ -46,7 +52,7 @@ module OMQ
46
52
  host_part = display_host.include?(":") ? "[#{display_host}]" : display_host
47
53
  resolved = "#{SCHEME}://#{host_part}:#{actual_port}"
48
54
 
49
- Listener.new(resolved, servers, actual_port, engine, dict&.b)
55
+ Listener.new(resolved, servers, actual_port, engine, dict&.b, normalize_auto_dict(auto_dict))
50
56
  end
51
57
 
52
58
 
@@ -55,10 +61,13 @@ module OMQ
55
61
  # @param endpoint [String]
56
62
  # @param engine [OMQ::Engine]
57
63
  # @param dict [String, nil] user-supplied dictionary bytes.
64
+ # @param auto_dict [true, Hash, nil] enable automatic dictionary
65
+ # training. See `listener` for details.
58
66
  # @return [Dialer]
59
- def dialer(endpoint, engine, dict: nil, **)
67
+ def dialer(endpoint, engine, dict: nil, auto_dict: nil, **)
60
68
  validate_dict!(dict)
61
- Dialer.new(endpoint, engine, dict&.b)
69
+ validate_auto_dict!(auto_dict, dict)
70
+ Dialer.new(endpoint, engine, dict&.b, normalize_auto_dict(auto_dict))
62
71
  end
63
72
 
64
73
 
@@ -111,6 +120,40 @@ module OMQ
111
120
  raise LZ4::ProtocolError,
112
121
  "dict size #{size} out of range [1, #{LZ4::Codec::MAX_DICT_SIZE}]"
113
122
  end
123
+
124
+
125
+ def validate_auto_dict!(auto_dict, dict)
126
+ return unless auto_dict
127
+
128
+ if dict
129
+ raise ArgumentError, "cannot combine auto_dict: and dict:"
130
+ end
131
+
132
+ return if auto_dict == true
133
+
134
+ unless auto_dict.is_a?(Hash)
135
+ raise TypeError, "auto_dict: must be true or a Hash; got #{auto_dict.class}"
136
+ end
137
+
138
+ cap = auto_dict[:capacity]
139
+ if cap && (cap < 1 || cap > LZ4::Codec::MAX_DICT_SIZE)
140
+ raise ArgumentError,
141
+ "auto_dict capacity #{cap} out of range [1, #{LZ4::Codec::MAX_DICT_SIZE}]"
142
+ end
143
+ end
144
+
145
+
146
+ def normalize_auto_dict(auto_dict)
147
+ return unless auto_dict
148
+
149
+ capacity = Lz4Connection::DEFAULT_DICT_CAPACITY
150
+ trigger = Lz4Connection::DEFAULT_TRAIN_TRIGGER
151
+ if auto_dict.is_a?(Hash)
152
+ capacity = auto_dict[:capacity] if auto_dict[:capacity]
153
+ trigger = auto_dict[:trigger] if auto_dict[:trigger]
154
+ end
155
+ { capacity: capacity, trigger: trigger }.freeze
156
+ end
114
157
  end
115
158
 
116
159
 
@@ -118,10 +161,11 @@ module OMQ
118
161
  class Dialer
119
162
  attr_reader :endpoint
120
163
 
121
- def initialize(endpoint, engine, dict_bytes)
122
- @endpoint = endpoint
123
- @engine = engine
164
+ def initialize(endpoint, engine, dict_bytes, auto_dict)
165
+ @endpoint = endpoint
166
+ @engine = engine
124
167
  @dict_bytes = dict_bytes
168
+ @auto_dict = auto_dict
125
169
  end
126
170
 
127
171
 
@@ -144,6 +188,7 @@ module OMQ
144
188
  conn,
145
189
  send_dict_bytes: @dict_bytes,
146
190
  max_message_size: @engine.options.max_message_size,
191
+ auto_dict: @auto_dict,
147
192
  )
148
193
  end
149
194
  end
@@ -153,13 +198,14 @@ module OMQ
153
198
  class Listener
154
199
  attr_reader :endpoint, :port
155
200
 
156
- def initialize(endpoint, servers, port, engine, dict_bytes)
157
- @endpoint = endpoint
158
- @servers = servers
159
- @port = port
160
- @engine = engine
201
+ def initialize(endpoint, servers, port, engine, dict_bytes, auto_dict)
202
+ @endpoint = endpoint
203
+ @servers = servers
204
+ @port = port
205
+ @engine = engine
161
206
  @dict_bytes = dict_bytes
162
- @tasks = []
207
+ @auto_dict = auto_dict
208
+ @tasks = []
163
209
  end
164
210
 
165
211
 
@@ -168,6 +214,7 @@ module OMQ
168
214
  conn,
169
215
  send_dict_bytes: @dict_bytes,
170
216
  max_message_size: @engine.options.max_message_size,
217
+ auto_dict: @auto_dict,
171
218
  )
172
219
  end
173
220
 
@@ -11,7 +11,7 @@
11
11
  # push.connect("lz4+tcp://127.0.0.1:5555")
12
12
 
13
13
  require "omq"
14
- require "rlz4"
14
+ require "lz4rip"
15
15
 
16
16
  require_relative "../lz4/errors"
17
17
  require_relative "../lz4/codec"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omq-lz4
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrik Wenger
@@ -15,31 +15,31 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.23'
18
+ version: '0.28'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.23'
25
+ version: '0.28'
26
26
  - !ruby/object:Gem::Dependency
27
- name: rlz4
27
+ name: lz4rip
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.5'
32
+ version: 0.1.1
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.5'
40
- description: 'Adds lz4+tcp:// endpoint support to OMQ with per-part LZ4 block-format
41
- compression, bounded decompression, and in-band dictionary shipping. Complementary
42
- to omq-zstd: worse ratio, far faster encode, far smaller per-connection footprint.'
39
+ version: 0.1.1
40
+ description: Adds lz4+tcp:// endpoint support to OMQ with per-part LZ4 block-format
41
+ compression, bounded decompression, in-band dictionary shipping, and automatic dictionary
42
+ training.
43
43
  email:
44
44
  - paddor@gmail.com
45
45
  executables: []
@@ -56,7 +56,7 @@ files:
56
56
  - lib/omq/transport/lz4_tcp.rb
57
57
  - lib/omq/transport/lz4_tcp/connection.rb
58
58
  - lib/omq/transport/lz4_tcp/transport.rb
59
- homepage: https://github.com/paddor/omq-lz4
59
+ homepage: https://github.com/zeromq/omq.rb/tree/main/gems/omq-lz4
60
60
  licenses:
61
61
  - ISC
62
62
  metadata: {}
@@ -67,14 +67,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
67
67
  requirements:
68
68
  - - ">="
69
69
  - !ruby/object:Gem::Version
70
- version: '3.3'
70
+ version: '4.0'
71
71
  required_rubygems_version: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
75
  version: '0'
76
76
  requirements: []
77
- rubygems_version: 4.0.6
77
+ rubygems_version: 4.0.16
78
78
  specification_version: 4
79
79
  summary: LZ4+TCP transport for OMQ
80
80
  test_files: []