omq-zstd 0.4.4 → 0.5.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 +4 -4
- data/DESIGN.md +36 -149
- data/README.md +15 -2
- data/RFC.md +102 -31
- data/lib/omq/transport/zstd_tcp/codec.rb +47 -1
- data/lib/omq/transport/zstd_tcp/connection.rb +28 -24
- data/lib/omq/transport/zstd_tcp/transport.rb +9 -0
- data/lib/omq/transport/zstd_tcp.rb +1 -1
- data/lib/omq/zstd/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0adb0f7516e8bfc1df21c5406669d36834d933851dbadb6b41c82d0b961509f7
|
|
4
|
+
data.tar.gz: 112a512e7266d6761e69a0b87bc2b4656afefc63f0b878c0c120e337206c020e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b9ef977a6b1a8a1b08935340bd2efb201aacc6adc20dc51f652a68e238c24d459d8afb9f3294699d931721f16b94afa053267a98dc69525f977abec0d4f9e932
|
|
7
|
+
data.tar.gz: 14e63f2bcbb48f191b13ef2ca3d27b503cd7650e9c1c6b82986c907cdbcfa7de9c6f3e9a644633d8d74ee99513aa147ef1c1545d1904aa964439e05bb4a7cfb3
|
data/DESIGN.md
CHANGED
|
@@ -1,161 +1,48 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Zstd transport architecture
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
the OMQ-specific architecture that doesn't belong in the RFC.
|
|
3
|
+
The wire contract is [ZMTP-Zstd revision 2](RFC.md). Compression starts
|
|
4
|
+
only after the normal ZMTP handshake. Commands remain uncompressed.
|
|
6
5
|
|
|
7
|
-
##
|
|
6
|
+
## Send path
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
transport
|
|
8
|
+
Routing applies subscription filters and queue limits to logical messages.
|
|
9
|
+
The transport receives the messages admitted for one connection and encodes
|
|
10
|
+
isolated or large messages per part. Already queued small messages may share
|
|
11
|
+
an arena, targeting 32 KiB without waiting for additional traffic.
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
per-connection state, but the wrapper only sees messages after routing
|
|
17
|
-
has dispatched them. Reconnect handling requires hooking into
|
|
18
|
-
connection lifecycle events, which is awkward from outside.
|
|
13
|
+
A codec owns the send dictionary, training state, and compression settings.
|
|
14
|
+
It is shared by the endpoints of one socket. Individual-message encoding
|
|
15
|
+
can reuse the result for the same frozen message across subscribers.
|
|
16
|
+
Arenas are built from each subscriber's admitted sequence.
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
connections.
|
|
18
|
+
Dictionary shipment is tracked per connection and direction. A newly
|
|
19
|
+
connected peer receives the dictionary before any data that uses it.
|
|
20
|
+
Training is optional, uses a 2 KiB dictionary, and collects up to 1000 samples
|
|
21
|
+
or 100 KiB. Samples of 2048 bytes or more are excluded.
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
explicit in the endpoint URI. Only TCP connections get compressed. IPC
|
|
28
|
-
and inproc are unaffected even on the same socket. Dict lifetime
|
|
29
|
-
matches connection lifetime naturally (new connection = new wrapper =
|
|
30
|
-
re-ship dict). No negotiation needed — both peers use `zstd+tcp://`.
|
|
31
|
-
The Codec is socket-wide (shared across connections via the Dialer or
|
|
32
|
-
Listener), so PUB compresses once and reuses the result.
|
|
23
|
+
## Receive path
|
|
33
24
|
|
|
34
|
-
|
|
25
|
+
Each connection owns its receive dictionary and any unread arena messages.
|
|
26
|
+
It accepts raw parts, Zstandard frames, one dictionary shipment, and raw or
|
|
27
|
+
compressed `ZSTA` arenas. Dictionary shipments must be single-part messages.
|
|
35
28
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
│ → Listener → Dialer
|
|
42
|
-
│ holds Codec holds Codec
|
|
43
|
-
│ #wrap_connection #wrap_connection
|
|
44
|
-
▼ │ │
|
|
45
|
-
ConnectionLifecycle ▼ ▼
|
|
46
|
-
ready! ──────► ZstdConnection(conn, codec)
|
|
47
|
-
│ #write_message → compress → ship_dict! → delegate
|
|
48
|
-
│ #receive_message → decode (per-connection recv_dict)
|
|
49
|
-
│ #respond_to?(:write_wire) → false (forces fan-out path)
|
|
50
|
-
```
|
|
29
|
+
The shared compression transport layer validates an entire arena before
|
|
30
|
+
returning its first message. Each subsequent receive returns one logical
|
|
31
|
+
message, preserving queue backpressure and receive scheduling. An invalid
|
|
32
|
+
arena closes its connection without exposing a valid prefix or killing the
|
|
33
|
+
socket's other connections.
|
|
51
34
|
|
|
52
|
-
|
|
35
|
+
## Bounds
|
|
53
36
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`ConnectionLifecycle#ready!`.
|
|
37
|
+
- Arena envelope: at most 65552 bytes.
|
|
38
|
+
- Decoded arena: at most 65536 bytes, 256 messages, and 4096 parts.
|
|
39
|
+
- Dictionary: at most 8192 bytes, shipped once per direction.
|
|
40
|
+
- Application size limit: checked per logical message inside an arena,
|
|
41
|
+
including a 64-byte storage charge per part.
|
|
60
42
|
|
|
61
|
-
|
|
62
|
-
|
|
43
|
+
Zstandard frames must declare their content size and consume exactly their
|
|
44
|
+
wire part. Arena frames must declare the same size as their arena header.
|
|
45
|
+
Decompression is bounded by that size. The outer ZMTP framer allows transport
|
|
46
|
+
envelopes independently of the application message limit.
|
|
63
47
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
One Codec per Dialer or Listener. Shared across all connections of
|
|
67
|
-
that endpoint. Owns:
|
|
68
|
-
|
|
69
|
-
- **Compression**: `#compress_parts(parts)` with identity cache
|
|
70
|
-
- **Training**: sample collection, `Zrip::DictTrainer`, dict ID patching
|
|
71
|
-
- **Send dict**: `#send_dict_bytes` — the trained or user-supplied dict bytes
|
|
72
|
-
|
|
73
|
-
### ZstdConnection (per-connection)
|
|
74
|
-
|
|
75
|
-
`SimpleDelegator` wrapping a `Protocol::ZMTP::Connection`. Per-connection
|
|
76
|
-
state:
|
|
77
|
-
|
|
78
|
-
- `@dict_shipped` — whether the dict has been sent on this connection
|
|
79
|
-
- `@recv_dict` — the peer's dictionary for decompression
|
|
80
|
-
|
|
81
|
-
Intercepts `#send_message`, `#write_message`, `#write_messages`,
|
|
82
|
-
`#receive_message`. Returns `false` for `respond_to?(:write_wire)` to
|
|
83
|
-
force fan-out through `#write_message` (which hits the compression
|
|
84
|
-
cache) instead of pre-encoded wire bytes.
|
|
85
|
-
|
|
86
|
-
## Identity-based compression cache
|
|
87
|
-
|
|
88
|
-
PUB fan-out sends the same frozen message parts Array to every
|
|
89
|
-
subscriber's `#write_message`. The Codec exploits this with an
|
|
90
|
-
`Object#equal?` check:
|
|
91
|
-
|
|
92
|
-
```ruby
|
|
93
|
-
def compress_parts(parts)
|
|
94
|
-
return @cached_compressed if parts.equal?(@cached_parts)
|
|
95
|
-
# ... compress ...
|
|
96
|
-
@cached_parts = parts
|
|
97
|
-
@cached_compressed = compressed.freeze
|
|
98
|
-
end
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
`.equal?` is O(1) — same frozen Array object from `freeze_message`.
|
|
102
|
-
First subscriber pays the compression cost; subsequent subscribers
|
|
103
|
-
get the cached result. Net: one compression per message, N wire
|
|
104
|
-
writes.
|
|
105
|
-
|
|
106
|
-
## Dict shipping order
|
|
107
|
-
|
|
108
|
-
Training can trigger DURING `#compress_parts` (when the sample
|
|
109
|
-
threshold is reached mid-compression). The dict must be shipped
|
|
110
|
-
AFTER compression but BEFORE the wire write, so the receiver
|
|
111
|
-
has the dict before seeing frames that use it:
|
|
112
|
-
|
|
113
|
-
```ruby
|
|
114
|
-
def write_message(parts)
|
|
115
|
-
compressed = @codec.compress_parts(parts) # may trigger training
|
|
116
|
-
ship_dict! # ships if newly trained
|
|
117
|
-
__getobj__.write_message(compressed)
|
|
118
|
-
end
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
## Training heuristics
|
|
122
|
-
|
|
123
|
-
- **Sample threshold**: 1000 messages OR 100 KiB of plaintext, whichever first
|
|
124
|
-
- **Sample size cap**: frames > 1024 bytes are skipped (dictionaries primarily benefit small frames)
|
|
125
|
-
- **Dict capacity**: 8 KiB (conservative; Zstd recommends ~100:1 sample-to-dict ratio)
|
|
126
|
-
- **Dict ID patching**: auto-trained dicts get a random ID in the user range (32768..2^31-1) to avoid collisions with Zstd's built-in dict IDs
|
|
127
|
-
- **Training failure**: if `Zrip::DictTrainer#train` raises, training is disabled permanently for the socket. No retry.
|
|
128
|
-
|
|
129
|
-
## Frame dispatch
|
|
130
|
-
|
|
131
|
-
Three sentinels for per-part decoding:
|
|
132
|
-
|
|
133
|
-
| Preamble (4 bytes hex) | Meaning |
|
|
134
|
-
|---|---|
|
|
135
|
-
| `00 00 00 00` | Uncompressed plaintext (part too small or incompressible) |
|
|
136
|
-
| `28 B5 2F FD` | Zstd compressed frame (the standard Zstd magic number) |
|
|
137
|
-
| `37 A4 30 EC` | Zstd dictionary — install into per-connection recv slot |
|
|
138
|
-
|
|
139
|
-
Dict frames are single-part ZMTP messages. When all parts in a message
|
|
140
|
-
are dict frames, `#decode_parts` returns `nil` and `#receive_message`
|
|
141
|
-
loops to get the next real message.
|
|
142
|
-
|
|
143
|
-
## Budget enforcement
|
|
144
|
-
|
|
145
|
-
The receiver tracks a per-message decompressed byte budget derived from
|
|
146
|
-
`max_message_size`. Each part's declared `Frame_Content_Size` is checked
|
|
147
|
-
BEFORE decompression. The budget decreases across parts of a multipart
|
|
148
|
-
message, so the total decompressed size can't exceed the limit even if
|
|
149
|
-
individual parts are within bounds.
|
|
150
|
-
|
|
151
|
-
## Constants
|
|
152
|
-
|
|
153
|
-
```
|
|
154
|
-
MAX_DICT_SIZE = 64 KiB (reject oversized dicts)
|
|
155
|
-
DICT_CAPACITY = 8 KiB (training target size)
|
|
156
|
-
TRAIN_MAX_SAMPLES = 1000
|
|
157
|
-
TRAIN_MAX_BYTES = 100 KiB
|
|
158
|
-
TRAIN_MAX_SAMPLE_LEN = 1024 (skip large frames for training)
|
|
159
|
-
MIN_COMPRESS_NO_DICT = 512 B
|
|
160
|
-
MIN_COMPRESS_WITH_DICT = 64 B
|
|
161
|
-
```
|
|
48
|
+
Encrypted mechanisms are rejected on compressed endpoints.
|
data/README.md
CHANGED
|
@@ -11,8 +11,8 @@ Use [`omq-lz4`](../omq-lz4) for CPU- or memory-sensitive compressed TCP
|
|
|
11
11
|
work. This gem remains for research, comparison, OMQ.rs parity, and cases
|
|
12
12
|
where zstd ratio matters more than transport simplicity.
|
|
13
13
|
|
|
14
|
-
Pick `zstd+tcp://` instead of `tcp://`
|
|
15
|
-
compressed
|
|
14
|
+
Pick `zstd+tcp://` instead of `tcp://` for individual parts or batches
|
|
15
|
+
compressed with [Zstandard](https://github.com/facebook/zstd).
|
|
16
16
|
Compression is intrinsic to the transport: no negotiation, no socket option,
|
|
17
17
|
no payload changes. The ZMTP handshake runs over plain TCP. Only
|
|
18
18
|
post-handshake message parts are compressed.
|
|
@@ -20,6 +20,19 @@ post-handshake message parts are compressed.
|
|
|
20
20
|
See [RFC.md](RFC.md) for the wire-format specification and
|
|
21
21
|
[DESIGN.md](DESIGN.md) for the implementation rationale.
|
|
22
22
|
|
|
23
|
+
Revision 2 `ZSTA` arenas are supported, including raw and compressed modes
|
|
24
|
+
and dictionaries. Each arena is validated completely before delivery and
|
|
25
|
+
is limited to 64 KiB, 256 messages, and 4096 parts. `max_message_size`
|
|
26
|
+
applies separately to each decoded message, charging payload bytes plus
|
|
27
|
+
64 bytes per part for storage. Message order and receive HWM apply to the
|
|
28
|
+
individual messages inside an arena.
|
|
29
|
+
|
|
30
|
+
The sender batches already queued small messages, targeting 32 KiB per arena.
|
|
31
|
+
It never waits to fill a batch. Subscription filtering and queue admission
|
|
32
|
+
happen before batching. Isolated or larger messages use per-part encoding.
|
|
33
|
+
Both peers must support revision 2; no revision negotiation is performed.
|
|
34
|
+
Encrypted mechanisms such as CURVE cannot be combined with this transport.
|
|
35
|
+
|
|
23
36
|
## Install
|
|
24
37
|
|
|
25
38
|
```ruby
|
data/RFC.md
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Field | Value |
|
|
4
4
|
|----------|----------------------------------------------------|
|
|
5
5
|
| Status | Draft |
|
|
6
|
+
| Revision | 2 |
|
|
6
7
|
| Editor | Patrik Wenger |
|
|
7
8
|
| Scheme | `zstd+tcp://` |
|
|
8
9
|
| Requires | [RFC 37/ZMTP 3.1](https://rfc.zeromq.org/spec/37/) |
|
|
@@ -11,13 +12,14 @@
|
|
|
11
12
|
## 1. Abstract
|
|
12
13
|
|
|
13
14
|
This specification defines `zstd+tcp://`, a TCP transport for ZMTP 3.1
|
|
14
|
-
that applies
|
|
15
|
+
that applies Zstandard compression to parts or bounded arenas of complete
|
|
16
|
+
messages after the ZMTP handshake.
|
|
15
17
|
Both peers use the `zstd+tcp://` scheme in their endpoint URIs. The ZMTP
|
|
16
18
|
greeting and handshake proceed over raw TCP exactly as they would over
|
|
17
19
|
`tcp://`. After the handshake completes, every message part on the wire
|
|
18
|
-
|
|
19
|
-
distinguishes uncompressed plaintext, Zstandard-compressed frames,
|
|
20
|
-
dictionary shipments. No ZMTP properties, command frames, or
|
|
20
|
+
uses a 4-byte sentinel dispatch that
|
|
21
|
+
distinguishes uncompressed plaintext, Zstandard-compressed frames,
|
|
22
|
+
dictionary shipments, and arenas. No ZMTP properties, command frames, or
|
|
21
23
|
negotiation are involved. Compression is an intrinsic property of the
|
|
22
24
|
transport, like encryption is an intrinsic property of TLS.
|
|
23
25
|
|
|
@@ -102,10 +104,10 @@ per-subscriber CPU dominates the total budget.
|
|
|
102
104
|
### 4.1 Goals
|
|
103
105
|
|
|
104
106
|
- Transparent to application code: send/receive operations see plaintext.
|
|
105
|
-
- Per-part sender decision: opt out for short or incompressible
|
|
107
|
+
- Per-part or per-arena sender decision: opt out for short or incompressible data.
|
|
106
108
|
- Works for legacy multipart socket types (PUSH/PULL, PUB/SUB, ...) and
|
|
107
109
|
draft single-frame types alike.
|
|
108
|
-
- Small-message-friendly via an optional shared dictionary, either
|
|
110
|
+
- Small-message-friendly via arenas and an optional shared dictionary, either
|
|
109
111
|
supplied out of band or automatically trained from early traffic.
|
|
110
112
|
- No ZMTP-level negotiation, no new READY properties, no new command
|
|
111
113
|
frames.
|
|
@@ -119,8 +121,8 @@ per-subscriber CPU dominates the total budget.
|
|
|
119
121
|
compression is pure overhead; `ipc://` rarely benefits).
|
|
120
122
|
- Replacing or weakening CurveZMQ or any other security mechanism.
|
|
121
123
|
See Sec. 9.
|
|
122
|
-
- Streaming / context-takeover compression.
|
|
123
|
-
|
|
124
|
+
- Streaming / context-takeover compression. Parts and arenas have no
|
|
125
|
+
dependency on earlier compression history beyond the installed dictionary.
|
|
124
126
|
|
|
125
127
|
|
|
126
128
|
## 5. Terminology
|
|
@@ -133,14 +135,14 @@ per-subscriber CPU dominates the total budget.
|
|
|
133
135
|
| Compressed part | A wire part whose first 4 bytes are the Zstandard magic `28 B5 2F FD`. |
|
|
134
136
|
| Dictionary part | A wire part whose first 4 bytes are `37 A4 30 EC` (Sec. 7). |
|
|
135
137
|
| Dictionary message | A single-part ZMTP message consisting of exactly one dictionary part. |
|
|
138
|
+
| Arena | A bounded single-part carrier containing complete logical messages. |
|
|
136
139
|
|
|
137
140
|
|
|
138
141
|
## 6. Part Encoding
|
|
139
142
|
|
|
140
|
-
After the ZMTP handshake
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
part is independent.
|
|
143
|
+
After the ZMTP handshake, data uses per-part encoding or an arena (Sec. 6.7).
|
|
144
|
+
Per-part encoding carries the ZMTP MORE flag normally; each part is independent.
|
|
145
|
+
An arena carries its logical multipart boundaries inside one outer frame.
|
|
144
146
|
|
|
145
147
|
### 6.1 Sentinel dispatch
|
|
146
148
|
|
|
@@ -151,16 +153,18 @@ The first 4 bytes of each wire part determine how it is decoded.
|
|
|
151
153
|
| `00 00 00 00` | Uncompressed plaintext (Sec. 6.3) |
|
|
152
154
|
| `28 B5 2F FD` | Zstandard compressed frame (Sec. 6.4)|
|
|
153
155
|
| `37 A4 30 EC` | Dictionary shipment (Sec. 7) |
|
|
156
|
+
| `5A 53 54 41` | `ZSTA` arena (Sec. 6.7) |
|
|
154
157
|
|
|
155
158
|
All other 4-byte values are reserved. A receiver that encounters an
|
|
156
159
|
unknown sentinel MUST close the connection.
|
|
157
160
|
|
|
158
161
|
### 6.2 Compression level
|
|
159
162
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
This specification suggests **level 1** as an implementation default.
|
|
164
|
+
This keeps encode throughput high while improving wire ratio over
|
|
165
|
+
negative fast levels. This is not a wire requirement. Implementations
|
|
166
|
+
may choose any default level that fits their deployment goals, and
|
|
167
|
+
bandwidth-constrained deployments may choose higher levels such as 3.
|
|
164
168
|
|
|
165
169
|
The compression level is a sender choice and is not communicated on the
|
|
166
170
|
wire. The receiver decodes any valid Zstandard frame regardless of the
|
|
@@ -252,13 +256,73 @@ For each incoming wire part, the receiver proceeds as follows:
|
|
|
252
256
|
|
|
253
257
|
4. Sentinel `37 A4 30 EC`: dictionary shipment. See Sec. 7.
|
|
254
258
|
|
|
255
|
-
5.
|
|
259
|
+
5. Sentinel `5A 53 54 41`: arena. See Sec. 6.7.
|
|
256
260
|
|
|
257
|
-
|
|
261
|
+
6. Any other sentinel: close the connection.
|
|
262
|
+
|
|
263
|
+
The outer framer MUST allow the bounded arena envelope independently of
|
|
264
|
+
the logical message limit. The maximum message size refers to **decompressed** plaintext
|
|
258
265
|
summed across all parts of a multipart message. A multipart message
|
|
259
|
-
|
|
260
|
-
|
|
266
|
+
in per-part encoding whose total decompressed size exceeds the limit MUST
|
|
267
|
+
be rejected before decoder invocation. Arenas use the separate bounds below.
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
### 6.7 Arena (`ZSTA`)
|
|
271
|
+
|
|
272
|
+
An arena is one single-part, non-command outer ZMTP message:
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
ZSTA | mode u8 | reserved[3] | decoded_size u32 LE | messages u32 LE | data
|
|
276
|
+
```
|
|
261
277
|
|
|
278
|
+
The header is 16 bytes. `mode = 0` carries raw arena bytes; `mode = 1`
|
|
279
|
+
carries one Zstandard frame, using the installed dictionary if present.
|
|
280
|
+
The frame MUST carry `Frame_Content_Size` equal to `decoded_size`.
|
|
281
|
+
Reserved bytes MUST be zero; other modes MUST close the connection.
|
|
282
|
+
|
|
283
|
+
Decoded data concatenates ordinary ZMTP data frames: flags, short or long
|
|
284
|
+
length, then payload. MORE chains parts of one logical message; a clear
|
|
285
|
+
MORE ends that message. Commands, reserved flag bits, incomplete headers,
|
|
286
|
+
truncated bodies, unfinished multipart messages, and trailing bytes are
|
|
287
|
+
invalid. Payloads are application bytes, with no inner compression sentinels.
|
|
288
|
+
|
|
289
|
+
Protocol limits, checked before decompression:
|
|
290
|
+
|
|
291
|
+
- `1 <= decoded_size <= 65536` bytes, including inner frame headers.
|
|
292
|
+
- `1 <= messages <= 256`.
|
|
293
|
+
- The outer payload MUST NOT exceed 65552 bytes, including its header.
|
|
294
|
+
- Raw data length MUST equal `decoded_size`; compressed data MUST decode
|
|
295
|
+
to exactly that length, with no unused compressed input.
|
|
296
|
+
|
|
297
|
+
The receiver MUST validate the complete decoded arena before exposing any
|
|
298
|
+
message. It MUST contain exactly `messages` complete messages and at most
|
|
299
|
+
4096 parts total. Apply `max_message_size` separately to each logical
|
|
300
|
+
message, including the implementation's per-part storage charge; the arena
|
|
301
|
+
size is a separate bound, never one application's message budget.
|
|
302
|
+
|
|
303
|
+
Receivers MAY retain one decoded arena and deliver it incrementally.
|
|
304
|
+
Delivery MUST preserve message order, message-level HWM/rate accounting,
|
|
305
|
+
and fair scheduling. Backpressure MUST retain the unread suffix. An arena
|
|
306
|
+
is transport overhead and MUST NOT appear as an application message.
|
|
307
|
+
|
|
308
|
+
### 6.8 Arena sender rules
|
|
309
|
+
|
|
310
|
+
Senders MAY batch already queued small messages. They MUST bound both
|
|
311
|
+
bytes and message count and MUST NOT wait for additional messages to fill
|
|
312
|
+
an arena. The recommended target is 32 KiB of decoded frame bytes;
|
|
313
|
+
isolated or larger messages MAY use the existing per-part encodings.
|
|
314
|
+
|
|
315
|
+
Apply the compression threshold to the whole arena. Use mode 0 below
|
|
316
|
+
threshold or when the compressed block is no smaller than the raw arena.
|
|
317
|
+
Each arena is independent of earlier arenas. A dictionary shipment MUST
|
|
318
|
+
precede any arena that uses it; dictionary changes cannot occur inside
|
|
319
|
+
an arena. Training MAY sample logical messages before encoding the arena.
|
|
320
|
+
|
|
321
|
+
Fan-out senders MUST filter and admit logical messages before sharing an
|
|
322
|
+
arena. Recipients sharing compressed bytes MUST receive the same message
|
|
323
|
+
sequence and dictionary. Limited credit or message-level eviction MAY
|
|
324
|
+
require smaller arenas or per-message encoding; batching MUST NOT change
|
|
325
|
+
the socket's drop policy or allow a slow peer to starve ready peers.
|
|
262
326
|
|
|
263
327
|
## 7. Dictionary Shipment
|
|
264
328
|
|
|
@@ -301,7 +365,7 @@ dictionary-load operation.
|
|
|
301
365
|
per connection. A receiver that receives a second dictionary message
|
|
302
366
|
on the same connection MUST close the connection.
|
|
303
367
|
|
|
304
|
-
- A dictionary message MUST be sent BEFORE any compressed part that
|
|
368
|
+
- A dictionary message MUST be sent BEFORE any compressed part or arena that
|
|
305
369
|
references the dictionary. In practice this means the sender ships
|
|
306
370
|
the dictionary before (or immediately after training triggers during)
|
|
307
371
|
the first compressed write that would benefit from it.
|
|
@@ -367,8 +431,9 @@ support auto-training:
|
|
|
367
431
|
| Max sample length | 2048 bytes |
|
|
368
432
|
| Training algorithm | FastCOVER |
|
|
369
433
|
|
|
370
|
-
|
|
371
|
-
|
|
434
|
+
Whether auto-training is enabled by default is an implementation
|
|
435
|
+
choice. Applications MAY disable it or supply an out-of-band
|
|
436
|
+
dictionary instead.
|
|
372
437
|
|
|
373
438
|
### 7.6 Dictionary ID
|
|
374
439
|
|
|
@@ -402,8 +467,8 @@ negotiation in the READY handshake is unaffected.
|
|
|
402
467
|
|
|
403
468
|
### 8.4 Peer requirement
|
|
404
469
|
|
|
405
|
-
Both peers
|
|
406
|
-
fallback to plaintext TCP and no negotiation. A `zstd+tcp://` peer
|
|
470
|
+
Both peers MUST use `zstd+tcp://` and support revision 2 arenas. There is no
|
|
471
|
+
fallback to older revisions or plaintext TCP, and no negotiation. A `zstd+tcp://` peer
|
|
407
472
|
connecting to a plain `tcp://` peer (or vice versa) will see garbled
|
|
408
473
|
data or sentinel errors and the connection will fail.
|
|
409
474
|
|
|
@@ -425,10 +490,11 @@ opt-in.
|
|
|
425
490
|
|
|
426
491
|
### 9.2 Length side-channel
|
|
427
492
|
|
|
428
|
-
Compression makes the wire length of a part depend on its content. An
|
|
493
|
+
Compression makes the wire length of a part or arena depend on its content. An
|
|
429
494
|
on-path observer can learn something about the plaintext from the
|
|
430
495
|
compressed length alone. Deployments that care about traffic analysis
|
|
431
|
-
MUST NOT rely on `zstd+tcp://` to hide payload shape.
|
|
496
|
+
MUST NOT rely on `zstd+tcp://` to hide payload shape. Arena messages share
|
|
497
|
+
compression history; separate logical messages are not separate compression contexts.
|
|
432
498
|
|
|
433
499
|
### 9.3 Dictionary contents
|
|
434
500
|
|
|
@@ -443,9 +509,10 @@ received dictionaries across connections.
|
|
|
443
509
|
A small compressed frame can decompress to many megabytes of plaintext.
|
|
444
510
|
The receiver rules in Sec. 6.6 mitigate this:
|
|
445
511
|
|
|
446
|
-
1. Every compressed part MUST carry `Frame_Content_Size`. The receiver
|
|
512
|
+
1. Every compressed part or arena MUST carry `Frame_Content_Size`. The receiver
|
|
447
513
|
checks the declared total against the maximum message size before
|
|
448
|
-
invoking the decoder
|
|
514
|
+
invoking the decoder for per-part encoding. Arenas instead enforce the
|
|
515
|
+
64 KiB decoded bound first, then validate every logical message before delivery.
|
|
449
516
|
2. The decoder is invoked in bounded mode. It aborts if it would write
|
|
450
517
|
more bytes than declared. A peer that lies in the header cannot
|
|
451
518
|
expand a part past its declared size.
|
|
@@ -462,7 +529,11 @@ unbounded.
|
|
|
462
529
|
| Uncompressed sentinel | `00 00 00 00` |
|
|
463
530
|
| Zstd frame sentinel | `28 B5 2F FD` (Zstandard frame magic) |
|
|
464
531
|
| Dictionary sentinel | `37 A4 30 EC` |
|
|
465
|
-
|
|
|
532
|
+
| Arena sentinel | `5A 53 54 41` (`ZSTA`) |
|
|
533
|
+
| Arena header | 16 bytes |
|
|
534
|
+
| Maximum decoded arena | 65536 bytes |
|
|
535
|
+
| Maximum arena messages/parts | 256 / 4096 |
|
|
536
|
+
| Suggested default level | 1 |
|
|
466
537
|
| Min compress, no dict | 512 bytes |
|
|
467
538
|
| Min compress, with dict | 64 bytes |
|
|
468
539
|
| Max dictionary size | 8 KiB |
|
|
@@ -480,4 +551,4 @@ unbounded.
|
|
|
480
551
|
- [Zstandard dictionary builder](https://github.com/facebook/zstd/blob/dev/lib/dictBuilder/zdict.h)
|
|
481
552
|
- [CRIME attack](https://en.wikipedia.org/wiki/CRIME)
|
|
482
553
|
- [BREACH attack](https://en.wikipedia.org/wiki/BREACH)
|
|
483
|
-
- [`lz4+tcp://` RFC](
|
|
554
|
+
- [`lz4+tcp://` RFC](lz4-rfc.md)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "omq/transport/compression_arena"
|
|
4
|
+
|
|
3
5
|
module OMQ
|
|
4
6
|
module Transport
|
|
5
7
|
module ZstdTcp
|
|
@@ -11,10 +13,12 @@ module OMQ
|
|
|
11
13
|
TRAIN_MAX_SAMPLE_LEN = 2048
|
|
12
14
|
MIN_COMPRESS_NO_DICT = 512
|
|
13
15
|
MIN_COMPRESS_WITH_DICT = 64
|
|
16
|
+
ARENA_TARGET_BYTES = 32 * 1024
|
|
14
17
|
|
|
15
|
-
NUL_PREAMBLE
|
|
18
|
+
NUL_PREAMBLE = ("\x00" * 4).b.freeze
|
|
16
19
|
ZSTD_MAGIC = "\x28\xB5\x2F\xFD".b.freeze
|
|
17
20
|
ZDICT_MAGIC = "\x37\xA4\x30\xEC".b.freeze
|
|
21
|
+
ARENA_MAGIC = "ZSTA".b.freeze
|
|
18
22
|
|
|
19
23
|
USER_DICT_ID_RANGE = (32_768..(2**31 - 1)).freeze
|
|
20
24
|
|
|
@@ -62,6 +66,21 @@ module OMQ
|
|
|
62
66
|
end
|
|
63
67
|
|
|
64
68
|
|
|
69
|
+
def compress_messages(messages)
|
|
70
|
+
CompressionArena.each_batch(messages, target: ARENA_TARGET_BYTES).map do |batch|
|
|
71
|
+
if batch.size == 1
|
|
72
|
+
compress_parts(batch.first)
|
|
73
|
+
else
|
|
74
|
+
batch.each { |parts| parts.each { |part| maybe_train!(part) } }
|
|
75
|
+
threshold = @send_dict_bytes ? MIN_COMPRESS_WITH_DICT : MIN_COMPRESS_NO_DICT
|
|
76
|
+
[CompressionArena.encode(batch, sentinel: ARENA_MAGIC, threshold: threshold) do |data|
|
|
77
|
+
@send_codec.compress(data)
|
|
78
|
+
end]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
|
|
65
84
|
def parse_frame_content_size(wire)
|
|
66
85
|
return nil if wire.bytesize < 5
|
|
67
86
|
|
|
@@ -92,6 +111,33 @@ module OMQ
|
|
|
92
111
|
end
|
|
93
112
|
|
|
94
113
|
|
|
114
|
+
# Zrip accepts unused trailing bytes. Walk the frame's block headers
|
|
115
|
+
# so each transport part contains exactly one complete Zstd frame.
|
|
116
|
+
def validate_frame!(wire)
|
|
117
|
+
raise ProtocolError, "truncated Zstd frame" if wire.bytesize < 5
|
|
118
|
+
|
|
119
|
+
descriptor = wire.getbyte(4)
|
|
120
|
+
single = (descriptor & 0x20) != 0
|
|
121
|
+
fcs_bytes = [single ? 1 : 0, 2, 4, 8][descriptor >> 6]
|
|
122
|
+
offset = 5 + (single ? 0 : 1) + [0, 1, 2, 4][descriptor & 3] + fcs_bytes
|
|
123
|
+
|
|
124
|
+
loop do
|
|
125
|
+
raise ProtocolError, "truncated Zstd block header" if offset + 3 > wire.bytesize
|
|
126
|
+
|
|
127
|
+
header = wire.getbyte(offset) | (wire.getbyte(offset + 1) << 8) | (wire.getbyte(offset + 2) << 16)
|
|
128
|
+
type = (header >> 1) & 3
|
|
129
|
+
raise ProtocolError, "reserved Zstd block type" if type == 3
|
|
130
|
+
|
|
131
|
+
offset += 3 + (type == 1 ? 1 : header >> 3)
|
|
132
|
+
raise ProtocolError, "truncated Zstd block" if offset > wire.bytesize
|
|
133
|
+
break unless (header & 1).zero?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
offset += 4 unless (descriptor & 4).zero?
|
|
137
|
+
raise ProtocolError, "Zstd frame has trailing or missing bytes" unless offset == wire.bytesize
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
|
|
95
141
|
private
|
|
96
142
|
|
|
97
143
|
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "omq/transport/compressed_connection"
|
|
4
4
|
|
|
5
5
|
module OMQ
|
|
6
6
|
module Transport
|
|
7
7
|
module ZstdTcp
|
|
8
|
-
class ZstdConnection <
|
|
8
|
+
class ZstdConnection < CompressedConnection
|
|
9
9
|
# @return [Integer, nil] wire bytesize of the last received message
|
|
10
10
|
#
|
|
11
11
|
attr_reader :last_wire_size_in
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
def initialize(conn, codec)
|
|
15
|
-
super(conn
|
|
15
|
+
super(conn, max_message_size: codec.max_message_size,
|
|
16
|
+
arena_sentinel: Codec::ARENA_MAGIC, protocol_error: ProtocolError)
|
|
16
17
|
@codec = codec
|
|
17
18
|
@dict_shipped = false
|
|
18
19
|
# FrameCodec is the decoder. Starts no-dict; when a dict
|
|
@@ -40,31 +41,22 @@ module OMQ
|
|
|
40
41
|
|
|
41
42
|
|
|
42
43
|
def write_messages(messages)
|
|
43
|
-
compressed =
|
|
44
|
+
compressed = @codec.compress_messages(messages)
|
|
44
45
|
ship_dict!
|
|
45
46
|
__getobj__.write_messages(compressed)
|
|
46
47
|
end
|
|
47
48
|
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
loop do
|
|
51
|
-
parts = __getobj__.receive_message
|
|
52
|
-
decoded = decode_parts(parts)
|
|
53
|
-
if decoded
|
|
54
|
-
@last_wire_size_in = parts.sum { |p| p.bytesize }
|
|
55
|
-
return decoded
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
50
|
+
private
|
|
60
51
|
|
|
61
|
-
def respond_to?(name, include_private = false)
|
|
62
|
-
return false if name == :write_wire
|
|
63
|
-
super
|
|
64
|
-
end
|
|
65
52
|
|
|
53
|
+
def decompress_arena(data, size)
|
|
54
|
+
unless data.start_with?(Codec::ZSTD_MAGIC) && @codec.parse_frame_content_size(data) == size
|
|
55
|
+
raise ProtocolError, "arena Frame_Content_Size mismatch"
|
|
56
|
+
end
|
|
66
57
|
|
|
67
|
-
|
|
58
|
+
decode_zstd_frame(data, size)
|
|
59
|
+
end
|
|
68
60
|
|
|
69
61
|
|
|
70
62
|
def ship_dict!
|
|
@@ -84,6 +76,10 @@ module OMQ
|
|
|
84
76
|
all_dicts = true
|
|
85
77
|
|
|
86
78
|
parts.each do |wire|
|
|
79
|
+
if wire.start_with?(Codec::ZDICT_MAGIC) && parts.size != 1
|
|
80
|
+
raise ProtocolError, "dictionary must be a single-part message"
|
|
81
|
+
end
|
|
82
|
+
|
|
87
83
|
plaintext = decode_part(wire, budget)
|
|
88
84
|
if plaintext
|
|
89
85
|
all_dicts = false
|
|
@@ -125,10 +121,12 @@ module OMQ
|
|
|
125
121
|
raise ProtocolError, "declared FCS #{fcs} exceeds limit #{budget}"
|
|
126
122
|
end
|
|
127
123
|
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
@codec.validate_frame!(wire)
|
|
130
125
|
codec = frame_has_dict_id?(wire) ? @recv_codec : @recv_no_dict_codec
|
|
131
|
-
codec.decompress(wire,
|
|
126
|
+
plaintext = codec.decompress(wire, max_output_size: fcs)
|
|
127
|
+
raise ProtocolError, "Zstd decoded size mismatch" unless plaintext.bytesize == fcs
|
|
128
|
+
|
|
129
|
+
plaintext
|
|
132
130
|
rescue Zrip::DecompressError => e
|
|
133
131
|
raise ProtocolError, "decompression failed: #{e.message}"
|
|
134
132
|
rescue Zrip::MissingContentSizeError => e
|
|
@@ -146,6 +144,8 @@ module OMQ
|
|
|
146
144
|
|
|
147
145
|
|
|
148
146
|
def install_recv_dict(wire)
|
|
147
|
+
raise ProtocolError, "second dictionary shipment on the same direction" if @recv_dict_bytes
|
|
148
|
+
|
|
149
149
|
if wire.bytesize < 8
|
|
150
150
|
raise ProtocolError, "dict frame too short"
|
|
151
151
|
end
|
|
@@ -154,7 +154,11 @@ module OMQ
|
|
|
154
154
|
raise ProtocolError, "dict exceeds #{Codec::MAX_DICT_SIZE} bytes"
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
@recv_codec
|
|
157
|
+
@recv_codec = begin
|
|
158
|
+
Zrip::FrameCodec.new(dict: wire.b)
|
|
159
|
+
rescue RuntimeError => error
|
|
160
|
+
raise ProtocolError, "invalid Zstd dictionary: #{error.message}"
|
|
161
|
+
end
|
|
158
162
|
@recv_dict_bytes = wire.b
|
|
159
163
|
end
|
|
160
164
|
|
|
@@ -29,6 +29,7 @@ module OMQ
|
|
|
29
29
|
# @return [Listener]
|
|
30
30
|
#
|
|
31
31
|
def listener(endpoint, engine, level: -3, dict: nil, auto_dict: nil, **)
|
|
32
|
+
validate_security!(engine)
|
|
32
33
|
validate_auto_dict!(auto_dict, dict)
|
|
33
34
|
codec = codec_for(
|
|
34
35
|
engine,
|
|
@@ -65,6 +66,7 @@ module OMQ
|
|
|
65
66
|
# @return [Dialer]
|
|
66
67
|
#
|
|
67
68
|
def dialer(endpoint, engine, level: -3, dict: nil, auto_dict: nil, **)
|
|
69
|
+
validate_security!(engine)
|
|
68
70
|
validate_auto_dict!(auto_dict, dict)
|
|
69
71
|
codec = codec_for(
|
|
70
72
|
engine,
|
|
@@ -109,6 +111,13 @@ module OMQ
|
|
|
109
111
|
private
|
|
110
112
|
|
|
111
113
|
|
|
114
|
+
def validate_security!(engine)
|
|
115
|
+
if engine.options.mechanism&.encrypted?
|
|
116
|
+
raise ArgumentError, "compressed transports do not support encrypted mechanisms"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
|
|
112
121
|
# Returns the shared Codec for this engine (socket), creating one
|
|
113
122
|
# on first call. Stored in a WeakKeyMap keyed by engine — the
|
|
114
123
|
# entry is automatically removed when the engine is GC'd.
|
data/lib/omq/zstd/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: omq-zstd
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Patrik Wenger
|
|
@@ -15,28 +15,28 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.
|
|
18
|
+
version: '0.29'
|
|
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.29'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: zrip
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
29
29
|
requirements:
|
|
30
30
|
- - "~>"
|
|
31
31
|
- !ruby/object:Gem::Version
|
|
32
|
-
version: 0.
|
|
32
|
+
version: 0.2.0
|
|
33
33
|
type: :runtime
|
|
34
34
|
prerelease: false
|
|
35
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
36
36
|
requirements:
|
|
37
37
|
- - "~>"
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
|
-
version: 0.
|
|
39
|
+
version: 0.2.0
|
|
40
40
|
description: Experimental zstd+tcp:// endpoint support for OMQ with per-frame Zstd
|
|
41
41
|
compression, bounded decompression, in-band dictionary shipping, and sender-side
|
|
42
42
|
dictionary training.
|
|
@@ -74,7 +74,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
74
74
|
- !ruby/object:Gem::Version
|
|
75
75
|
version: '0'
|
|
76
76
|
requirements: []
|
|
77
|
-
rubygems_version: 4.0.
|
|
77
|
+
rubygems_version: 4.0.20
|
|
78
78
|
specification_version: 4
|
|
79
79
|
summary: Experimental Zstd+TCP transport for OMQ
|
|
80
80
|
test_files: []
|