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 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
+ [![Gem Version](https://img.shields.io/gem/v/omq-lz4?color=e9573f)](https://rubygems.org/gems/omq-lz4)
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)
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
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ module LZ4
5
+ VERSION = "0.2.0"
6
+ end
7
+ 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: []