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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 22b1f25991e512b445fe5a7202d2671048159b494a7ac5418d1b121f696940ab
4
- data.tar.gz: 941b1c5f85ec8dd1a9816fbfac241f882be1f2e9cd80696b1cf894465e90d884
3
+ metadata.gz: 0adb0f7516e8bfc1df21c5406669d36834d933851dbadb6b41c82d0b961509f7
4
+ data.tar.gz: 112a512e7266d6761e69a0b87bc2b4656afefc63f0b878c0c120e337206c020e
5
5
  SHA512:
6
- metadata.gz: 6525d971daf7707cb46113848b2a70c6e29775f9d7a87c6e9dcbd13cd481eebdd7c6a75c919d629b281e491c3263ec56b2e149bc573e4e0f24df32f042247dab
7
- data.tar.gz: 99d1a5e3d2ce007036a0712fc7912331bbda35437eca0976d55eb58b8d0eedb52fd5a285c9c025fa658c75231ff4e538fb0f1dcd71d0d38a6d67505a72c4ae1e
6
+ metadata.gz: b9ef977a6b1a8a1b08935340bd2efb201aacc6adc20dc51f652a68e238c24d459d8afb9f3294699d931721f16b94afa053267a98dc69525f977abec0d4f9e932
7
+ data.tar.gz: 14e63f2bcbb48f191b13ef2ca3d27b503cd7650e9c1c6b82986c907cdbcfa7de9c6f3e9a644633d8d74ee99513aa147ef1c1545d1904aa964439e05bb4a7cfb3
data/DESIGN.md CHANGED
@@ -1,161 +1,48 @@
1
- # omq-transport-zstd — Implementation Notes
1
+ # Zstd transport architecture
2
2
 
3
- These are implementation details for the `zstd+tcp://` transport plugin.
4
- The wire protocol is specified in [RFC.md](RFC.md); this document covers
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
- ## Why a transport plugin
6
+ ## Send path
8
7
 
9
- Compression could live at three layers. Each has a fatal flaw except the
10
- transport layer.
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
- **Socket-level wrapper** (too high). A wrapper sits above routing and
13
- knows nothing about transports. It can't distinguish TCP from IPC or
14
- inproc, so it compresses local connections (pure overhead). It also
15
- can't act on new connections naturally — dict shipping requires
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
- **ZMTP connection layer** (too low). Embedding compression into each
21
- ZMTP connection means PUB fan-out compresses the same message N times
22
- (once per subscriber connection). The connection layer has no
23
- socket-wide view, so there's no way to share compression work across
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
- **Transport layer** (right). `zstd+tcp://` makes transport selection
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
- ## Architecture
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
- Socket bind("zstd+tcp://...") / connect("zstd+tcp://...")
38
- │ │
39
- ▼ ▼
40
- Engine transport.listener(...) transport.dialer(...)
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
- ### Dialer / Listener
35
+ ## Bounds
53
36
 
54
- Both are stateful transport objects created by the transport module's
55
- `.dialer` / `.listener` factory methods. They hold the Codec and
56
- implement `#wrap_connection(conn)` which wraps raw ZMTP connections
57
- in a `ZstdConnection`. The Engine stores them in `@dialers` / `@listeners`
58
- (Hash keyed by endpoint) and calls `#wrap_connection` during
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
- Reconnect calls `dialer.connect` directly — no transport lookup or
62
- opts replay needed. The Dialer holds everything.
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
- ### Codec (socket-wide)
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://` and every message part on the wire is
15
- compressed per-part with [Zstandard](https://github.com/facebook/zstd).
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 per-part Zstandard compression after the ZMTP handshake.
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
- is individually encoded with a 4-byte sentinel dispatch that
19
- distinguishes uncompressed plaintext, Zstandard-compressed frames, and
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 parts.
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. Each part is decodable in
123
- isolation with no dependency on a previous part's LZ77 history.
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 completes, every message part on the wire is
141
- individually encoded. The ZMTP MORE flag is carried on the wire frame
142
- header as normal. Multipart messages are encoded part by part; each
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
- The default compression level is **-3** (Zstandard fast strategy). At
161
- this level the encoder cost is in the low single-digit microseconds per
162
- kilobyte, and the achieved ratio is within a few percent of level 3 once
163
- a dictionary is in play.
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. Any other sentinel: close the connection.
259
+ 5. Sentinel `5A 53 54 41`: arena. See Sec. 6.7.
256
260
 
257
- The maximum message size always refers to the **decompressed** plaintext
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
- whose total wire length is small but whose total decompressed size
260
- exceeds the limit MUST be rejected before decoder invocation.
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
- Auto-training SHOULD be disabled by default. Applications MAY enable it
371
- explicitly or supply an out-of-band dictionary instead.
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 of a connection MUST use `zstd+tcp://`. There is no
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, so a bomb is rejected on its header alone.
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
- | Default level | -3 |
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](../omq-lz4/RFC.md)
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 = ("\x00" * 4).b.freeze
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 "delegate"
3
+ require "omq/transport/compressed_connection"
4
4
 
5
5
  module OMQ
6
6
  module Transport
7
7
  module ZstdTcp
8
- class ZstdConnection < SimpleDelegator
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 = messages.map { |parts| @codec.compress_parts(parts) }
44
+ compressed = @codec.compress_messages(messages)
44
45
  ship_dict!
45
46
  __getobj__.write_messages(compressed)
46
47
  end
47
48
 
48
49
 
49
- def receive_message
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
- private
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
- decompress_opts = budget ? { max_output_size: budget } : {}
129
-
124
+ @codec.validate_frame!(wire)
130
125
  codec = frame_has_dict_id?(wire) ? @recv_codec : @recv_no_dict_codec
131
- codec.decompress(wire, **decompress_opts)
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 = Zrip::FrameCodec.new(dict: wire.b)
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.
@@ -18,7 +18,7 @@ require_relative "zstd_tcp/transport"
18
18
  module OMQ
19
19
  module Transport
20
20
  module ZstdTcp
21
- class ProtocolError < StandardError; end
21
+ class ProtocolError < Protocol::ZMTP::Error; end
22
22
  end
23
23
  end
24
24
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module OMQ
4
4
  module Zstd
5
- VERSION = "0.4.4"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
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.4
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.28'
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.28'
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.1.1
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.1.1
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.16
77
+ rubygems_version: 4.0.20
78
78
  specification_version: 4
79
79
  summary: Experimental Zstd+TCP transport for OMQ
80
80
  test_files: []