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 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
+ [![CI](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml/badge.svg)](https://github.com/zeromq/omq.rb/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/omq-qos?color=e9573f)](https://rubygems.org/gems/omq-qos)
5
+ [![License: ISC](https://img.shields.io/badge/License-ISC-blue.svg)](LICENSE)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%203.3-CC342D?logo=ruby&logoColor=white)](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)