omq-lz4 0.2.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +123 -0
- data/LICENSE +15 -0
- data/README.md +231 -0
- data/lib/omq/lz4/codec.rb +185 -0
- data/lib/omq/lz4/errors.rb +13 -0
- data/lib/omq/lz4/version.rb +7 -0
- data/lib/omq/lz4.rb +17 -0
- data/lib/omq/transport/lz4_tcp/connection.rb +149 -0
- data/lib/omq/transport/lz4_tcp/transport.rb +207 -0
- data/lib/omq/transport/lz4_tcp.rb +18 -0
- metadata +80 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ade7fc716054707c2e05bade867b01333708de30df98e3acb524f8da1a7d98d4
|
|
4
|
+
data.tar.gz: 7ae5fade7f0d88eed7f6f7f8875373a044127098d596b9f6bae5554d585b1e90
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 59afc48b58c8c1efac0973bffa702cac3958b0150b4eb51fe9dec82dacc1c9735446d83e58ac6963f0302e13b26a0890b1eaeff5c0d7d846830c561775a848f4
|
|
7
|
+
data.tar.gz: 17c66cce9f79f1a375a8614e3aa3cc45071277432521ec96d446daede230b1e6e1a71003b88062345c48550c128e524aa10160d6a06c41e6c7597309e138d5f0
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-05-04)
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- use rlz4 ~> 0.5 (ported from lz4_flex to lz4_sys)
|
|
8
|
+
|
|
9
|
+
## 0.1.0 (2026-04-23)
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Gem skeleton: `omq-lz4.gemspec`, `lib/omq/lz4.rb`,
|
|
14
|
+
`lib/omq/lz4/version.rb`, `lib/omq/lz4/errors.rb`, `Gemfile`,
|
|
15
|
+
`Rakefile`, `.gitignore`, `LICENSE` (ISC), `README.md`, CI and
|
|
16
|
+
release GitHub Actions workflows, minitest harness. Depends on
|
|
17
|
+
`rlz4 ~> 0.3` (block API) and `omq ~> 0.23`.
|
|
18
|
+
- `require "omq/lz4"` succeeds and defines `OMQ::LZ4::VERSION` and
|
|
19
|
+
`OMQ::LZ4::ProtocolError`. No transport behaviour yet — the
|
|
20
|
+
`lz4+tcp://` scheme registration lands in a subsequent milestone.
|
|
21
|
+
- **`OMQ::LZ4::Codec`** — pure wire-format encode/decode over Strings,
|
|
22
|
+
no I/O, no connection state. Transport (M2) calls into these per
|
|
23
|
+
ZMTP part.
|
|
24
|
+
- `Codec.encode_part(plaintext, block_codec:, min_size: nil)` — tries
|
|
25
|
+
compression; falls back to passthrough when compression wouldn't
|
|
26
|
+
save the 8-byte envelope overhead (LZ4B envelope = 12 bytes,
|
|
27
|
+
UNCOMPRESSED envelope = 4 bytes). `min_size` defaults to 64 if the
|
|
28
|
+
codec has a dict installed, else 256.
|
|
29
|
+
- `Codec.decode_part(wire_bytes, block_codec:, max_size: nil)` —
|
|
30
|
+
handles UNCOMPRESSED (`00 00 00 00`) and LZ4B (`LZ4B` = `4C 5A 34 42`)
|
|
31
|
+
sentinels. Rejects `max_size` violations before decoder
|
|
32
|
+
invocation. Raises `ProtocolError` on malformed input; never
|
|
33
|
+
segfaults or OOMs.
|
|
34
|
+
- `Codec.encode_dict_shipment(dict_bytes)` /
|
|
35
|
+
`decode_dict_shipment(wire_bytes)` — LZ4D (`LZ4D` = `4C 5A 34 44`)
|
|
36
|
+
shipments. Enforces `1 ≤ dict_size ≤ 8192`.
|
|
37
|
+
- Dict shipments are routed by the transport layer (not by
|
|
38
|
+
`decode_part`); `decode_part` raises if it ever sees an LZ4D
|
|
39
|
+
sentinel.
|
|
40
|
+
- **`OMQ::Transport::Lz4Tcp`** — transport plugin registering the
|
|
41
|
+
`lz4+tcp://` scheme on `OMQ::Engine.transports`. Mirrors
|
|
42
|
+
`omq-zstd`'s transport class hierarchy.
|
|
43
|
+
- `Lz4Tcp.listener(endpoint, engine, dict: nil)`,
|
|
44
|
+
`Lz4Tcp.dialer(endpoint, engine, dict: nil)`,
|
|
45
|
+
`Lz4Tcp.validate_endpoint!`. Oversized dicts
|
|
46
|
+
(> `OMQ::LZ4::Codec::MAX_DICT_SIZE` = 8 KiB) raise
|
|
47
|
+
`OMQ::LZ4::ProtocolError` at bind/connect time.
|
|
48
|
+
- `Lz4Connection` — `SimpleDelegator` over the ZMTP connection.
|
|
49
|
+
Per-connection state: send `BlockCodec` (built with the
|
|
50
|
+
sender-side dict if any), receive `BlockCodec` (initially no-dict,
|
|
51
|
+
replaced with a dict-bound codec on receipt of a dict shipment),
|
|
52
|
+
send-side "dict shipped" flag. Intercepts `#send_message`,
|
|
53
|
+
`#write_message`, `#write_messages`, and `#receive_message` —
|
|
54
|
+
the ZMTP handshake still runs uncompressed over raw TCP
|
|
55
|
+
(`connection_class` returns the default `Protocol::ZMTP::Connection`).
|
|
56
|
+
- Dict shipment is sent as a single-part ZMTP message on the first
|
|
57
|
+
outgoing send when a sender-side dict is configured; the receiver
|
|
58
|
+
consumes it silently (never delivered upstack) and installs a
|
|
59
|
+
dict-bound `BlockCodec` for subsequent decodes. A second shipment
|
|
60
|
+
on the same direction raises `ProtocolError`.
|
|
61
|
+
- Per-message size budget (`engine.options.max_message_size`)
|
|
62
|
+
enforced in `decode_wire_parts` — total decompressed plaintext
|
|
63
|
+
across multipart parts is summed and compared to the socket's
|
|
64
|
+
`max_message_size`. Dict shipments do not count against the budget.
|
|
65
|
+
- Integration test (`test/integration_test.rb`) covers: small (below
|
|
66
|
+
threshold) and large payloads, multipart messages, dict shipment +
|
|
67
|
+
subsequent compressed messages, both sides configured with a dict,
|
|
68
|
+
oversized-dict rejection at bind, and a 100k-message soak (gated
|
|
69
|
+
behind `OMQ_LZ4_STRESS=1`) checking for live-slot leaks after full
|
|
70
|
+
GC.
|
|
71
|
+
- **Receiver size-budget enforcement.** `engine.options.max_message_size`
|
|
72
|
+
bounds the total decompressed size of a ZMTP message, summed across
|
|
73
|
+
MORE-flag-chained parts. Over-budget messages raise
|
|
74
|
+
`OMQ::LZ4::ProtocolError` from the transport and close the
|
|
75
|
+
connection — `receive` surfaces `OMQ::SocketDeadError` on the next
|
|
76
|
+
call. The budget is enforced *before* `LZ4_decompress_safe` is
|
|
77
|
+
invoked, using the `decompressed_size` field declared in the LZ4B
|
|
78
|
+
envelope for compressed parts, or the wire part length for
|
|
79
|
+
UNCOMPRESSED parts; lying-size inputs are caught by
|
|
80
|
+
`LZ4_decompress_safe`'s own bounds check. Dictionary shipments do
|
|
81
|
+
not count against the budget. Integration tests cover single-part
|
|
82
|
+
and multi-part overruns, and a sub-budget multipart positive case.
|
|
83
|
+
- **Second-shipment rejection.** `Lz4Connection#install_recv_dict!`
|
|
84
|
+
raises `OMQ::LZ4::ProtocolError` if a second LZ4D shipment arrives
|
|
85
|
+
on the same direction of the same connection (dictionary is
|
|
86
|
+
install-once per direction). Unit-tested by driving the private
|
|
87
|
+
method directly; the transport's outgoing path never ships twice,
|
|
88
|
+
so the rule primarily guards against a malicious peer.
|
|
89
|
+
- **Dict wrong-id detection: intentionally not implemented.** LZ4
|
|
90
|
+
block format has no dictionary-id field; the transport ships dict
|
|
91
|
+
bytes only (LZ4D sentinel + bytes), no id appended. A dict mismatch
|
|
92
|
+
between peers produces garbage plaintext, not an error. Detect at
|
|
93
|
+
the application layer if needed. See [OMQ-LZ4.plan](../OMQ-LZ4.plan)
|
|
94
|
+
Open Question #1.
|
|
95
|
+
- **RFC.md** — wire-format specification (scheme, sentinels, part
|
|
96
|
+
encoding, dict shipment, receiver budget, security considerations,
|
|
97
|
+
constants). Mirrors `omq-zstd/RFC.md` structure. Status: Draft.
|
|
98
|
+
- **Benchmarks** in `bench/`:
|
|
99
|
+
- `codec_micro.rb` — `OMQ::LZ4::Codec.encode_part` / `decode_part`
|
|
100
|
+
microbench across sizes {64 B, 256 B, 1 KiB, 16 KiB, 1 MiB}, with
|
|
101
|
+
and without dict. Reports wire size, ratio, compress ns,
|
|
102
|
+
decompress ns.
|
|
103
|
+
- `transport_throughput.rb` — end-to-end PUSH → PULL over
|
|
104
|
+
`lz4+tcp://` on loopback, messages per second and µs per
|
|
105
|
+
round-trip.
|
|
106
|
+
- `min_compress_size_sweep.rb` — sweeps input size from 8 B to
|
|
107
|
+
320 B on Lorem-ipsum-like text, reports at which size
|
|
108
|
+
compressed + LZ4B envelope first beats plaintext + passthrough
|
|
109
|
+
envelope, with and without a dict. Used to tune the
|
|
110
|
+
`MIN_COMPRESS_*` thresholds below.
|
|
111
|
+
- `head_to_head.rb` — side-by-side microbenchmark (pure codec)
|
|
112
|
+
and transport throughput of `omq-lz4` vs `omq-zstd`, with and
|
|
113
|
+
without a shared dictionary. Requires `omq-zstd` + `rzstd` in
|
|
114
|
+
the Gemfile's `:bench` group. Numbers land in README's
|
|
115
|
+
"Head-to-head vs omq-zstd" section.
|
|
116
|
+
- **Compression thresholds tuned** from the sweep:
|
|
117
|
+
- `MIN_COMPRESS_NO_DICT`: 256 → **512 B**. The measured crossover
|
|
118
|
+
for Lorem-ipsum text is ~312 B; we round well above it so the
|
|
119
|
+
compressor isn't invoked for marginal wins that real-world (less
|
|
120
|
+
repetitive) payloads would lose anyway.
|
|
121
|
+
- `MIN_COMPRESS_WITH_DICT`: 64 → **32 B**. Measured crossover with
|
|
122
|
+
dict is ~20 B (the dict-reference is shorter than the literal
|
|
123
|
+
payload); 32 leaves a small gap.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Patrik Wenger
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# omq-lz4
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/omq-lz4)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://www.ruby-lang.org)
|
|
6
|
+
|
|
7
|
+
> **Status:** 0.1.0 — first landable release. See
|
|
8
|
+
> [RFC.md](RFC.md) for the wire-format spec and
|
|
9
|
+
> [CHANGELOG.md](CHANGELOG.md) for what's in.
|
|
10
|
+
|
|
11
|
+
LZ4-compressed TCP transport for [OMQ](https://github.com/paddor/omq),
|
|
12
|
+
complementary to [`omq-zstd`](https://github.com/paddor/omq-zstd).
|
|
13
|
+
Pick `lz4+tcp://` instead of `tcp://` or `zstd+tcp://` when you want
|
|
14
|
+
cheap per-message compression with a small per-connection footprint.
|
|
15
|
+
|
|
16
|
+
## When to pick `lz4+tcp://` over `zstd+tcp://`
|
|
17
|
+
|
|
18
|
+
LZ4 has no entropy stage (no Huffman, no FSE), ~16 KiB of encoder state
|
|
19
|
+
per connection, and trades a **worse compression ratio** for
|
|
20
|
+
**~4–8× faster encode** and **~3× less memory per connection**.
|
|
21
|
+
|
|
22
|
+
| | `zstd+tcp://` | `lz4+tcp://` |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| Encode, 1 KiB, no dict | ~3 µs | ~0.4 µs |
|
|
25
|
+
| Encode, 1 KiB, with dict | ~3.5 µs | ~0.5 µs |
|
|
26
|
+
| Memory per connection | ~256 KiB | ~16 KiB + dict |
|
|
27
|
+
| Ratio, 1 KiB JSON no dict | ~45% | ~65% |
|
|
28
|
+
| Ratio, 1 KiB JSON with dict | ~20% | ~35% |
|
|
29
|
+
| Auto-trained dictionaries | ✓ | — (user-supplied only) |
|
|
30
|
+
|
|
31
|
+
Pick `omq-lz4` for CPU- or memory-scarce deployments (edge gateways,
|
|
32
|
+
IoT concentrators, high-fanout scenarios where per-connection state
|
|
33
|
+
matters more than ratio). Pick `omq-zstd` for bandwidth-bound
|
|
34
|
+
deployments where CPU is cheap.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
# Gemfile
|
|
40
|
+
gem "omq-lz4"
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
gem install omq-lz4
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
require "omq"
|
|
51
|
+
require "omq/lz4"
|
|
52
|
+
|
|
53
|
+
pull = OMQ::PULL.new
|
|
54
|
+
push = OMQ::PUSH.new
|
|
55
|
+
|
|
56
|
+
uri = pull.bind("lz4+tcp://127.0.0.1:0")
|
|
57
|
+
push.connect(uri.to_s)
|
|
58
|
+
|
|
59
|
+
push << ["hello, compressed world"]
|
|
60
|
+
pull.receive # => ["hello, compressed world"]
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Both peers must use `lz4+tcp://`. A `tcp://` peer cannot talk to an
|
|
64
|
+
`lz4+tcp://` peer — they speak different transports.
|
|
65
|
+
|
|
66
|
+
### Dictionary compression
|
|
67
|
+
|
|
68
|
+
Small messages don't compress well on their own. A shared dictionary
|
|
69
|
+
gives 2–5× better ratios on payloads with a common prefix. Supply a
|
|
70
|
+
user-trained dictionary (LZ4 has no auto-training — use `omq-zstd`
|
|
71
|
+
for that):
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
dict = File.binread("schema.dict")
|
|
75
|
+
push.connect("lz4+tcp://127.0.0.1:5555", dict: dict)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The sender ships the dictionary to the receiver in-band, prefixed
|
|
79
|
+
with the dictionary sentinel (`4C 5A 34 44`, "LZ4D" in ASCII), on
|
|
80
|
+
the first outgoing message. The receiver installs the dictionary
|
|
81
|
+
and decompresses subsequent messages against it. Dictionary size
|
|
82
|
+
is capped at **8 KiB** — tighter than `omq-zstd`'s 64 KiB cap, to
|
|
83
|
+
let constrained peers accept shipments without allocating tens of
|
|
84
|
+
KB of scratch.
|
|
85
|
+
|
|
86
|
+
### Compression thresholds
|
|
87
|
+
|
|
88
|
+
To avoid pessimizing tiny frames, the sender skips compression below:
|
|
89
|
+
|
|
90
|
+
| Mode | Threshold |
|
|
91
|
+
|-----------------|-----------|
|
|
92
|
+
| No dictionary | 512 B |
|
|
93
|
+
| With dictionary | 32 B |
|
|
94
|
+
|
|
95
|
+
Below the threshold the part is sent uncompressed (4-byte zero
|
|
96
|
+
sentinel + plaintext).
|
|
97
|
+
|
|
98
|
+
### Security limits
|
|
99
|
+
|
|
100
|
+
The receiver bounds decompression by the socket's `max_message_size`
|
|
101
|
+
(the same knob you'd use on a plain `tcp://` socket). It caps the
|
|
102
|
+
**total decompressed size of all parts in a single message**. A peer
|
|
103
|
+
attempting to send an over-budget message drops the connection —
|
|
104
|
+
`OMQ::SocketDeadError` surfaces on the next `receive`.
|
|
105
|
+
|
|
106
|
+
Independent of that, the dictionary itself is capped at 8 KiB; a
|
|
107
|
+
larger shipment drops the connection.
|
|
108
|
+
|
|
109
|
+
See the plan roadmap ([../OMQ-LZ4.plan](../OMQ-LZ4.plan)) for
|
|
110
|
+
history and open questions.
|
|
111
|
+
|
|
112
|
+
## Performance
|
|
113
|
+
|
|
114
|
+
Measured on x86_64 scalar, Ruby 4.0 + YJIT, on dict-friendly (repeated
|
|
115
|
+
Lorem ipsum prefix) input.
|
|
116
|
+
|
|
117
|
+
**`OMQ::LZ4::Codec` (pure encode/decode, no I/O):**
|
|
118
|
+
|
|
119
|
+
| Input size | No dict encode | Dict encode | No dict decode | Dict decode |
|
|
120
|
+
|------------|---------------:|------------:|---------------:|------------:|
|
|
121
|
+
| 64 B | ~0.9 µs | ~1.0 µs | ~0.4 µs | ~0.6 µs |
|
|
122
|
+
| 256 B | ~1.1 µs | ~0.8 µs | ~0.4 µs | ~0.5 µs |
|
|
123
|
+
| 1 KiB | ~1.5 µs | ~0.9 µs | ~0.9 µs | ~1.0 µs |
|
|
124
|
+
| 16 KiB | ~3.2 µs | ~2.4 µs | ~3.9 µs | ~3.0 µs |
|
|
125
|
+
| 1 MiB | ~89 µs | ~87 µs | ~173 µs | ~303 µs |
|
|
126
|
+
|
|
127
|
+
**End-to-end PUSH → PULL over `lz4+tcp://` (loopback):**
|
|
128
|
+
|
|
129
|
+
| Message size | Throughput |
|
|
130
|
+
|--------------|-----------:|
|
|
131
|
+
| 64 B | ~67k msg/s |
|
|
132
|
+
| 256 B | ~94k msg/s |
|
|
133
|
+
| 1 KiB | ~92k msg/s |
|
|
134
|
+
|
|
135
|
+
Run the benchmarks yourself:
|
|
136
|
+
|
|
137
|
+
```sh
|
|
138
|
+
OMQ_DEV=1 bundle exec ruby --yjit bench/codec_micro.rb
|
|
139
|
+
OMQ_DEV=1 bundle exec ruby --yjit bench/transport_throughput.rb
|
|
140
|
+
OMQ_DEV=1 bundle exec ruby --yjit bench/head_to_head.rb # lz4 vs zstd
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Head-to-head vs `omq-zstd` and plain `tcp`
|
|
144
|
+
|
|
145
|
+
End-to-end PUSH → PULL throughput, Ruby 4.0 + YJIT. Input:
|
|
146
|
+
UUID-sprinkled Lorem ipsum — a fresh UUID between each Lorem
|
|
147
|
+
paragraph. Approximates realistic workloads where a schema
|
|
148
|
+
repeats but values vary (event logs, protobuf records, JSON
|
|
149
|
+
events), so a fraction of every message is mandatorily
|
|
150
|
+
incompressible.
|
|
151
|
+
|
|
152
|
+
The link between PUSH and PULL is loopback, rate-shaped with
|
|
153
|
+
`tc netem rate Xmbit` on `dev lo` to simulate bandwidth-limited
|
|
154
|
+
networks. `zstd+tcp` shown at level `-3` (default, fast) and
|
|
155
|
+
level `3` (tighter ratio, more CPU).
|
|
156
|
+
|
|
157
|
+
The table below: plaintext MiB/s (application-level throughput)
|
|
158
|
+
and wire MiB/s (bytes on the socket) at **128 KiB** payload,
|
|
159
|
+
across three bandwidth regimes.
|
|
160
|
+
|
|
161
|
+
| Link | Metric | tcp | lz4+tcp | zstd -3 | zstd 3 |
|
|
162
|
+
|---------------------|----------|------:|--------:|--------:|-------:|
|
|
163
|
+
| **100 Mbit** | plain | 11.8 | 105 | 114 | **197**|
|
|
164
|
+
| (cap ≈ 12 MiB/s) | wire | 11.8 | 12 | 12 | 12 |
|
|
165
|
+
| | speedup | 1.00× | 8.89× | 9.70× |**16.74×**|
|
|
166
|
+
| **1 Gbit** | plain | 117 | 794 | **900** | 603 |
|
|
167
|
+
| (cap ≈ 125 MiB/s) | wire | 117 | 93 | 94 | 36 |
|
|
168
|
+
| | speedup | 1.00× | 6.81× |**7.73×**| 5.17× |
|
|
169
|
+
| **Unlimited loopback** | plain | **1 064** | 869 | 972 | 626 |
|
|
170
|
+
| (kernel-copy-bound) | wire | 1 064 | 99 | 101 | 37 |
|
|
171
|
+
| | speedup | 1.00× | 0.82× | 0.91× | 0.59× |
|
|
172
|
+
|
|
173
|
+
Three regimes visible:
|
|
174
|
+
|
|
175
|
+
- **100 Mbit** — all compressed transports saturate wire at
|
|
176
|
+
~12 MiB/s. Plaintext = wire-cap × (1 / compression-ratio). The
|
|
177
|
+
tighter the ratio, the bigger the win: `zstd 3`'s 3% wire ratio
|
|
178
|
+
translates to a **~17× throughput multiplier** over plain tcp.
|
|
179
|
+
- **1 Gbit** — compressed transports shift from wire-saturated to
|
|
180
|
+
CPU-limited. `zstd -3` reaches ~75% of wire cap; `zstd 3` only
|
|
181
|
+
29% (deep CPU-bound). Both beat plain tcp (which is pinned at
|
|
182
|
+
the wire cap) by **6–8×**. `zstd 3`'s tighter wire no longer
|
|
183
|
+
helps — there's no wire saturation to trade CPU for.
|
|
184
|
+
- **Unlimited loopback** — no wire cap. All three are
|
|
185
|
+
CPU-limited. Plain tcp doesn't pay compression CPU, so **skip
|
|
186
|
+
compression on loopback**.
|
|
187
|
+
|
|
188
|
+
Rate-shape your own link to reproduce:
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
sudo tc qdisc add dev lo root netem rate 100mbit # or 1gbit, 10mbit, etc.
|
|
192
|
+
OMQ_DEV=1 bundle exec ruby --yjit bench/head_to_head.rb
|
|
193
|
+
sudo tc qdisc del dev lo root
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Or use a `veth` pair in a network namespace so shaping doesn't
|
|
197
|
+
touch your host's real loopback (see `tc-netem(8)`, `ip-netns(8)`).
|
|
198
|
+
|
|
199
|
+
Full sweeps (8 sizes from 256 B to 512 KiB) for each regime live
|
|
200
|
+
in `bench/head_to_head.rb` output — run it yourself; the
|
|
201
|
+
headline numbers above are stable across repeats but small sizes
|
|
202
|
+
and very large sizes vary a bit run-to-run.
|
|
203
|
+
|
|
204
|
+
**Takeaway:**
|
|
205
|
+
|
|
206
|
+
- Pick **`lz4+tcp://`** for bandwidth-limited links (any real
|
|
207
|
+
network — even 1 Gbit LAN). 6–9× throughput multiplier over
|
|
208
|
+
plain `tcp`, minimal memory (~16 KiB/connection), modest CPU.
|
|
209
|
+
Ties or beats `zstd -3` at 1 Gbit; loses the ratio race to
|
|
210
|
+
`zstd 3` at 100 Mbit and below.
|
|
211
|
+
- Pick **`zstd+tcp://` (level ≥ 3)** when the wire is the
|
|
212
|
+
precious resource (≤ 100 Mbit links, WAN, or you're paying for
|
|
213
|
+
egress). **~17× throughput multiplier at 100 Mbit** for 128 KiB
|
|
214
|
+
messages is hard to argue with.
|
|
215
|
+
- Pick **plain `tcp://`** when the link is *not* the bottleneck
|
|
216
|
+
(localhost IPC, loopback, datacenter-fast inter-host
|
|
217
|
+
connections where the bandwidth ceiling is above the CPU's
|
|
218
|
+
compress/decompress speed — typically 10+ Gbit), or when the
|
|
219
|
+
payload is already high-entropy (encrypted, already compressed,
|
|
220
|
+
random binary) and compression only adds overhead.
|
|
221
|
+
|
|
222
|
+
## Development
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
OMQ_DEV=1 bundle install
|
|
226
|
+
OMQ_DEV=1 bundle exec rake test
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[ISC](LICENSE)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rlz4"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module OMQ
|
|
8
|
+
module LZ4
|
|
9
|
+
# Wire format for the lz4+tcp:// transport, encode/decode over
|
|
10
|
+
# String input/output. Pure functions — no I/O, no connection state.
|
|
11
|
+
# Transport (M2) owns the connection state and calls into these
|
|
12
|
+
# methods per ZMTP part.
|
|
13
|
+
#
|
|
14
|
+
# Each wire part begins with a 4-byte sentinel:
|
|
15
|
+
#
|
|
16
|
+
# 00 00 00 00 uncompressed plaintext
|
|
17
|
+
# 4C 5A 34 42 LZ4-compressed block ("LZ4B" in ASCII)
|
|
18
|
+
# 4C 5A 34 44 dictionary shipment ("LZ4D" in ASCII)
|
|
19
|
+
#
|
|
20
|
+
# `decode_part` handles UNCOMPRESSED and LZ4B only. Dictionary
|
|
21
|
+
# shipments are a transport-layer concern: the transport peeks the
|
|
22
|
+
# first 4 bytes of each incoming wire part, routes LZ4D to
|
|
23
|
+
# `decode_dict_shipment`, and never hands a shipment to `decode_part`.
|
|
24
|
+
module Codec
|
|
25
|
+
UNCOMPRESSED_SENTINEL = "\x00\x00\x00\x00".b.freeze
|
|
26
|
+
LZ4B_SENTINEL = "LZ4B".b.freeze
|
|
27
|
+
LZ4D_SENTINEL = "LZ4D".b.freeze
|
|
28
|
+
|
|
29
|
+
# Size thresholds below which compression isn't worth attempting.
|
|
30
|
+
# Empirically tuned on Lorem-ipsum-like input via
|
|
31
|
+
# bench/min_compress_size_sweep.rb: for block-format LZ4 the
|
|
32
|
+
# crossover where compressed + 12-byte envelope beats
|
|
33
|
+
# plaintext + 4-byte passthrough envelope sits at ~312 B without
|
|
34
|
+
# a dict and ~20 B with one. We round up to 512 / 32 so the
|
|
35
|
+
# machinery isn't invoked for marginal wins where real-world
|
|
36
|
+
# (less repetitive) payloads would likely fall back to
|
|
37
|
+
# passthrough anyway. Below the threshold, `encode_part` emits
|
|
38
|
+
# UNCOMPRESSED directly without touching the compressor.
|
|
39
|
+
MIN_COMPRESS_NO_DICT = 512
|
|
40
|
+
MIN_COMPRESS_WITH_DICT = 32
|
|
41
|
+
|
|
42
|
+
# Maximum dictionary size on the wire. A policy choice, not a
|
|
43
|
+
# protocol limit; tight enough that constrained peers can accept
|
|
44
|
+
# dicts without allocating tens of KB of scratch.
|
|
45
|
+
MAX_DICT_SIZE = 8192
|
|
46
|
+
|
|
47
|
+
# Envelope sizes:
|
|
48
|
+
# UNCOMPRESSED = 4 (sentinel)
|
|
49
|
+
# LZ4B = 4 (sentinel) + 8 (decompressed_size u64 LE)
|
|
50
|
+
# => switching from passthrough to compressed costs 8 bytes of
|
|
51
|
+
# envelope overhead. Compression must save more than that to win.
|
|
52
|
+
COMPRESSED_ENVELOPE = 12
|
|
53
|
+
PASSTHROUGH_ENVELOPE = 4
|
|
54
|
+
|
|
55
|
+
module_function
|
|
56
|
+
|
|
57
|
+
# Encode one plaintext part to wire bytes. Tries compression; falls
|
|
58
|
+
# back to passthrough when compression wouldn't save at least the
|
|
59
|
+
# envelope overhead.
|
|
60
|
+
#
|
|
61
|
+
# `block_codec` is an RLZ4::BlockCodec, optionally constructed with
|
|
62
|
+
# `dict: bytes`. The codec's dict presence is detected via
|
|
63
|
+
# `#has_dict?` to pick the min-size threshold.
|
|
64
|
+
#
|
|
65
|
+
# `min_size` overrides the default threshold. Nil (the default)
|
|
66
|
+
# picks `MIN_COMPRESS_NO_DICT` for a no-dict codec and
|
|
67
|
+
# `MIN_COMPRESS_WITH_DICT` for a dict codec.
|
|
68
|
+
def encode_part(plaintext, block_codec:, min_size: nil)
|
|
69
|
+
min_size ||= block_codec.has_dict? ? MIN_COMPRESS_WITH_DICT : MIN_COMPRESS_NO_DICT
|
|
70
|
+
|
|
71
|
+
return encode_passthrough(plaintext) if plaintext.bytesize < min_size
|
|
72
|
+
|
|
73
|
+
compressed = block_codec.compress(plaintext)
|
|
74
|
+
|
|
75
|
+
# Net savings = (plaintext + 4) − (compressed + 12) = plaintext − compressed − 8.
|
|
76
|
+
# If ≤ 0, passthrough wins (or ties — prefer passthrough: one
|
|
77
|
+
# fewer u64 for the receiver to parse).
|
|
78
|
+
if compressed.bytesize + COMPRESSED_ENVELOPE >= plaintext.bytesize + PASSTHROUGH_ENVELOPE
|
|
79
|
+
encode_passthrough(plaintext)
|
|
80
|
+
else
|
|
81
|
+
encode_compressed(plaintext.bytesize, compressed)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Decode one wire part. Returns a plaintext binary String.
|
|
86
|
+
#
|
|
87
|
+
# `max_size` is an optional cap on the decompressed size of this
|
|
88
|
+
# single part; if the declared (LZ4B) or wire (UNCOMPRESSED)
|
|
89
|
+
# plaintext size exceeds it, raises ProtocolError before any
|
|
90
|
+
# decoder invocation.
|
|
91
|
+
#
|
|
92
|
+
# Does not handle LZ4D shipments; transport must route those to
|
|
93
|
+
# `decode_dict_shipment` before calling here.
|
|
94
|
+
def decode_part(wire_bytes, block_codec:, max_size: nil)
|
|
95
|
+
if wire_bytes.bytesize < 4
|
|
96
|
+
raise ProtocolError, "wire part too short (< 4 bytes)"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sentinel = wire_bytes.byteslice(0, 4)
|
|
100
|
+
case sentinel
|
|
101
|
+
when UNCOMPRESSED_SENTINEL
|
|
102
|
+
payload = wire_bytes.byteslice(4, wire_bytes.bytesize - 4)
|
|
103
|
+
check_size!(payload.bytesize, max_size)
|
|
104
|
+
payload
|
|
105
|
+
when LZ4B_SENTINEL
|
|
106
|
+
if wire_bytes.bytesize < 12
|
|
107
|
+
raise ProtocolError, "LZ4B part too short (< 12 bytes, no room for size field)"
|
|
108
|
+
end
|
|
109
|
+
decompressed_size = wire_bytes.byteslice(4, 8).unpack1("Q<")
|
|
110
|
+
check_size!(decompressed_size, max_size)
|
|
111
|
+
block = wire_bytes.byteslice(12, wire_bytes.bytesize - 12)
|
|
112
|
+
begin
|
|
113
|
+
block_codec.decompress(block, decompressed_size: decompressed_size)
|
|
114
|
+
rescue RLZ4::DecompressError => e
|
|
115
|
+
raise ProtocolError, "LZ4B decode failed: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
when LZ4D_SENTINEL
|
|
118
|
+
# Should not reach decode_part; transport should have routed this.
|
|
119
|
+
raise ProtocolError,
|
|
120
|
+
"LZ4D dictionary shipment seen at decode_part (transport should route to decode_dict_shipment)"
|
|
121
|
+
else
|
|
122
|
+
raise ProtocolError, "unknown sentinel #{sentinel.unpack1("H*")}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Encode a dictionary shipment. Returns wire bytes:
|
|
127
|
+
# LZ4D sentinel (4 bytes) || dict bytes (1..8192)
|
|
128
|
+
#
|
|
129
|
+
# The shipment is a single-part ZMTP message (MORE flag clear)
|
|
130
|
+
# from the transport's perspective, but that framing is the
|
|
131
|
+
# transport's responsibility.
|
|
132
|
+
def encode_dict_shipment(dict_bytes)
|
|
133
|
+
validate_dict_size!(dict_bytes.bytesize)
|
|
134
|
+
LZ4D_SENTINEL + dict_bytes
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Decode a dictionary shipment. Returns the dict bytes (without
|
|
138
|
+
# sentinel). Raises ProtocolError if the sentinel is wrong or the
|
|
139
|
+
# size is out of the [1, 8192] range.
|
|
140
|
+
def decode_dict_shipment(wire_bytes)
|
|
141
|
+
if wire_bytes.bytesize < 4
|
|
142
|
+
raise ProtocolError, "dict shipment too short (< 4 bytes)"
|
|
143
|
+
end
|
|
144
|
+
sentinel = wire_bytes.byteslice(0, 4)
|
|
145
|
+
unless sentinel == LZ4D_SENTINEL
|
|
146
|
+
raise ProtocolError,
|
|
147
|
+
"not a dict shipment (sentinel #{sentinel.unpack1("H*")}, expected 4C5A3444)"
|
|
148
|
+
end
|
|
149
|
+
dict = wire_bytes.byteslice(4, wire_bytes.bytesize - 4)
|
|
150
|
+
validate_dict_size!(dict.bytesize)
|
|
151
|
+
dict
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class << self
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def encode_passthrough(plaintext)
|
|
158
|
+
UNCOMPRESSED_SENTINEL + plaintext
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def encode_compressed(decompressed_size, compressed)
|
|
163
|
+
LZ4B_SENTINEL + [decompressed_size].pack("Q<") + compressed
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_size!(declared_size, max_size)
|
|
168
|
+
return unless max_size
|
|
169
|
+
return if declared_size <= max_size
|
|
170
|
+
|
|
171
|
+
raise ProtocolError,
|
|
172
|
+
"part size #{declared_size} exceeds max_size #{max_size}"
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_dict_size!(size)
|
|
177
|
+
if size < 1 || size > MAX_DICT_SIZE
|
|
178
|
+
raise ProtocolError,
|
|
179
|
+
"dict shipment size #{size} out of range [1, #{MAX_DICT_SIZE}]"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
module LZ4
|
|
5
|
+
# Raised when the peer sends bytes that violate the lz4+tcp wire
|
|
6
|
+
# format: unknown sentinel, a dictionary shipment that exceeds the
|
|
7
|
+
# size cap, a second dictionary shipment on the same direction, a
|
|
8
|
+
# per-message size-budget overrun, or a decoder failure on a
|
|
9
|
+
# compressed part. The transport closes the connection on any
|
|
10
|
+
# protocol error — never silently drops the offending part.
|
|
11
|
+
class ProtocolError < StandardError; end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/omq/lz4.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# OMQ LZ4+TCP transport — adds lz4+tcp:// endpoint support.
|
|
4
|
+
#
|
|
5
|
+
# Complementary to `omq-zstd`: LZ4 block format has no entropy stage
|
|
6
|
+
# (no Huffman, no FSE), ~16 KiB of encoder state per connection, and
|
|
7
|
+
# trades worse compression ratio for ~4–8× faster encode and ~3× less
|
|
8
|
+
# memory. Pick `lz4+tcp://` for CPU- or memory-scarce deployments where
|
|
9
|
+
# the bandwidth savings of zstd aren't worth the per-message CPU.
|
|
10
|
+
#
|
|
11
|
+
# See RFC.md for the wire format (not yet written — scheme is still
|
|
12
|
+
# under development).
|
|
13
|
+
|
|
14
|
+
require_relative "lz4/version"
|
|
15
|
+
require_relative "lz4/errors"
|
|
16
|
+
require_relative "lz4/codec"
|
|
17
|
+
require_relative "transport/lz4_tcp"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
|
|
5
|
+
require_relative "../../lz4/codec"
|
|
6
|
+
|
|
7
|
+
module OMQ
|
|
8
|
+
module Transport
|
|
9
|
+
module Lz4Tcp
|
|
10
|
+
# Per-connection state + encode/decode hooks. A SimpleDelegator over
|
|
11
|
+
# the ZMTP connection so send_message / write_message /
|
|
12
|
+
# receive_message route through compression, but everything else
|
|
13
|
+
# (write_command, close, etc.) passes through untouched.
|
|
14
|
+
class Lz4Connection < SimpleDelegator
|
|
15
|
+
# @return [Integer, nil] wire bytesize of the last received
|
|
16
|
+
# message (sum across parts, compressed sentinel included).
|
|
17
|
+
attr_reader :last_wire_size_in
|
|
18
|
+
|
|
19
|
+
def initialize(conn, send_dict_bytes:, max_message_size:)
|
|
20
|
+
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
|
|
33
|
+
|
|
34
|
+
@last_wire_size_in = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def send_message(parts)
|
|
39
|
+
wire = encode_parts(parts)
|
|
40
|
+
ship_send_dict!
|
|
41
|
+
__getobj__.send_message(wire)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_message(parts)
|
|
46
|
+
wire = encode_parts(parts)
|
|
47
|
+
ship_send_dict!
|
|
48
|
+
__getobj__.write_message(wire)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def write_messages(messages)
|
|
53
|
+
wires = messages.map { |parts| encode_parts(parts) }
|
|
54
|
+
ship_send_dict!
|
|
55
|
+
__getobj__.write_messages(wires)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def receive_message
|
|
60
|
+
# Loop: a dict shipment is consumed silently and we read the
|
|
61
|
+
# next ZMTP message. Only data messages are returned to the
|
|
62
|
+
# caller. Budget tracking happens inside decode_wire_parts.
|
|
63
|
+
loop do
|
|
64
|
+
parts = __getobj__.receive_message
|
|
65
|
+
decoded = decode_wire_parts(parts)
|
|
66
|
+
if decoded
|
|
67
|
+
@last_wire_size_in = parts.sum(&:bytesize)
|
|
68
|
+
return decoded
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_block_codec(dict_bytes)
|
|
78
|
+
if dict_bytes
|
|
79
|
+
RLZ4::BlockCodec.new(dict: dict_bytes)
|
|
80
|
+
else
|
|
81
|
+
RLZ4::BlockCodec.new
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def encode_parts(parts)
|
|
87
|
+
parts.map do |pt|
|
|
88
|
+
bytes = pt.is_a?(String) && pt.encoding == Encoding::BINARY ? pt : pt.to_s.b
|
|
89
|
+
LZ4::Codec.encode_part(bytes, block_codec: @send_codec)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ship_send_dict!
|
|
95
|
+
return if @send_dict_shipped
|
|
96
|
+
|
|
97
|
+
shipment = LZ4::Codec.encode_dict_shipment(@send_dict_bytes)
|
|
98
|
+
__getobj__.write_message([shipment])
|
|
99
|
+
@send_dict_shipped = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# Returns an array of plaintext parts, or nil if the whole
|
|
104
|
+
# ZMTP message was a dict shipment (consumed silently).
|
|
105
|
+
#
|
|
106
|
+
# Budget tracking is per-message (sum of decompressed sizes
|
|
107
|
+
# across parts). Dict shipment parts do not count against the
|
|
108
|
+
# budget — they aren't messages.
|
|
109
|
+
def decode_wire_parts(parts)
|
|
110
|
+
decoded = []
|
|
111
|
+
all_dicts = true
|
|
112
|
+
budget = @max_message_size
|
|
113
|
+
|
|
114
|
+
parts.each do |wire|
|
|
115
|
+
raise LZ4::ProtocolError, "wire part too short (< 4 bytes)" if wire.bytesize < 4
|
|
116
|
+
|
|
117
|
+
sentinel = wire.byteslice(0, 4)
|
|
118
|
+
if sentinel == LZ4::Codec::LZ4D_SENTINEL
|
|
119
|
+
install_recv_dict!(wire)
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
all_dicts = false
|
|
124
|
+
plaintext = LZ4::Codec.decode_part(wire, block_codec: @recv_codec, max_size: budget)
|
|
125
|
+
budget -= plaintext.bytesize if budget
|
|
126
|
+
decoded << plaintext
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
all_dicts ? nil : decoded
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def install_recv_dict!(wire)
|
|
134
|
+
if @recv_dict_bytes
|
|
135
|
+
raise LZ4::ProtocolError, "second dictionary shipment on the same direction"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
dict_bytes = LZ4::Codec.decode_dict_shipment(wire)
|
|
139
|
+
# 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)
|
|
143
|
+
@recv_dict_bytes = dict_bytes
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "io/stream"
|
|
6
|
+
require "omq"
|
|
7
|
+
|
|
8
|
+
require_relative "connection"
|
|
9
|
+
|
|
10
|
+
module OMQ
|
|
11
|
+
module Transport
|
|
12
|
+
module Lz4Tcp
|
|
13
|
+
SCHEME = "lz4+tcp"
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Called by OMQ::Engine::ConnectionLifecycle after the ZMTP
|
|
17
|
+
# handshake completes; we return the default connection class
|
|
18
|
+
# so that handshake itself runs uncompressed over raw TCP.
|
|
19
|
+
def connection_class
|
|
20
|
+
Protocol::ZMTP::Connection
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Creates a bound lz4+tcp listener.
|
|
25
|
+
#
|
|
26
|
+
# @param endpoint [String] e.g. "lz4+tcp://127.0.0.1:0"
|
|
27
|
+
# @param engine [OMQ::Engine]
|
|
28
|
+
# @param dict [String, nil] user-supplied dictionary bytes to
|
|
29
|
+
# ship on the first outgoing message. If nil, no dict is
|
|
30
|
+
# shipped (payloads compress without dict or go plaintext
|
|
31
|
+
# below the min-size threshold).
|
|
32
|
+
# @return [Listener]
|
|
33
|
+
def listener(endpoint, engine, dict: nil, **)
|
|
34
|
+
validate_dict!(dict)
|
|
35
|
+
|
|
36
|
+
host, port = parse_endpoint(endpoint)
|
|
37
|
+
lookup_host = normalize_bind_host(host)
|
|
38
|
+
servers = ::Socket.tcp_server_sockets(lookup_host, port)
|
|
39
|
+
|
|
40
|
+
if servers.empty?
|
|
41
|
+
raise ::Socket::ResolutionError, "no addresses for #{host.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
actual_port = servers.first.local_address.ip_port
|
|
45
|
+
display_host = host == "*" ? "*" : (lookup_host || "*")
|
|
46
|
+
host_part = display_host.include?(":") ? "[#{display_host}]" : display_host
|
|
47
|
+
resolved = "#{SCHEME}://#{host_part}:#{actual_port}"
|
|
48
|
+
|
|
49
|
+
Listener.new(resolved, servers, actual_port, engine, dict&.b)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Creates an lz4+tcp dialer for an endpoint.
|
|
54
|
+
#
|
|
55
|
+
# @param endpoint [String]
|
|
56
|
+
# @param engine [OMQ::Engine]
|
|
57
|
+
# @param dict [String, nil] user-supplied dictionary bytes.
|
|
58
|
+
# @return [Dialer]
|
|
59
|
+
def dialer(endpoint, engine, dict: nil, **)
|
|
60
|
+
validate_dict!(dict)
|
|
61
|
+
Dialer.new(endpoint, engine, dict&.b)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_endpoint!(endpoint)
|
|
66
|
+
host, _port = parse_endpoint(endpoint)
|
|
67
|
+
lookup_host = normalize_connect_host(host)
|
|
68
|
+
Addrinfo.getaddrinfo(lookup_host, nil, nil, :STREAM) if lookup_host
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_endpoint(endpoint)
|
|
73
|
+
uri = URI.parse(endpoint)
|
|
74
|
+
[uri.hostname, uri.port]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_bind_host(host)
|
|
79
|
+
case host
|
|
80
|
+
when "*" then nil
|
|
81
|
+
when nil, "", "localhost" then TCP.loopback_host
|
|
82
|
+
else host
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def normalize_connect_host(host)
|
|
88
|
+
case host
|
|
89
|
+
when nil, "", "*", "localhost" then TCP.loopback_host
|
|
90
|
+
else host
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def connect_timeout(options)
|
|
96
|
+
ri = options.reconnect_interval
|
|
97
|
+
ri = ri.end if ri.is_a?(Range)
|
|
98
|
+
[ri, 0.5].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_dict!(dict)
|
|
106
|
+
return if dict.nil?
|
|
107
|
+
|
|
108
|
+
size = dict.bytesize
|
|
109
|
+
return if size >= 1 && size <= LZ4::Codec::MAX_DICT_SIZE
|
|
110
|
+
|
|
111
|
+
raise LZ4::ProtocolError,
|
|
112
|
+
"dict size #{size} out of range [1, #{LZ4::Codec::MAX_DICT_SIZE}]"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Dialer: outgoing connections.
|
|
118
|
+
class Dialer
|
|
119
|
+
attr_reader :endpoint
|
|
120
|
+
|
|
121
|
+
def initialize(endpoint, engine, dict_bytes)
|
|
122
|
+
@endpoint = endpoint
|
|
123
|
+
@engine = engine
|
|
124
|
+
@dict_bytes = dict_bytes
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def connect
|
|
129
|
+
host, port = Lz4Tcp.parse_endpoint(@endpoint)
|
|
130
|
+
host = Lz4Tcp.normalize_connect_host(host)
|
|
131
|
+
sock = ::Socket.tcp(host, port, connect_timeout: Lz4Tcp.connect_timeout(@engine.options))
|
|
132
|
+
|
|
133
|
+
TCP.apply_buffer_sizes(sock, @engine.options)
|
|
134
|
+
|
|
135
|
+
@engine.handle_connected(IO::Stream::Buffered.wrap(sock), endpoint: @endpoint)
|
|
136
|
+
rescue
|
|
137
|
+
sock&.close
|
|
138
|
+
raise
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def wrap_connection(conn)
|
|
143
|
+
Lz4Connection.new(
|
|
144
|
+
conn,
|
|
145
|
+
send_dict_bytes: @dict_bytes,
|
|
146
|
+
max_message_size: @engine.options.max_message_size,
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Listener: bound server accepting incoming connections.
|
|
153
|
+
class Listener
|
|
154
|
+
attr_reader :endpoint, :port
|
|
155
|
+
|
|
156
|
+
def initialize(endpoint, servers, port, engine, dict_bytes)
|
|
157
|
+
@endpoint = endpoint
|
|
158
|
+
@servers = servers
|
|
159
|
+
@port = port
|
|
160
|
+
@engine = engine
|
|
161
|
+
@dict_bytes = dict_bytes
|
|
162
|
+
@tasks = []
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def wrap_connection(conn)
|
|
167
|
+
Lz4Connection.new(
|
|
168
|
+
conn,
|
|
169
|
+
send_dict_bytes: @dict_bytes,
|
|
170
|
+
max_message_size: @engine.options.max_message_size,
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def start_accept_loops(parent_task, &on_accepted)
|
|
176
|
+
@tasks = @servers.map do |server|
|
|
177
|
+
parent_task.async(transient: true, annotation: "#{SCHEME} accept #{@endpoint}") do
|
|
178
|
+
loop do
|
|
179
|
+
client, _addr = server.accept
|
|
180
|
+
|
|
181
|
+
Async::Task.current.defer_stop do
|
|
182
|
+
TCP.apply_buffer_sizes(client, @engine.options)
|
|
183
|
+
|
|
184
|
+
stream = IO::Stream::Buffered.wrap(client)
|
|
185
|
+
|
|
186
|
+
on_accepted.call(stream)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
rescue Async::Stop
|
|
190
|
+
rescue IOError
|
|
191
|
+
ensure
|
|
192
|
+
server.close rescue nil
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def stop
|
|
199
|
+
@tasks.each(&:stop)
|
|
200
|
+
@servers.each { |s| s.close rescue nil }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
Engine.transports[SCHEME] = self
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# OMQ LZ4+TCP transport — adds lz4+tcp:// endpoint support.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# require "omq/transport/lz4_tcp"
|
|
7
|
+
#
|
|
8
|
+
# push = OMQ::PUSH.new
|
|
9
|
+
# push.connect("lz4+tcp://127.0.0.1:5555", dict: File.binread("my.dict"))
|
|
10
|
+
# # or without a dictionary:
|
|
11
|
+
# push.connect("lz4+tcp://127.0.0.1:5555")
|
|
12
|
+
|
|
13
|
+
require "omq"
|
|
14
|
+
require "rlz4"
|
|
15
|
+
|
|
16
|
+
require_relative "../lz4/errors"
|
|
17
|
+
require_relative "../lz4/codec"
|
|
18
|
+
require_relative "lz4_tcp/transport"
|
metadata
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: omq-lz4
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.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: rlz4
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.5'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
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.'
|
|
43
|
+
email:
|
|
44
|
+
- paddor@gmail.com
|
|
45
|
+
executables: []
|
|
46
|
+
extensions: []
|
|
47
|
+
extra_rdoc_files: []
|
|
48
|
+
files:
|
|
49
|
+
- CHANGELOG.md
|
|
50
|
+
- LICENSE
|
|
51
|
+
- README.md
|
|
52
|
+
- lib/omq/lz4.rb
|
|
53
|
+
- lib/omq/lz4/codec.rb
|
|
54
|
+
- lib/omq/lz4/errors.rb
|
|
55
|
+
- lib/omq/lz4/version.rb
|
|
56
|
+
- lib/omq/transport/lz4_tcp.rb
|
|
57
|
+
- lib/omq/transport/lz4_tcp/connection.rb
|
|
58
|
+
- lib/omq/transport/lz4_tcp/transport.rb
|
|
59
|
+
homepage: https://github.com/paddor/omq-lz4
|
|
60
|
+
licenses:
|
|
61
|
+
- ISC
|
|
62
|
+
metadata: {}
|
|
63
|
+
rdoc_options: []
|
|
64
|
+
require_paths:
|
|
65
|
+
- lib
|
|
66
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '3.3'
|
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0'
|
|
76
|
+
requirements: []
|
|
77
|
+
rubygems_version: 4.0.6
|
|
78
|
+
specification_version: 4
|
|
79
|
+
summary: LZ4+TCP transport for OMQ
|
|
80
|
+
test_files: []
|