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 +4 -4
- data/CHANGELOG.md +22 -4
- data/README.md +94 -48
- data/lib/omq/lz4/codec.rb +5 -5
- data/lib/omq/lz4/version.rb +1 -1
- data/lib/omq/transport/lz4_tcp/connection.rb +60 -18
- data/lib/omq/transport/lz4_tcp/transport.rb +60 -13
- data/lib/omq/transport/lz4_tcp.rb +1 -1
- metadata +12 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b849f0045410a07f748583b8647d096110a7dbd632fcb9f3597e216c182cbdd
|
|
4
|
+
data.tar.gz: 94b0067640c5bef152f7381340b5e620ca1f2c66a42fc7b45de411c35f1ad2d1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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` + `
|
|
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
|
[](https://rubygems.org/gems/omq-lz4)
|
|
4
4
|
[](LICENSE)
|
|
5
|
-
[](https://www.ruby-lang.org)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
##
|
|
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
|
|
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 |
|
|
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).
|
|
33
|
-
deployments
|
|
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
|
|
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
|
|
69
|
-
user-trained dictionary
|
|
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**
|
|
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 |
|
|
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
|
|
110
|
+
Independent of that, the dictionary itself is capped at 8 KiB. A
|
|
106
111
|
larger shipment drops the connection.
|
|
107
112
|
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
145
|
-
UUID-sprinkled Lorem ipsum
|
|
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
|
|
164
|
-
| | speedup | 1.
|
|
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
|
|
167
|
-
| | speedup | 1.
|
|
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.
|
|
215
|
+
| | speedup | 1.00x | 0.82x | 0.91x | 0.59x |
|
|
171
216
|
|
|
172
217
|
Three regimes visible:
|
|
173
218
|
|
|
174
|
-
- **100 Mbit
|
|
175
|
-
~12 MiB/s. Plaintext = wire-cap
|
|
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 **~
|
|
178
|
-
- **1 Gbit
|
|
179
|
-
CPU-limited. `zstd -3` reaches ~75% of wire cap
|
|
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
|
|
182
|
-
helps
|
|
183
|
-
- **Unlimited loopback
|
|
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
|
|
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
|
|
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
|
|
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
|
|
211
|
-
precious resource (
|
|
212
|
-
egress). **~
|
|
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
|
|
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 "
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
216
|
+
rescue Lz4rip::DecompressError => e
|
|
217
217
|
raise ProtocolError, "LZ4M block decode failed: #{e.message}"
|
|
218
218
|
end
|
|
219
219
|
|
data/lib/omq/lz4/version.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
95
|
+
Lz4rip::BlockCodec.new(dict: dict_bytes)
|
|
80
96
|
else
|
|
81
|
-
|
|
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.
|
|
141
|
-
#
|
|
142
|
-
@recv_codec =
|
|
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
|
-
|
|
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
|
|
123
|
-
@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
|
|
158
|
-
@servers
|
|
159
|
-
@port
|
|
160
|
-
@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
|
-
@
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
25
|
+
version: '0.28'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
|
-
name:
|
|
27
|
+
name: lz4rip
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version:
|
|
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:
|
|
40
|
-
description:
|
|
41
|
-
compression, bounded decompression,
|
|
42
|
-
|
|
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/
|
|
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: '
|
|
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.
|
|
77
|
+
rubygems_version: 4.0.16
|
|
78
78
|
specification_version: 4
|
|
79
79
|
summary: LZ4+TCP transport for OMQ
|
|
80
80
|
test_files: []
|