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.
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ class QoS
5
+ # Prepended onto Protocol::ZMTP::Codec::Command's singleton class
6
+ # to add ACK/NACK/CLR/COMP command builders.
7
+ #
8
+ module CommandClassExt
9
+ # Builds an ACK command.
10
+ #
11
+ # @param hash_bytes [String] binary hash digest
12
+ # @param algorithm [String] "x" (XXH64) or "s" (SHA-1 truncated)
13
+ def ack(hash_bytes, algorithm: "x")
14
+ new("ACK", "#{algorithm}#{hash_bytes}".b)
15
+ end
16
+
17
+
18
+ # Builds a NACK command. The on-wire +error_info+ payload is
19
+ # +[1 byte code] [2 bytes big-endian message length] [message]+
20
+ # per the omq-qos RFC §NACK command.
21
+ #
22
+ # @param hash_bytes [String] binary hash digest
23
+ # @param code [Integer] error code byte (bit 7 = retryable)
24
+ # @param message [String] UTF-8 error description (≤ 65535 bytes)
25
+ # @param algorithm [String] "x" (XXH64) or "s" (SHA-1 truncated)
26
+ def nack(hash_bytes, code: 0, message: "", algorithm: "x")
27
+ msg_bytes = message.b
28
+ error_info = [code, msg_bytes.bytesize].pack("Cn") + msg_bytes
29
+ new("NACK", "#{algorithm}#{hash_bytes}#{error_info}".b)
30
+ end
31
+
32
+
33
+ # Builds a CLR ("clear") command. Sent by the sender after
34
+ # receiving an ACK (QoS 2) or COMP (QoS 3) so the receiver may
35
+ # evict the digest from its dedup set immediately instead of
36
+ # waiting for TTL expiry.
37
+ #
38
+ # @param hash_bytes [String] binary hash digest
39
+ # @param algorithm [String] "x" (XXH64) or "s" (SHA-1 truncated)
40
+ def clr(hash_bytes, algorithm: "x")
41
+ new("CLR", "#{algorithm}#{hash_bytes}".b)
42
+ end
43
+
44
+
45
+ # Builds a COMP ("complete") command. Sent by the QoS 3 receiver
46
+ # after its application handler returns successfully.
47
+ #
48
+ # @param hash_bytes [String] binary hash digest
49
+ # @param algorithm [String] "x" (XXH64) or "s" (SHA-1 truncated)
50
+ def comp(hash_bytes, algorithm: "x")
51
+ new("COMP", "#{algorithm}#{hash_bytes}".b)
52
+ end
53
+ end
54
+
55
+
56
+ # Prepended onto Protocol::ZMTP::Codec::Command to add
57
+ # ACK/NACK/CLR/COMP data extraction methods.
58
+ #
59
+ module CommandExt
60
+ # Extracts algorithm prefix and hash bytes from an ACK command's data.
61
+ #
62
+ # @return [Array(String, String)] [algorithm, hash_bytes]
63
+ def ack_data
64
+ algo = @data.byteslice(0, 1)
65
+ hash_size = Protocol::ZMTP::Codec::Command::ACK_HASH_SIZES.fetch(algo, 8)
66
+ [algo, @data.byteslice(1, hash_size)]
67
+ end
68
+
69
+
70
+ # Same layout as ACK.
71
+ #
72
+ # @return [Array(String, String)] [algorithm, hash_bytes]
73
+ def clr_data
74
+ ack_data
75
+ end
76
+
77
+
78
+ # Same layout as ACK — COMP carries only the digest.
79
+ #
80
+ # @return [Array(String, String)] [algorithm, hash_bytes]
81
+ def comp_data
82
+ ack_data
83
+ end
84
+
85
+
86
+ # Extracts algorithm, hash bytes, NACK code, and error message
87
+ # from a NACK command's data. See {CommandClassExt.nack} for the
88
+ # wire layout.
89
+ #
90
+ # @return [Array(String, String, Integer, String)]
91
+ # [algorithm, hash_bytes, code, message]
92
+ def nack_data
93
+ algo = @data.byteslice(0, 1)
94
+ hash_size = Protocol::ZMTP::Codec::Command::ACK_HASH_SIZES.fetch(algo, 8)
95
+ hash = @data.byteslice(1, hash_size)
96
+ off = 1 + hash_size
97
+ header = @data.byteslice(off, 3)
98
+
99
+ if header && header.bytesize == 3
100
+ code, msg_len = header.unpack("Cn")
101
+ message = @data.byteslice(off + 3, msg_len) || "".b
102
+ else
103
+ code, message = 0, "".b
104
+ end
105
+
106
+ [algo, hash, code, message]
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+
113
+ # Known hash digest sizes by algorithm prefix.
114
+ Protocol::ZMTP::Codec::Command::ACK_HASH_SIZES = { "x" => 8, "s" => 8 }.freeze
data/lib/omq/qos.rb ADDED
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ # OMQ QoS — per-hop delivery guarantees for OMQ sockets.
4
+ #
5
+ # Usage:
6
+ # require "omq"
7
+ # require "omq/qos"
8
+ #
9
+ # push = OMQ::PUSH.new
10
+ # push.qos = OMQ::QoS.exactly_once(dead_letter_timeout: 30)
11
+ # push.connect("tcp://127.0.0.1:5555")
12
+ # promise = push.send("guaranteed delivery")
13
+ # case promise.wait
14
+ # in :delivered then :ok
15
+ # in OMQ::QoS::DeadLetter => dl then retry_queue << dl.parts
16
+ # end
17
+
18
+ require "async/semaphore"
19
+
20
+ require "omq"
21
+
22
+ require_relative "qos/version"
23
+ require_relative "qos/zmtp/command_ext"
24
+ require_relative "qos/hasher"
25
+ require_relative "qos/pending_store"
26
+ require_relative "qos/peer_registry"
27
+ require_relative "qos/dedup_set"
28
+ require_relative "qos/dead_letter"
29
+ require_relative "qos/error_codes"
30
+ require_relative "qos/retry_scheduler"
31
+ require_relative "qos/connection_ext"
32
+ require_relative "qos/lifecycle_ext"
33
+ require_relative "qos/routing_ext"
34
+ require_relative "qos/options_ext"
35
+ require_relative "qos/socket_ext"
36
+
37
+ module OMQ
38
+ # Per-hop delivery guarantee handle for a single socket.
39
+ #
40
+ # A {QoS} instance bundles both the user-visible configuration (level,
41
+ # hash-algorithm preference, timeouts, retry caps) and the socket-wide
42
+ # runtime state needed for levels >= 2 ({PeerRegistry}, a bounded-slot
43
+ # {Async::Semaphore} for backpressure, per-connection {DedupSet}s, and
44
+ # the dead-letter sweep task).
45
+ #
46
+ # Construct via the class-method builders — never via `.new(level:)`
47
+ # directly.
48
+ #
49
+ # socket.qos = nil # QoS 0 (default)
50
+ # socket.qos = OMQ::QoS.at_least_once # QoS 1
51
+ # socket.qos = OMQ::QoS.exactly_once(dead_letter_timeout: 30) # QoS 2
52
+ # socket.qos = OMQ::QoS.exactly_once_and_processed( # QoS 3
53
+ # max_retries: 5, processing_timeout: 1.0,
54
+ # )
55
+ #
56
+ # Each socket gets its own instance. The same {QoS} cannot be shared
57
+ # across sockets — {#attach!} rejects a second engine.
58
+ #
59
+ class QoS
60
+ DEFAULT_DEAD_LETTER_TIMEOUT = 60.0
61
+ DEFAULT_DEDUP_TTL = 60.0
62
+ DEFAULT_MAX_RETRIES = 3
63
+ DEFAULT_RETRY_BACKOFF = (0.1..10.0).freeze
64
+
65
+
66
+ # Data outcome pushed through `#send`'s Promise when a message
67
+ # cannot be delivered under the requested guarantee.
68
+ #
69
+ # @!attribute [r] parts
70
+ # @return [Array<String>] the original message frames
71
+ # @!attribute [r] reason
72
+ # @return [Symbol] +:peer_timeout+, +:terminal_nack+, +:retry_exhausted+, +:socket_closed+
73
+ # @!attribute [r] peer_info
74
+ # @return [Protocol::ZMTP::PeerInfo, nil] peer it was pinned to, if any
75
+ # @!attribute [r] error
76
+ # @return [NackInfo, nil] NACK details at QoS 3; nil at QoS 2
77
+ DeadLetter = Data.define(:parts, :reason, :peer_info, :error)
78
+
79
+
80
+ # Builds a QoS 1 (at-least-once) handle.
81
+ #
82
+ # @param hash_algos [String] preference list, e.g. +"xs"+
83
+ # @return [QoS]
84
+ def self.at_least_once(hash_algos: SUPPORTED_HASH_ALGOS)
85
+ new(level: 1, hash_algos: hash_algos)
86
+ end
87
+
88
+
89
+ # Builds a QoS 2 (exactly-once with peer pinning) handle.
90
+ #
91
+ # @param hash_algos [String]
92
+ # @param dead_letter_timeout [Numeric] seconds to wait for a disconnected
93
+ # peer to return before dead-lettering its pending messages
94
+ # @param dedup_ttl [Numeric] seconds a receiver keeps a digest in its
95
+ # dedup set before it may evict it
96
+ # @return [QoS]
97
+ def self.exactly_once(
98
+ hash_algos: SUPPORTED_HASH_ALGOS,
99
+ dead_letter_timeout: DEFAULT_DEAD_LETTER_TIMEOUT,
100
+ dedup_ttl: DEFAULT_DEDUP_TTL
101
+ )
102
+ new(
103
+ level: 2,
104
+ hash_algos: hash_algos,
105
+ dead_letter_timeout: dead_letter_timeout,
106
+ dedup_ttl: dedup_ttl,
107
+ )
108
+ end
109
+
110
+
111
+ # Builds a QoS 3 (exactly-once + application-level COMP/NACK) handle.
112
+ #
113
+ # @param hash_algos [String]
114
+ # @param dead_letter_timeout [Numeric]
115
+ # @param dedup_ttl [Numeric]
116
+ # @param max_retries [Integer] retry cap on retryable NACKs
117
+ # @param processing_timeout [Numeric, nil] per-message receiver handler deadline
118
+ # @param retry_backoff [Range] exponential backoff bounds (min..max, seconds)
119
+ # @return [QoS]
120
+ def self.exactly_once_and_processed(
121
+ hash_algos: SUPPORTED_HASH_ALGOS,
122
+ dead_letter_timeout: DEFAULT_DEAD_LETTER_TIMEOUT,
123
+ dedup_ttl: DEFAULT_DEDUP_TTL,
124
+ max_retries: DEFAULT_MAX_RETRIES,
125
+ processing_timeout: nil,
126
+ retry_backoff: DEFAULT_RETRY_BACKOFF
127
+ )
128
+ new(
129
+ level: 3,
130
+ hash_algos: hash_algos,
131
+ dead_letter_timeout: dead_letter_timeout,
132
+ dedup_ttl: dedup_ttl,
133
+ max_retries: max_retries,
134
+ processing_timeout: processing_timeout,
135
+ retry_backoff: retry_backoff,
136
+ )
137
+ end
138
+
139
+
140
+ attr_reader :level, :hash_algos,
141
+ :dead_letter_timeout, :dedup_ttl,
142
+ :max_retries, :processing_timeout, :retry_backoff
143
+
144
+
145
+ # Constructed only via the class-method builders.
146
+ def initialize(level:,
147
+ hash_algos: SUPPORTED_HASH_ALGOS,
148
+ dead_letter_timeout: nil,
149
+ dedup_ttl: nil,
150
+ max_retries: nil,
151
+ processing_timeout: nil,
152
+ retry_backoff: nil)
153
+ @level = level
154
+ @hash_algos = hash_algos
155
+ @dead_letter_timeout = dead_letter_timeout
156
+ @dedup_ttl = dedup_ttl
157
+ @max_retries = max_retries
158
+ @processing_timeout = processing_timeout
159
+ @retry_backoff = retry_backoff
160
+
161
+ @engine = nil
162
+ @peer_registry = nil
163
+ @send_semaphore = nil
164
+ @dedup_sets = nil
165
+ @sweep_task = nil
166
+ @enqueue_promises = nil
167
+ end
168
+
169
+
170
+ # Registers the {Async::Promise} associated with an outgoing
171
+ # +parts+ array keyed by +parts.object_id+. The routing-layer
172
+ # {RoundRobinExt#write_batch} looks it up (via
173
+ # {#take_enqueue_promise}) right before handing the entry to the
174
+ # {PeerRegistry}. Using object_id on a freshly-frozen Array that
175
+ # lives in the send queue avoids reshaping the send queue itself
176
+ # (which would otherwise need a Data wrapper every consumer has
177
+ # to know about).
178
+ #
179
+ # @param parts [Array<String>]
180
+ # @param promise [Async::Promise]
181
+ def enqueue_promise(parts, promise)
182
+ (@enqueue_promises ||= {})[parts.object_id] = promise
183
+ end
184
+
185
+
186
+ # Removes and returns the Promise previously registered for +parts+.
187
+ # nil if none (e.g. message was enqueued before QoS upgrade — should
188
+ # not happen under the rules enforced by {SocketExt#qos=}).
189
+ def take_enqueue_promise(parts)
190
+ @enqueue_promises&.delete(parts.object_id)
191
+ end
192
+
193
+
194
+ # Binds this QoS handle to a socket's engine. Called by
195
+ # {SocketExt#qos=} once the instance is assigned.
196
+ #
197
+ # @param engine [OMQ::Engine]
198
+ # @return [void]
199
+ # @raise [ArgumentError] if already attached to a (possibly different) engine
200
+ def attach!(engine)
201
+ if @engine && !@engine.equal?(engine)
202
+ raise ArgumentError, "OMQ::QoS instance is already attached to a different socket"
203
+ end
204
+ @engine = engine
205
+ end
206
+
207
+
208
+ # @return [Boolean]
209
+ def attached?
210
+ !@engine.nil?
211
+ end
212
+
213
+
214
+ # Lazily built pending-message registry (QoS >= 2). Keyed by
215
+ # {Protocol::ZMTP::PeerInfo} so entries survive reconnects.
216
+ #
217
+ # @return [PeerRegistry]
218
+ def peer_registry
219
+ @peer_registry ||= PeerRegistry.new(capacity: @engine.options.send_hwm)
220
+ end
221
+
222
+
223
+ # Lazily built bounded-slot semaphore. Bounds the number of
224
+ # in-flight (un-ACK'd / un-COMP'd) messages at +send_hwm+; senders
225
+ # acquire a slot before tracking and release on ACK/COMP/dead-letter.
226
+ #
227
+ # @return [Async::Semaphore]
228
+ def send_semaphore
229
+ @send_semaphore ||= Async::Semaphore.new(@engine.options.send_hwm)
230
+ end
231
+
232
+
233
+ # Lazily built per-connection dedup set for the receive side (QoS >= 2).
234
+ #
235
+ # @param conn [Protocol::ZMTP::Connection]
236
+ # @return [DedupSet]
237
+ def dedup_set_for(conn)
238
+ (@dedup_sets ||= {})[conn] ||= DedupSet.new(capacity: @engine.options.recv_hwm)
239
+ end
240
+
241
+
242
+ # Drops the dedup set for a connection (called on disconnect).
243
+ def forget_dedup_set(conn)
244
+ @dedup_sets&.delete(conn)
245
+ end
246
+
247
+
248
+ # Tears down socket-scoped state. Called from {OMQ::Socket#close}.
249
+ # Resolves any pending Promises with +DeadLetter(reason: :socket_closed)+
250
+ # so no fiber hangs on +#wait+.
251
+ #
252
+ # @return [void]
253
+ def shutdown
254
+ @sweep_task&.stop
255
+ @sweep_task = nil
256
+ @peer_registry&.drain_with_dead_letter(:socket_closed)
257
+ end
258
+
259
+
260
+ # Spawns a task on the engine's task tree. Used by the QoS 3 retry
261
+ # scheduler and internal sweep fiber.
262
+ #
263
+ # @param annotation [String]
264
+ # @yield the task body
265
+ # @return [Async::Task]
266
+ def spawn_task(annotation:, &block)
267
+ @engine.spawn_pump_task(annotation: annotation, &block)
268
+ end
269
+
270
+
271
+ # Starts the per-socket dead-letter sweep fiber (idempotent).
272
+ # Called from the routing layer once the first connection reaches
273
+ # the handshake-ready point — that's when +Async::Task.current+ is
274
+ # guaranteed to be inside the engine's task tree.
275
+ #
276
+ # @return [void]
277
+ def ensure_sweep_task_started
278
+ return if @sweep_task
279
+ return unless @level >= 2 && @dead_letter_timeout
280
+
281
+ interval = @dead_letter_timeout / 4.0
282
+ @sweep_task = @engine.spawn_pump_task(annotation: "qos dead-letter sweep") do
283
+ loop do
284
+ sleep interval
285
+ next unless @peer_registry
286
+
287
+ expired = @peer_registry.sweep_dead_letters(Async::Clock.now, @dead_letter_timeout)
288
+ expired.each do |entry, peer_info|
289
+ QoS.dead_letter(entry, peer_info: peer_info, reason: :peer_timeout, semaphore: @send_semaphore)
290
+ end
291
+ end
292
+ end
293
+ end
294
+ end
295
+ end
296
+
297
+ # Wire up prepends.
298
+ Protocol::ZMTP::Codec::Command.singleton_class.prepend(OMQ::QoS::CommandClassExt)
299
+ Protocol::ZMTP::Codec::Command.prepend(OMQ::QoS::CommandExt)
300
+
301
+ OMQ::Routing::RoundRobin.prepend(OMQ::QoS::RoundRobinExt)
302
+ OMQ::Routing::Push.prepend(OMQ::QoS::PushExt)
303
+ OMQ::Routing::Pull.prepend(OMQ::QoS::PullExt)
304
+
305
+ # Draft socket types from optional extension gems.
306
+ OMQ::Routing::Scatter.prepend(OMQ::QoS::ScatterExt) if defined?(OMQ::Routing::Scatter)
307
+ OMQ::Routing::Gather.prepend(OMQ::QoS::GatherExt) if defined?(OMQ::Routing::Gather)
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: omq-qos
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.2
5
+ platform: ruby
6
+ authors:
7
+ - Patrik Wenger
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: omq
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.28'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.28'
26
+ - !ruby/object:Gem::Dependency
27
+ name: xxhash
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: 'Experimental Quality of Service plugin for the OMQ pure-Ruby ZeroMQ
41
+ library. Adds per-hop delivery guarantees: QoS 1 (at-least-once), QoS 2 (exactly-once
42
+ with peer pinning and dedup), and QoS 3 (exactly-once plus application-level COMP/NACK),
43
+ using ACK/NACK command frames and xxHash message identification.'
44
+ email:
45
+ - paddor@gmail.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - LICENSE
51
+ - README.md
52
+ - lib/omq/qos.rb
53
+ - lib/omq/qos/connection_ext.rb
54
+ - lib/omq/qos/dead_letter.rb
55
+ - lib/omq/qos/dedup_set.rb
56
+ - lib/omq/qos/error_codes.rb
57
+ - lib/omq/qos/hasher.rb
58
+ - lib/omq/qos/lifecycle_ext.rb
59
+ - lib/omq/qos/options_ext.rb
60
+ - lib/omq/qos/peer_registry.rb
61
+ - lib/omq/qos/pending_store.rb
62
+ - lib/omq/qos/retry_scheduler.rb
63
+ - lib/omq/qos/routing_ext.rb
64
+ - lib/omq/qos/socket_ext.rb
65
+ - lib/omq/qos/version.rb
66
+ - lib/omq/qos/zmtp/command_ext.rb
67
+ homepage: https://github.com/zeromq/omq.rb/tree/main/gems/omq-qos
68
+ licenses:
69
+ - ISC
70
+ metadata: {}
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.3'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.16
86
+ specification_version: 4
87
+ summary: Experimental QoS delivery guarantees for OMQ
88
+ test_files: []