omq-qos 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +15 -0
- data/README.md +101 -0
- data/lib/omq/qos/connection_ext.rb +36 -0
- data/lib/omq/qos/dead_letter.rb +36 -0
- data/lib/omq/qos/dedup_set.rb +69 -0
- data/lib/omq/qos/error_codes.rb +102 -0
- data/lib/omq/qos/hasher.rb +64 -0
- data/lib/omq/qos/lifecycle_ext.rb +106 -0
- data/lib/omq/qos/options_ext.rb +35 -0
- data/lib/omq/qos/peer_registry.rb +146 -0
- data/lib/omq/qos/pending_store.rb +89 -0
- data/lib/omq/qos/retry_scheduler.rb +22 -0
- data/lib/omq/qos/routing_ext.rb +481 -0
- data/lib/omq/qos/socket_ext.rb +173 -0
- data/lib/omq/qos/version.rb +8 -0
- data/lib/omq/qos/zmtp/command_ext.rb +114 -0
- data/lib/omq/qos.rb +307 -0
- metadata +88 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b322c352350486881a5e55890321584ab7ada43df692853909dab67ecc54459e
|
|
4
|
+
data.tar.gz: b6c35d6dc9230d79f0478cd73502c4a0a7591b291b21d27205ebe0c2f728b5e8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 447a1787214ede18698fb24c91714dcd8b08baf2ffe28e0e1ba43cfa2722f9f4146415a5c32b8ef4648db86a43049d21cf8b2a888b5294269efff6b216eddf31
|
|
7
|
+
data.tar.gz: aa9c899206ca43d8791110cb2571d6ee7ba6e14981e06e643852c640939061ab78efd1a997a65ecd913e60e2f14d8df791f227bb87d9680929e602dfc28b6f4f
|
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,101 @@
|
|
|
1
|
+
# OMQ::QoS -- Delivery Guarantees for OMQ
|
|
2
|
+
|
|
3
|
+
[](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/omq-qos)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
|
|
8
|
+
Per-hop delivery guarantees for [OMQ](https://github.com/zeromq/omq.rb),
|
|
9
|
+
inspired by MQTT QoS levels. Adds ACK-based at-least-once delivery using
|
|
10
|
+
xxHash message identification.
|
|
11
|
+
|
|
12
|
+
Experimental. Wire details and retry semantics may change before stable
|
|
13
|
+
release. Use only when you can tolerate protocol churn.
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
require "omq"
|
|
17
|
+
require "omq/qos"
|
|
18
|
+
|
|
19
|
+
push = OMQ::PUSH.new(nil, qos: 1)
|
|
20
|
+
push.connect("tcp://worker-1:5555")
|
|
21
|
+
push.connect("tcp://worker-2:5555")
|
|
22
|
+
push << "reliably delivered"
|
|
23
|
+
# If worker-1 dies, unacked messages retry on worker-2.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## QoS Levels
|
|
27
|
+
|
|
28
|
+
| Level | Name | Behavior |
|
|
29
|
+
|-------|-----------------|-----------------------------------------------|
|
|
30
|
+
| 0 | Fire-and-forget | Default ZMQ behavior (no overhead) |
|
|
31
|
+
| 1 | At-least-once | Receiver ACKs, sender retries on connection loss |
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
`require "omq/qos"` prepends onto OMQ routing strategies. No
|
|
36
|
+
monkey-patching of core send/receive paths. The prepends activate only
|
|
37
|
+
when `qos >= 1`:
|
|
38
|
+
|
|
39
|
+
- **Sender** (PUSH, SCATTER): tracks sent messages in a pending store
|
|
40
|
+
keyed by xxHash digest. An ACK listener reads ACK command frames from
|
|
41
|
+
each peer. On disconnect, unacked messages are re-enqueued for
|
|
42
|
+
delivery to the next peer.
|
|
43
|
+
- **Receiver** (PULL, GATHER): sends an ACK command frame back to the
|
|
44
|
+
sender after each message is received.
|
|
45
|
+
- **REQ/REP**: the reply IS the ACK. At QoS 1, if the connection drops
|
|
46
|
+
before a reply arrives, the request is transparently re-sent to the
|
|
47
|
+
next REP.
|
|
48
|
+
|
|
49
|
+
Fan-out patterns (PUB/SUB, XPUB/XSUB, RADIO/DISH) are deliberately out
|
|
50
|
+
of scope. See the RFC for the rationale.
|
|
51
|
+
|
|
52
|
+
### ACK protocol
|
|
53
|
+
|
|
54
|
+
ACKs are ZMTP command frames (invisible to applications):
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
Name: "ACK" Data: 'x' + XXH64(wire_bytes) # 9 bytes total
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The hash covers raw ZMTP wire bytes (frame headers + bodies), so
|
|
61
|
+
different framings of the same payload produce different digests.
|
|
62
|
+
|
|
63
|
+
### Backpressure
|
|
64
|
+
|
|
65
|
+
Pending (un-ACK'd) messages count toward `send_hwm`. When the pending
|
|
66
|
+
store is full, `send` blocks in the fiber until an ACK arrives or the
|
|
67
|
+
connection drops (which re-enqueues the stuck messages). A misbehaving
|
|
68
|
+
peer that never ACKs will stall the sender rather than grow the store
|
|
69
|
+
unboundedly.
|
|
70
|
+
|
|
71
|
+
### Linger and pending messages
|
|
72
|
+
|
|
73
|
+
`Socket#close` with `linger: 0` discards anything that hasn't yet been
|
|
74
|
+
ACK'd. This is correct but worth calling out: with QoS 1, messages you
|
|
75
|
+
sent just before closing, even successfully written on the wire, can
|
|
76
|
+
still be lost if the ACKs hadn't come back yet. Set `linger` to a
|
|
77
|
+
non-zero value (or `Float::INFINITY`) if you need the close to wait
|
|
78
|
+
for outstanding ACKs.
|
|
79
|
+
|
|
80
|
+
### Zero overhead at QoS 0
|
|
81
|
+
|
|
82
|
+
At QoS 0 (the default), no pending store is created, no ACK commands are
|
|
83
|
+
sent, and no xxHash is computed. The prepended methods check
|
|
84
|
+
`engine.options.qos` and fall through to the original behavior.
|
|
85
|
+
|
|
86
|
+
## Supported socket types
|
|
87
|
+
|
|
88
|
+
| Sender | Receiver | ACK mechanism |
|
|
89
|
+
|----------------|-----------------|---------------------|
|
|
90
|
+
| PUSH / SCATTER | PULL / GATHER | ACK command frame |
|
|
91
|
+
| REQ | REP | Reply = ACK |
|
|
92
|
+
|
|
93
|
+
## Requirements
|
|
94
|
+
|
|
95
|
+
- Ruby >= 3.3
|
|
96
|
+
- [omq](https://github.com/zeromq/omq.rb) >= 0.12
|
|
97
|
+
- [xxhash](https://rubygems.org/gems/xxhash) (C extension)
|
|
98
|
+
|
|
99
|
+
## RFC
|
|
100
|
+
|
|
101
|
+
See [rfc/zmtp-qos.md](rfc/zmtp-qos.md) for the full specification.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class QoS
|
|
5
|
+
# Prepended onto Protocol::ZMTP::Connection so that command frames
|
|
6
|
+
# received during a normal {Connection#receive_message} loop are
|
|
7
|
+
# dispatched to a per-connection QoS handler. Without this, ACK
|
|
8
|
+
# commands sent by the peer would be silently dropped by the recv
|
|
9
|
+
# pump (which skips command frames).
|
|
10
|
+
#
|
|
11
|
+
module ConnectionExt
|
|
12
|
+
attr_accessor :qos_on_command
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Re-reads +@qos_on_command+ on each command frame rather than
|
|
16
|
+
# capturing it once at call-start. The PUSH reaper fires
|
|
17
|
+
# +receive_message+ immediately after handshake — before the
|
|
18
|
+
# routing layer has wired up the QoS handler — so a one-time
|
|
19
|
+
# capture would pin the handler to +nil+ for the lifetime of the
|
|
20
|
+
# connection.
|
|
21
|
+
def receive_message(&block)
|
|
22
|
+
super() do |frame|
|
|
23
|
+
if (handler = @qos_on_command)
|
|
24
|
+
cmd = Protocol::ZMTP::Codec::Command.from_body(frame.body)
|
|
25
|
+
handler.call(cmd)
|
|
26
|
+
end
|
|
27
|
+
block&.call(frame)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
Protocol::ZMTP::Connection.prepend(OMQ::QoS::ConnectionExt)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class QoS
|
|
5
|
+
# Builds a {DeadLetter} for +entry+, resolves the entry's Promise
|
|
6
|
+
# with it, and releases one slot on +semaphore+ so a waiting sender
|
|
7
|
+
# can proceed.
|
|
8
|
+
#
|
|
9
|
+
# No-op if the Promise is already resolved (late ACK after
|
|
10
|
+
# dead-letter, or shutdown racing a sweep).
|
|
11
|
+
#
|
|
12
|
+
# @param entry [PeerRegistry::Entry]
|
|
13
|
+
# @param peer_info [Protocol::ZMTP::PeerInfo]
|
|
14
|
+
# @param reason [Symbol] +:peer_timeout+, +:terminal_nack+,
|
|
15
|
+
# +:retry_exhausted+, or +:socket_closed+
|
|
16
|
+
# @param semaphore [Async::Semaphore, nil] the send-slot semaphore
|
|
17
|
+
# to release; pass +nil+ on shutdown-drain where the whole
|
|
18
|
+
# semaphore is being torn down anyway
|
|
19
|
+
# @param error [Object, nil] NackInfo at QoS 3; nil at QoS 2
|
|
20
|
+
# @return [DeadLetter, nil] the built DeadLetter, or nil if the
|
|
21
|
+
# Promise was already resolved
|
|
22
|
+
def self.dead_letter(entry, peer_info:, reason:, semaphore: nil, error: nil)
|
|
23
|
+
return nil if entry.promise.resolved?
|
|
24
|
+
|
|
25
|
+
dl = DeadLetter.new(
|
|
26
|
+
parts: entry.parts,
|
|
27
|
+
reason: reason,
|
|
28
|
+
peer_info: peer_info,
|
|
29
|
+
error: error,
|
|
30
|
+
)
|
|
31
|
+
entry.promise.resolve(dl)
|
|
32
|
+
semaphore&.release
|
|
33
|
+
dl
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/clock"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
class QoS
|
|
7
|
+
# Per-receiving-connection dedup set for QoS >= 2.
|
|
8
|
+
#
|
|
9
|
+
# An ordered map of +{digest => added_at}+ keyed by the 8-byte XXH64 /
|
|
10
|
+
# SHA-1 digest. Hash insertion order gives us oldest-first eviction
|
|
11
|
+
# for free.
|
|
12
|
+
#
|
|
13
|
+
# Entries are added on message delivery, removed eagerly on CLR from
|
|
14
|
+
# the sender, and swept lazily on TTL expiry via {#sweep}. When a new
|
|
15
|
+
# entry would exceed +capacity+ the oldest entry is evicted — the
|
|
16
|
+
# sender will not retransmit past +dead_letter_timeout+ anyway, so an
|
|
17
|
+
# eviction older than that is safe.
|
|
18
|
+
#
|
|
19
|
+
class DedupSet
|
|
20
|
+
# @param capacity [Integer] typically recv_hwm; maximum entries
|
|
21
|
+
# before oldest-first eviction kicks in
|
|
22
|
+
def initialize(capacity:)
|
|
23
|
+
@capacity = capacity
|
|
24
|
+
@entries = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# @param digest [String] 8-byte binary
|
|
29
|
+
def seen?(digest)
|
|
30
|
+
@entries.key?(digest)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Adds +digest+ and stamps it with the current monotonic clock.
|
|
35
|
+
# Evicts the oldest entry if capacity would be exceeded.
|
|
36
|
+
def add(digest)
|
|
37
|
+
if @entries.size >= @capacity && !@entries.key?(digest)
|
|
38
|
+
@entries.shift
|
|
39
|
+
end
|
|
40
|
+
@entries[digest] = Async::Clock.now
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Removes an entry (called on CLR from the sender).
|
|
45
|
+
def remove(digest)
|
|
46
|
+
@entries.delete(digest)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Drops entries older than +ttl+ seconds at +now+.
|
|
51
|
+
def sweep(now, ttl)
|
|
52
|
+
cutoff = now - ttl
|
|
53
|
+
@entries.delete_if { |_digest, added_at| added_at < cutoff }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# @return [Integer]
|
|
58
|
+
def size
|
|
59
|
+
@entries.size
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# @return [Boolean]
|
|
64
|
+
def empty?
|
|
65
|
+
@entries.empty?
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class QoS
|
|
5
|
+
# NACK error codes and exception mapping for QoS 3.
|
|
6
|
+
#
|
|
7
|
+
# Receiver side: applications raise one of these exceptions from the
|
|
8
|
+
# block passed to +#receive+; the mapped error code travels back to
|
|
9
|
+
# the sender in a NACK command.
|
|
10
|
+
#
|
|
11
|
+
# Sender side: NACK payloads are decoded into {NackInfo} (attached to
|
|
12
|
+
# {DeadLetter#error} when the NACK is terminal or retries are
|
|
13
|
+
# exhausted).
|
|
14
|
+
#
|
|
15
|
+
# The high bit (+0x80+) of the error code byte is the *retryable*
|
|
16
|
+
# flag (see RFC §163–196). Senders MUST respect this flag for unknown
|
|
17
|
+
# codes.
|
|
18
|
+
#
|
|
19
|
+
module ErrorCodes
|
|
20
|
+
CODE_TIMEOUT = 0x81 # retryable
|
|
21
|
+
CODE_BAD_INPUT = 0x02 # terminal
|
|
22
|
+
CODE_INTERNAL = 0x83 # retryable
|
|
23
|
+
CODE_OVERLOADED = 0x84 # retryable
|
|
24
|
+
CODE_REJECTED = 0x05 # terminal
|
|
25
|
+
|
|
26
|
+
RETRYABLE_BIT = 0x80
|
|
27
|
+
MAX_MSG_BYTES = 65_535
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# @param code [Integer]
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def self.retryable?(code)
|
|
33
|
+
(code & RETRYABLE_BIT) != 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Maps an exception to a NACK code + truncated UTF-8 message
|
|
38
|
+
# suitable for the wire.
|
|
39
|
+
#
|
|
40
|
+
# @param exc [Exception]
|
|
41
|
+
# @return [Array(Integer, String)] [code, message bytes]
|
|
42
|
+
def self.exception_to_payload(exc)
|
|
43
|
+
code =
|
|
44
|
+
case exc
|
|
45
|
+
when TimeoutError then CODE_TIMEOUT
|
|
46
|
+
when BadInputError then CODE_BAD_INPUT
|
|
47
|
+
when OverloadedError then CODE_OVERLOADED
|
|
48
|
+
when RejectedError then CODE_REJECTED
|
|
49
|
+
else CODE_INTERNAL
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
msg = (exc.message || "").b
|
|
53
|
+
msg = msg.byteslice(0, MAX_MSG_BYTES) if msg.bytesize > MAX_MSG_BYTES
|
|
54
|
+
[code, msg]
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Raised by a QoS 3 receive block when processing exceeds
|
|
60
|
+
# +processing_timeout+. Retryable (code +0x81+).
|
|
61
|
+
class TimeoutError < StandardError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Raised by the application to indicate malformed or invalid input.
|
|
66
|
+
# Terminal (code +0x02+).
|
|
67
|
+
class BadInputError < StandardError
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# Raised by the application to indicate capacity pressure. Retryable
|
|
72
|
+
# (code +0x84+).
|
|
73
|
+
class OverloadedError < StandardError
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Raised by the application for explicit rejection. Terminal
|
|
78
|
+
# (code +0x05+).
|
|
79
|
+
class RejectedError < StandardError
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# Raised to a QoS 3 REQ sender's fiber when the REP peer returns a
|
|
84
|
+
# NACK. Carries the wire-level code + message so the caller can
|
|
85
|
+
# inspect them.
|
|
86
|
+
class NackError < StandardError
|
|
87
|
+
attr_reader :code
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def initialize(code, message)
|
|
91
|
+
super(message)
|
|
92
|
+
@code = code
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Decoded NACK payload. Attached to {DeadLetter#error} at QoS 3 when
|
|
99
|
+
# the failure is terminal or retries are exhausted.
|
|
100
|
+
NackInfo = Data.define(:code, :message)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "xxhash"
|
|
4
|
+
require "digest/sha1"
|
|
5
|
+
|
|
6
|
+
module OMQ
|
|
7
|
+
class QoS
|
|
8
|
+
# Supported hash algorithms in default preference order.
|
|
9
|
+
# x = XXH64 (8 bytes) — fast, requires xxhash library
|
|
10
|
+
# s = SHA-1 truncated to 64 bits (8 bytes) — available in any standard library
|
|
11
|
+
#
|
|
12
|
+
# All digests are 8 bytes (64 bits). Collision probability within
|
|
13
|
+
# the in-flight window (typically ≤ HWM) is negligible: ~N²/2⁶⁵
|
|
14
|
+
# where N is the number of un-ACK'd messages.
|
|
15
|
+
SUPPORTED_HASH_ALGOS = "xs".freeze
|
|
16
|
+
DEFAULT_HASH_ALGO = "x".freeze
|
|
17
|
+
HASH_SIZE = 8
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Computes an 8-byte digest over raw ZMTP wire bytes.
|
|
21
|
+
#
|
|
22
|
+
# @param parts [Array<String>] message frames
|
|
23
|
+
# @param algorithm [String] "x" (XXH64) or "s" (SHA-1 truncated)
|
|
24
|
+
# @return [String] 8-byte binary digest
|
|
25
|
+
#
|
|
26
|
+
def self.digest(parts, algorithm: DEFAULT_HASH_ALGO)
|
|
27
|
+
wire = Protocol::ZMTP::Codec::Frame.encode_message(parts)
|
|
28
|
+
case algorithm
|
|
29
|
+
when "x"
|
|
30
|
+
[XXhash.xxh64(wire)].pack("Q<")
|
|
31
|
+
when "s"
|
|
32
|
+
Digest::SHA1.digest(wire).byteslice(0, 8)
|
|
33
|
+
else
|
|
34
|
+
raise ArgumentError, "unsupported QoS hash algorithm: #{algorithm.inspect}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Negotiates the hash algorithm for a connection.
|
|
40
|
+
# Returns the first algo in our preference list that the peer supports.
|
|
41
|
+
#
|
|
42
|
+
# @param peer_hash [String] peer's supported algos (e.g. "sx")
|
|
43
|
+
# @return [String, nil] single-char algorithm, or nil if no overlap
|
|
44
|
+
#
|
|
45
|
+
def self.negotiate_hash(peer_hash)
|
|
46
|
+
return DEFAULT_HASH_ALGO if peer_hash.empty?
|
|
47
|
+
SUPPORTED_HASH_ALGOS.each_char do |algo|
|
|
48
|
+
return algo if peer_hash.include?(algo)
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Builds an ACK command for the given message.
|
|
55
|
+
#
|
|
56
|
+
# @param parts [Array<String>] message frames
|
|
57
|
+
# @param algorithm [String] "x" (XXH64) or "s" (SHA-1 truncated)
|
|
58
|
+
# @return [Protocol::ZMTP::Codec::Command]
|
|
59
|
+
#
|
|
60
|
+
def self.ack_command(parts, algorithm: DEFAULT_HASH_ALGO)
|
|
61
|
+
Protocol::ZMTP::Codec::Command.ack(digest(parts, algorithm: algorithm), algorithm: algorithm)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class QoS
|
|
5
|
+
# Prepended onto OMQ::Engine::ConnectionLifecycle so that the
|
|
6
|
+
# Protocol::ZMTP::Connection is built with this socket's QoS
|
|
7
|
+
# metadata, and the peer's QoS properties are validated after the
|
|
8
|
+
# handshake.
|
|
9
|
+
#
|
|
10
|
+
module LifecycleExt
|
|
11
|
+
def handshake!(io, as_server:)
|
|
12
|
+
transition!(:handshaking)
|
|
13
|
+
conn_class =
|
|
14
|
+
if @transport.respond_to?(:connection_class)
|
|
15
|
+
@transport.connection_class
|
|
16
|
+
else
|
|
17
|
+
Protocol::ZMTP::Connection
|
|
18
|
+
end
|
|
19
|
+
conn = conn_class.new io,
|
|
20
|
+
socket_type: @engine.socket_type.to_s,
|
|
21
|
+
identity: @engine.options.identity,
|
|
22
|
+
as_server: as_server,
|
|
23
|
+
mechanism: @engine.options.mechanism&.dup,
|
|
24
|
+
max_message_size: @engine.options.max_message_size,
|
|
25
|
+
**QoS.handshake_metadata(@engine.options)
|
|
26
|
+
|
|
27
|
+
Async::Task.current.with_timeout(handshake_timeout) do
|
|
28
|
+
conn.handshake!
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
QoS.validate_handshake!(@engine.options, conn)
|
|
32
|
+
|
|
33
|
+
OMQ::Engine::Heartbeat.start(@barrier, conn, @engine.options)
|
|
34
|
+
ready!(conn)
|
|
35
|
+
@conn
|
|
36
|
+
rescue Protocol::ZMTP::Error, *OMQ::CONNECTION_LOST, Async::TimeoutError => error
|
|
37
|
+
@engine.emit_monitor_event :handshake_failed,
|
|
38
|
+
endpoint: @endpoint, detail: { error: error }
|
|
39
|
+
|
|
40
|
+
conn&.close
|
|
41
|
+
|
|
42
|
+
tear_down!(reconnect: true)
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Builds the READY-property metadata hash this socket should
|
|
50
|
+
# advertise based on its QoS configuration. Empty hash at QoS 0 so
|
|
51
|
+
# the Connection sees no extras.
|
|
52
|
+
#
|
|
53
|
+
# @param options [OMQ::Options]
|
|
54
|
+
# @return [Hash{String => String}]
|
|
55
|
+
def self.handshake_metadata(options)
|
|
56
|
+
qos = options.qos
|
|
57
|
+
return {} if qos.nil?
|
|
58
|
+
meta = { "X-QoS" => qos.level.to_s }
|
|
59
|
+
meta["X-QoS-Hash"] = qos.hash_algos unless qos.hash_algos.empty?
|
|
60
|
+
meta
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Validates the peer's READY properties against the local QoS
|
|
65
|
+
# configuration. At QoS >= 2 also asserts that the connection has a
|
|
66
|
+
# stable per-peer identity (CURVE pubkey or non-empty ZMQ_IDENTITY)
|
|
67
|
+
# — without one the {PeerRegistry} cannot pin pending messages
|
|
68
|
+
# across reconnects.
|
|
69
|
+
def self.validate_handshake!(options, conn)
|
|
70
|
+
props = conn.peer_properties || {}
|
|
71
|
+
qos = options.qos
|
|
72
|
+
local_lvl = qos&.level || 0
|
|
73
|
+
peer_lvl = (props["X-QoS"] || "0").to_i
|
|
74
|
+
|
|
75
|
+
if local_lvl != peer_lvl
|
|
76
|
+
raise Protocol::ZMTP::Error,
|
|
77
|
+
"QoS mismatch: local=#{local_lvl} peer=#{peer_lvl}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
return if local_lvl == 0
|
|
81
|
+
|
|
82
|
+
local_hash = qos.hash_algos
|
|
83
|
+
peer_hash = props["X-QoS-Hash"] || ""
|
|
84
|
+
|
|
85
|
+
unless local_hash.empty? || peer_hash.empty?
|
|
86
|
+
algo = local_hash.each_char.find { |c| peer_hash.include?(c) }
|
|
87
|
+
unless algo
|
|
88
|
+
raise Protocol::ZMTP::Error,
|
|
89
|
+
"QoS hash algorithm mismatch: local=#{local_hash.inspect} peer=#{peer_hash.inspect}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
return if local_lvl < 2
|
|
94
|
+
|
|
95
|
+
info = conn.peer_info
|
|
96
|
+
if info.nil? || (info.public_key.nil? && info.identity.empty?)
|
|
97
|
+
raise Protocol::ZMTP::Error,
|
|
98
|
+
"QoS #{local_lvl} requires a stable peer anchor " \
|
|
99
|
+
"(CURVE public key or non-empty ZMQ_IDENTITY)"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
OMQ::Engine::ConnectionLifecycle.prepend(OMQ::QoS::LifecycleExt)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OMQ
|
|
4
|
+
class QoS
|
|
5
|
+
# Prepended onto {OMQ::Options} once omq-qos is loaded. Changes the
|
|
6
|
+
# semantics of +Options#qos+ from "an Integer level" to "nil or an
|
|
7
|
+
# {OMQ::QoS} instance":
|
|
8
|
+
#
|
|
9
|
+
# options.qos # => nil (QoS 0, default)
|
|
10
|
+
# options.qos # => <OMQ::QoS level=2 ...>
|
|
11
|
+
# options.qos_level # => 0, 1, 2, or 3 (convenience Integer)
|
|
12
|
+
#
|
|
13
|
+
# The Integer form is still accepted by callers that never loaded
|
|
14
|
+
# omq-qos — core omq keeps the Integer default. Once this extension
|
|
15
|
+
# is installed the default resets to +nil+.
|
|
16
|
+
#
|
|
17
|
+
module OptionsExt
|
|
18
|
+
def initialize(**kwargs)
|
|
19
|
+
super
|
|
20
|
+
@qos = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Convenience: 0 / 1 / 2 / 3. nil-safe.
|
|
25
|
+
#
|
|
26
|
+
# @return [Integer]
|
|
27
|
+
def qos_level
|
|
28
|
+
@qos&.level || 0
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
OMQ::Options.prepend(OMQ::QoS::OptionsExt)
|