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
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/clock"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
class QoS
|
|
7
|
+
# Installs the QoS 1 command handler: dispatches incoming ACK frames
|
|
8
|
+
# to +pending_store.ack+.
|
|
9
|
+
#
|
|
10
|
+
# @param conn [Protocol::ZMTP::Connection]
|
|
11
|
+
# @param pending_store [PendingStore]
|
|
12
|
+
def self.install_qos1_handler(conn, pending_store)
|
|
13
|
+
conn.qos_on_command = lambda do |cmd|
|
|
14
|
+
next unless cmd.name == "ACK"
|
|
15
|
+
_, hash = cmd.ack_data
|
|
16
|
+
pending_store.ack(hash)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Installs the QoS 2 sender-side command handler on +conn+.
|
|
22
|
+
# Dispatches ACK → resolve promise + send CLR + release a
|
|
23
|
+
# {OMQ::QoS#send_semaphore} permit.
|
|
24
|
+
#
|
|
25
|
+
# @param conn [Protocol::ZMTP::Connection]
|
|
26
|
+
# @param qos [OMQ::QoS]
|
|
27
|
+
# @param algo [String]
|
|
28
|
+
def self.install_qos2_sender_handler(conn, qos, algo)
|
|
29
|
+
peer_info = conn.peer_info
|
|
30
|
+
semaphore = qos.send_semaphore
|
|
31
|
+
registry = qos.peer_registry
|
|
32
|
+
clr_class = Protocol::ZMTP::Codec::Command
|
|
33
|
+
|
|
34
|
+
conn.qos_on_command = lambda do |cmd|
|
|
35
|
+
next unless cmd.name == "ACK"
|
|
36
|
+
_, hash = cmd.ack_data
|
|
37
|
+
entry = registry.ack(peer_info, hash)
|
|
38
|
+
next unless entry
|
|
39
|
+
|
|
40
|
+
entry.promise.resolve(:delivered) unless entry.promise.resolved?
|
|
41
|
+
semaphore.release
|
|
42
|
+
|
|
43
|
+
conn.send_command(clr_class.clr(hash, algorithm: algo))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Installs the QoS 3 sender-side command handler on +conn+.
|
|
49
|
+
# Dispatches COMP → resolve promise + send CLR + release a permit;
|
|
50
|
+
# NACK → classify code, either dead-letter terminally or schedule a
|
|
51
|
+
# retry with exponential backoff (up to +max_retries+).
|
|
52
|
+
#
|
|
53
|
+
# @param conn [Protocol::ZMTP::Connection]
|
|
54
|
+
# @param qos [OMQ::QoS]
|
|
55
|
+
# @param algo [String]
|
|
56
|
+
def self.install_qos3_sender_handler(conn, qos, algo)
|
|
57
|
+
peer_info = conn.peer_info
|
|
58
|
+
semaphore = qos.send_semaphore
|
|
59
|
+
registry = qos.peer_registry
|
|
60
|
+
cmd_class = Protocol::ZMTP::Codec::Command
|
|
61
|
+
|
|
62
|
+
conn.qos_on_command = lambda do |cmd|
|
|
63
|
+
case cmd.name
|
|
64
|
+
when "COMP"
|
|
65
|
+
_, hash = cmd.comp_data
|
|
66
|
+
entry = registry.ack(peer_info, hash)
|
|
67
|
+
next unless entry
|
|
68
|
+
|
|
69
|
+
entry.promise.resolve(:delivered) unless entry.promise.resolved?
|
|
70
|
+
semaphore.release
|
|
71
|
+
conn.send_command(cmd_class.clr(hash, algorithm: algo))
|
|
72
|
+
|
|
73
|
+
when "NACK"
|
|
74
|
+
_, hash, code, message = cmd.nack_data
|
|
75
|
+
entry = registry.ack(peer_info, hash)
|
|
76
|
+
next unless entry
|
|
77
|
+
|
|
78
|
+
handle_qos3_nack(conn, qos, algo, peer_info, entry, hash, code, message)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# NACK dispatch for QoS 3 senders. Terminal codes dead-letter
|
|
85
|
+
# immediately; retryable codes either retry on the same peer after
|
|
86
|
+
# exponential backoff or dead-letter once +max_retries+ is reached.
|
|
87
|
+
#
|
|
88
|
+
# @return [void]
|
|
89
|
+
def self.handle_qos3_nack(conn, qos, algo, peer_info, entry, hash, code, message)
|
|
90
|
+
nack_info = NackInfo.new(code: code, message: message)
|
|
91
|
+
|
|
92
|
+
unless ErrorCodes.retryable?(code)
|
|
93
|
+
QoS.dead_letter(entry,
|
|
94
|
+
peer_info: peer_info,
|
|
95
|
+
reason: :terminal_nack,
|
|
96
|
+
semaphore: qos.send_semaphore,
|
|
97
|
+
error: nack_info,
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if entry.retry_count + 1 >= qos.max_retries
|
|
103
|
+
QoS.dead_letter(entry,
|
|
104
|
+
peer_info: peer_info,
|
|
105
|
+
reason: :retry_exhausted,
|
|
106
|
+
semaphore: qos.send_semaphore,
|
|
107
|
+
error: nack_info,
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
next_entry = entry.with(retry_count: entry.retry_count + 1, sent_at: Async::Clock.now)
|
|
113
|
+
qos.peer_registry.track(peer_info, hash, next_entry, conn)
|
|
114
|
+
|
|
115
|
+
delay = RetryScheduler.delay(entry.retry_count, qos.retry_backoff)
|
|
116
|
+
qos.spawn_task(annotation: "qos3 retry") do
|
|
117
|
+
sleep delay
|
|
118
|
+
live_conn = qos.peer_registry.connection_for(peer_info)
|
|
119
|
+
live_conn&.send_message(next_entry.parts)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Installs the QoS 2 receiver-side command handler on +conn+.
|
|
125
|
+
# Dispatches CLR → evict digest from the peer's dedup set.
|
|
126
|
+
#
|
|
127
|
+
# @param conn [Protocol::ZMTP::Connection]
|
|
128
|
+
# @param dedup [DedupSet]
|
|
129
|
+
def self.install_qos2_receiver_handler(conn, dedup)
|
|
130
|
+
conn.qos_on_command = lambda do |cmd|
|
|
131
|
+
next unless cmd.name == "CLR"
|
|
132
|
+
_, hash = cmd.clr_data
|
|
133
|
+
dedup.remove(hash)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Tuple enqueued into the recv queue at QoS 3 so that
|
|
139
|
+
# {SocketExt#receive} can send the appropriate COMP/NACK on the
|
|
140
|
+
# source +conn+ after the application block runs.
|
|
141
|
+
Envelope = Data.define(:parts, :conn, :digest, :algo)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Inproc Pipes deliver messages synchronously through a shared
|
|
145
|
+
# in-memory queue, so there's nothing for at-least-once to protect
|
|
146
|
+
# against. QoS hooks short-circuit when they see a Pipe.
|
|
147
|
+
#
|
|
148
|
+
# @param conn [Object]
|
|
149
|
+
# @return [Boolean]
|
|
150
|
+
#
|
|
151
|
+
def self.reliable_transport?(conn)
|
|
152
|
+
defined?(OMQ::Transport::Inproc::Pipe) &&
|
|
153
|
+
conn.is_a?(OMQ::Transport::Inproc::Pipe)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# Picks a hash algorithm for a connection by intersecting our
|
|
158
|
+
# preferences with the peer's advertised list (read from the peer's
|
|
159
|
+
# READY properties).
|
|
160
|
+
#
|
|
161
|
+
# @param connection [Protocol::ZMTP::Connection]
|
|
162
|
+
# @return [String] single-char algorithm identifier
|
|
163
|
+
#
|
|
164
|
+
def self.algo_for(connection)
|
|
165
|
+
peer = connection.respond_to?(:peer_properties) ? connection.peer_properties : nil
|
|
166
|
+
negotiate_hash(peer&.fetch("X-QoS-Hash", "") || "") || DEFAULT_HASH_ALGO
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Prepended onto {Routing::RoundRobin}. Branches by QoS level:
|
|
171
|
+
#
|
|
172
|
+
# 0 — no-op, defer entirely to base.
|
|
173
|
+
# 1 — existing {PendingStore} path. Disconnect drains entries
|
|
174
|
+
# back into the send queue (failover).
|
|
175
|
+
# 2 — {PeerRegistry} path. Each outgoing message carries an
|
|
176
|
+
# {Async::Promise} (via {OMQ::QoS#take_enqueue_promise}); the
|
|
177
|
+
# registry pins it to the peer's {Protocol::ZMTP::PeerInfo}
|
|
178
|
+
# and a connection-drop leaves entries in place for replay
|
|
179
|
+
# on reconnect (or dead-letter after the timeout).
|
|
180
|
+
#
|
|
181
|
+
module RoundRobinExt
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def init_round_robin(engine)
|
|
186
|
+
super
|
|
187
|
+
@pending_store = nil
|
|
188
|
+
@conn_algos = {}
|
|
189
|
+
@conn_peer_info = {}
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Lazily built QoS-1 pending store. Nil at QoS 0 and QoS >= 2
|
|
194
|
+
# (those paths use {OMQ::QoS#peer_registry} instead).
|
|
195
|
+
def pending_store
|
|
196
|
+
return @pending_store if @pending_store
|
|
197
|
+
qos = @engine.options.qos
|
|
198
|
+
@pending_store = PendingStore.new(capacity: @engine.options.send_hwm) if qos&.level == 1
|
|
199
|
+
@pending_store
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def algo_for(conn)
|
|
204
|
+
@conn_algos[conn] ||= QoS.algo_for(conn)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def peer_info_for(conn)
|
|
209
|
+
@conn_peer_info[conn] ||= conn.peer_info
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def remove_round_robin_send_connection(conn)
|
|
214
|
+
super
|
|
215
|
+
qos = @engine.options.qos
|
|
216
|
+
|
|
217
|
+
if qos && qos.level >= 2 && !QoS.reliable_transport?(conn)
|
|
218
|
+
peer_info = @conn_peer_info.delete(conn)
|
|
219
|
+
qos.peer_registry.disconnect(peer_info) if peer_info
|
|
220
|
+
@conn_algos.delete(conn)
|
|
221
|
+
elsif (ps = @pending_store)
|
|
222
|
+
ps.messages_for(conn).each { |entry| @send_queue.enqueue(entry.parts) }
|
|
223
|
+
@conn_algos.delete(conn)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def write_batch(conn, batch)
|
|
229
|
+
qos = @engine.options.qos
|
|
230
|
+
|
|
231
|
+
if qos && qos.level >= 2 && !QoS.reliable_transport?(conn)
|
|
232
|
+
write_batch_qos2(conn, batch, qos)
|
|
233
|
+
elsif (ps = @pending_store) && !QoS.reliable_transport?(conn)
|
|
234
|
+
super
|
|
235
|
+
algo = algo_for(conn)
|
|
236
|
+
batch.each do |parts|
|
|
237
|
+
ps.wait_for_slot
|
|
238
|
+
wire_parts = transform_send(parts)
|
|
239
|
+
ps.track(QoS.digest(wire_parts, algorithm: algo), parts, conn)
|
|
240
|
+
end
|
|
241
|
+
else
|
|
242
|
+
super
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def write_batch_qos2(conn, batch, qos)
|
|
248
|
+
peer_info = peer_info_for(conn)
|
|
249
|
+
algo = algo_for(conn)
|
|
250
|
+
registry = qos.peer_registry
|
|
251
|
+
semaphore = qos.send_semaphore
|
|
252
|
+
|
|
253
|
+
batch.each do |parts|
|
|
254
|
+
promise = qos.take_enqueue_promise(parts) or
|
|
255
|
+
raise "OMQ::QoS #{qos.level}: missing enqueue Promise for outgoing parts"
|
|
256
|
+
|
|
257
|
+
semaphore.acquire
|
|
258
|
+
|
|
259
|
+
wire_parts = transform_send(parts)
|
|
260
|
+
conn.send_message(wire_parts)
|
|
261
|
+
|
|
262
|
+
digest = QoS.digest(wire_parts, algorithm: algo)
|
|
263
|
+
entry = PeerRegistry::Entry.new(
|
|
264
|
+
parts: parts,
|
|
265
|
+
peer_info: peer_info,
|
|
266
|
+
sent_at: Async::Clock.now,
|
|
267
|
+
promise: promise,
|
|
268
|
+
retry_count: 0,
|
|
269
|
+
)
|
|
270
|
+
registry.track(peer_info, digest, entry, conn)
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# Shared helper for the send-side connection_added path: installs
|
|
277
|
+
# the QoS 1 or QoS 2 command handler, and at QoS 2 replays any
|
|
278
|
+
# pending entries for the peer onto the fresh connection.
|
|
279
|
+
#
|
|
280
|
+
# @param socket [OMQ::Routing::RoundRobin] the routing strategy instance
|
|
281
|
+
# @param conn [Protocol::ZMTP::Connection]
|
|
282
|
+
# @return [void]
|
|
283
|
+
def self.on_send_connection_added(socket, conn)
|
|
284
|
+
qos = socket.instance_variable_get(:@engine).options.qos
|
|
285
|
+
return if qos.nil?
|
|
286
|
+
return if reliable_transport?(conn)
|
|
287
|
+
|
|
288
|
+
algo = socket.send(:algo_for, conn)
|
|
289
|
+
|
|
290
|
+
case qos.level
|
|
291
|
+
when 1
|
|
292
|
+
install_qos1_handler(conn, socket.send(:pending_store))
|
|
293
|
+
when 2
|
|
294
|
+
install_qos2_sender_handler(conn, qos, algo)
|
|
295
|
+
qos.ensure_sweep_task_started
|
|
296
|
+
replay_pending(conn, qos)
|
|
297
|
+
when 3
|
|
298
|
+
install_qos3_sender_handler(conn, qos, algo)
|
|
299
|
+
qos.ensure_sweep_task_started
|
|
300
|
+
replay_pending(conn, qos)
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# Replays any pending entries for +conn.peer_info+ on +conn+,
|
|
306
|
+
# preserving original insertion order. Called on reconnect at
|
|
307
|
+
# QoS >= 2.
|
|
308
|
+
#
|
|
309
|
+
# @param conn [Protocol::ZMTP::Connection]
|
|
310
|
+
# @param qos [OMQ::QoS]
|
|
311
|
+
def self.replay_pending(conn, qos)
|
|
312
|
+
peer_info = conn.peer_info
|
|
313
|
+
entries = qos.peer_registry.resume(peer_info, conn)
|
|
314
|
+
return if entries.empty?
|
|
315
|
+
|
|
316
|
+
entries.each do |entry|
|
|
317
|
+
conn.send_message(entry.parts)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# Prepended onto {Routing::Push}. Installs the per-level command
|
|
323
|
+
# handler on every new connection. The existing reaper fiber keeps
|
|
324
|
+
# blocking on {Connection#receive_message}, which dispatches
|
|
325
|
+
# incoming command frames through {ConnectionExt} to our handler.
|
|
326
|
+
#
|
|
327
|
+
module PushExt
|
|
328
|
+
def connection_added(conn)
|
|
329
|
+
super
|
|
330
|
+
QoS.on_send_connection_added(self, conn)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
# Same pattern as PushExt — SCATTER uses RoundRobin too.
|
|
336
|
+
#
|
|
337
|
+
module ScatterExt
|
|
338
|
+
def connection_added(conn)
|
|
339
|
+
super
|
|
340
|
+
QoS.on_send_connection_added(self, conn)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# Prepended onto {Routing::Pull}. At QoS >= 1 every received message
|
|
346
|
+
# is ACK'd back to the sender. At QoS >= 2 the ACK is paired with a
|
|
347
|
+
# dedup-set check: seen digests are ACK'd again but not redelivered
|
|
348
|
+
# to the application.
|
|
349
|
+
#
|
|
350
|
+
# ACK after successful enqueue into +recv_queue+. A receiver that
|
|
351
|
+
# has read a frame off the wire but not yet stored it (because the
|
|
352
|
+
# app-facing recv_queue is at +recv_hwm+) has NOT yet taken
|
|
353
|
+
# responsibility for it — if we ACK'd on wire-receipt and then
|
|
354
|
+
# crashed before the app dequeued, the sender would have cleared
|
|
355
|
+
# pending on a message that was effectively lost. ACK-after-enqueue
|
|
356
|
+
# keeps the at-least-once contract honest and lets +recv_hwm+
|
|
357
|
+
# bound truly-in-flight (= read-but-not-stored) messages at zero.
|
|
358
|
+
#
|
|
359
|
+
# The transform returns +nil+ so the pump's own post-transform
|
|
360
|
+
# enqueue is skipped — we already enqueued inside the transform.
|
|
361
|
+
module PullExt
|
|
362
|
+
def connection_added(conn)
|
|
363
|
+
qos = @engine.options.qos
|
|
364
|
+
return super if qos.nil?
|
|
365
|
+
return super if QoS.reliable_transport?(conn)
|
|
366
|
+
|
|
367
|
+
algo = QoS.algo_for(conn)
|
|
368
|
+
recv_queue = @recv_queue
|
|
369
|
+
engine = @engine
|
|
370
|
+
|
|
371
|
+
case qos.level
|
|
372
|
+
when 1
|
|
373
|
+
engine.start_recv_pump(conn, recv_queue) do |msg|
|
|
374
|
+
recv_queue.enqueue(msg)
|
|
375
|
+
conn.send_command(QoS.ack_command(msg, algorithm: algo))
|
|
376
|
+
nil
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
when 2
|
|
380
|
+
dedup = qos.dedup_set_for(conn)
|
|
381
|
+
QoS.install_qos2_receiver_handler(conn, dedup)
|
|
382
|
+
|
|
383
|
+
engine.start_recv_pump(conn, recv_queue) do |msg|
|
|
384
|
+
digest = QoS.digest(msg, algorithm: algo)
|
|
385
|
+
ack = Protocol::ZMTP::Codec::Command.ack(digest, algorithm: algo)
|
|
386
|
+
|
|
387
|
+
if dedup.seen?(digest)
|
|
388
|
+
conn.send_command(ack)
|
|
389
|
+
else
|
|
390
|
+
dedup.add(digest)
|
|
391
|
+
recv_queue.enqueue(msg)
|
|
392
|
+
conn.send_command(ack)
|
|
393
|
+
end
|
|
394
|
+
nil
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
when 3
|
|
398
|
+
dedup = qos.dedup_set_for(conn)
|
|
399
|
+
QoS.install_qos2_receiver_handler(conn, dedup)
|
|
400
|
+
|
|
401
|
+
engine.start_recv_pump(conn, recv_queue) do |msg|
|
|
402
|
+
digest = QoS.digest(msg, algorithm: algo)
|
|
403
|
+
if dedup.seen?(digest)
|
|
404
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.comp(digest, algorithm: algo))
|
|
405
|
+
else
|
|
406
|
+
recv_queue.enqueue(QoS::Envelope.new(parts: msg, conn: conn, digest: digest, algo: algo))
|
|
407
|
+
end
|
|
408
|
+
nil
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# Same pattern as PullExt — GATHER uses fair-recv too.
|
|
416
|
+
#
|
|
417
|
+
module GatherExt
|
|
418
|
+
def connection_added(conn)
|
|
419
|
+
qos = @engine.options.qos
|
|
420
|
+
return super if qos.nil?
|
|
421
|
+
return super if QoS.reliable_transport?(conn)
|
|
422
|
+
|
|
423
|
+
algo = QoS.algo_for(conn)
|
|
424
|
+
recv_queue = @recv_queue
|
|
425
|
+
engine = @engine
|
|
426
|
+
|
|
427
|
+
case qos.level
|
|
428
|
+
when 1
|
|
429
|
+
engine.start_recv_pump(conn, recv_queue) do |msg|
|
|
430
|
+
recv_queue.enqueue(msg)
|
|
431
|
+
conn.send_command(QoS.ack_command(msg, algorithm: algo))
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
when 2
|
|
436
|
+
dedup = qos.dedup_set_for(conn)
|
|
437
|
+
QoS.install_qos2_receiver_handler(conn, dedup)
|
|
438
|
+
|
|
439
|
+
engine.start_recv_pump(conn, recv_queue) do |msg|
|
|
440
|
+
digest = QoS.digest(msg, algorithm: algo)
|
|
441
|
+
ack = Protocol::ZMTP::Codec::Command.ack(digest, algorithm: algo)
|
|
442
|
+
|
|
443
|
+
if dedup.seen?(digest)
|
|
444
|
+
conn.send_command(ack)
|
|
445
|
+
else
|
|
446
|
+
dedup.add(digest)
|
|
447
|
+
recv_queue.enqueue(msg)
|
|
448
|
+
conn.send_command(ack)
|
|
449
|
+
end
|
|
450
|
+
nil
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
when 3
|
|
454
|
+
dedup = qos.dedup_set_for(conn)
|
|
455
|
+
QoS.install_qos2_receiver_handler(conn, dedup)
|
|
456
|
+
|
|
457
|
+
engine.start_recv_pump(conn, recv_queue) do |msg|
|
|
458
|
+
digest = QoS.digest(msg, algorithm: algo)
|
|
459
|
+
if dedup.seen?(digest)
|
|
460
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.comp(digest, algorithm: algo))
|
|
461
|
+
else
|
|
462
|
+
recv_queue.enqueue(QoS::Envelope.new(parts: msg, conn: conn, digest: digest, algo: algo))
|
|
463
|
+
end
|
|
464
|
+
nil
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# Routing::Req needs no QoS-specific override at levels 0/1:
|
|
472
|
+
# re-enqueueing a mid-flight request on connection loss is already
|
|
473
|
+
# handled by {RoundRobinExt#remove_round_robin_send_connection}
|
|
474
|
+
# through the pending store. At QoS >= 2 the request is instead
|
|
475
|
+
# pinned in {PeerRegistry}; the RoundRobinExt path handles that
|
|
476
|
+
# directly, and REQ's +@state+ legitimately stays +:waiting_reply+
|
|
477
|
+
# until either the pinned peer returns with the reply or the
|
|
478
|
+
# dead-letter timer fires (which the application observes through
|
|
479
|
+
# the +#send+ Promise).
|
|
480
|
+
end
|
|
481
|
+
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "async/promise"
|
|
4
|
+
|
|
5
|
+
module OMQ
|
|
6
|
+
class QoS
|
|
7
|
+
# Adds the +qos+ accessors to {OMQ::Socket} and validates +qos=+
|
|
8
|
+
# against the RFC. Fan-out socket types MUST refuse any QoS level
|
|
9
|
+
# above 0, and Integer levels are rejected in favour of {OMQ::QoS}
|
|
10
|
+
# instances (a 0.x → 0.3 break — see CHANGELOG).
|
|
11
|
+
#
|
|
12
|
+
module SocketExt
|
|
13
|
+
FAN_OUT_TYPES = %i[PUB XPUB RADIO SUB XSUB DISH].freeze
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def qos
|
|
17
|
+
@options.qos
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def qos=(value)
|
|
22
|
+
unless value.nil? || value.is_a?(OMQ::QoS)
|
|
23
|
+
raise ArgumentError,
|
|
24
|
+
"QoS must be nil (fire-and-forget) or an OMQ::QoS instance " \
|
|
25
|
+
"(OMQ::QoS.at_least_once / .exactly_once / .exactly_once_and_processed); " \
|
|
26
|
+
"received #{value.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if value && FAN_OUT_TYPES.include?(@engine.socket_type)
|
|
30
|
+
raise ArgumentError,
|
|
31
|
+
"QoS > 0 is not supported for fan-out socket type #{@engine.socket_type}; " \
|
|
32
|
+
"use a broker (e.g. Malamute) for reliable fan-out"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
current = @options.qos
|
|
36
|
+
if current && !current.equal?(value)
|
|
37
|
+
raise ArgumentError,
|
|
38
|
+
"OMQ::QoS already attached to this socket; a different instance " \
|
|
39
|
+
"cannot be assigned (reset to nil first if supported)"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@options.qos = value
|
|
43
|
+
value&.attach!(@engine)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def close
|
|
48
|
+
@options.qos&.shutdown
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# Prepended onto {OMQ::Readable} so that at QoS 3 +#receive+ accepts
|
|
55
|
+
# (and requires) a block. The block runs the application handler;
|
|
56
|
+
# its return value becomes a COMP command; a raised {StandardError}
|
|
57
|
+
# becomes a NACK with the mapped error code.
|
|
58
|
+
#
|
|
59
|
+
# At QoS 0/1/2 +#receive+ is unchanged.
|
|
60
|
+
#
|
|
61
|
+
module ReadableExt
|
|
62
|
+
def receive(&block)
|
|
63
|
+
qos = @options.qos
|
|
64
|
+
return super() if qos.nil? || qos.level < 3
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "QoS 3 requires a block to #receive — the block's return or raise signals COMP/NACK" unless block
|
|
67
|
+
|
|
68
|
+
env = super()
|
|
69
|
+
QoS.process_qos3_envelope(env, qos, &block)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def each(&block)
|
|
74
|
+
qos = @options.qos
|
|
75
|
+
return super unless qos && qos.level >= 3
|
|
76
|
+
|
|
77
|
+
raise ArgumentError, "QoS 3 requires a block to #each" unless block
|
|
78
|
+
|
|
79
|
+
loop do
|
|
80
|
+
receive(&block)
|
|
81
|
+
rescue IO::TimeoutError
|
|
82
|
+
return
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Runs the application block against a QoS 3 {Envelope} and emits
|
|
89
|
+
# COMP on success, NACK on any +StandardError+.
|
|
90
|
+
#
|
|
91
|
+
# Success path: add the digest to the peer's dedup set first, then
|
|
92
|
+
# send COMP — a late retransmit from the sender (crossing our COMP
|
|
93
|
+
# on the wire) must see a seen-digest so the receiver answers with a
|
|
94
|
+
# duplicate COMP rather than invoking the handler again.
|
|
95
|
+
#
|
|
96
|
+
# @param env [Envelope]
|
|
97
|
+
# @param qos [OMQ::QoS]
|
|
98
|
+
# @yieldparam parts [Array<String>]
|
|
99
|
+
# @return [Array<String>] the original message parts
|
|
100
|
+
def self.process_qos3_envelope(env, qos, &block)
|
|
101
|
+
parts = env.parts
|
|
102
|
+
conn = env.conn
|
|
103
|
+
digest = env.digest
|
|
104
|
+
algo = env.algo
|
|
105
|
+
dedup = qos.dedup_set_for(conn)
|
|
106
|
+
|
|
107
|
+
if (timeout = qos.processing_timeout)
|
|
108
|
+
Async::Task.current.with_timeout(timeout, TimeoutError) do
|
|
109
|
+
block.call(parts)
|
|
110
|
+
end
|
|
111
|
+
else
|
|
112
|
+
block.call(parts)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
dedup.add(digest)
|
|
116
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.comp(digest, algorithm: algo))
|
|
117
|
+
parts
|
|
118
|
+
rescue StandardError => error
|
|
119
|
+
code, msg = ErrorCodes.exception_to_payload(error)
|
|
120
|
+
conn.send_command(Protocol::ZMTP::Codec::Command.nack(digest, code: code, message: msg, algorithm: algo))
|
|
121
|
+
parts
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Prepended onto {OMQ::Writable} so that at QoS >= 2 +#send+ returns
|
|
126
|
+
# an {Async::Promise} resolving to +:delivered+ or a {DeadLetter}.
|
|
127
|
+
# At QoS 0/1 the original {Writable#send} is preserved (returns
|
|
128
|
+
# +self+).
|
|
129
|
+
#
|
|
130
|
+
# The Promise is stashed on the socket's {OMQ::QoS} via
|
|
131
|
+
# {QoS#enqueue_promise} keyed by +parts.object_id+. The routing
|
|
132
|
+
# layer ({RoundRobinExt#write_batch}) takes it back out when the
|
|
133
|
+
# message reaches the wire and attaches it to the
|
|
134
|
+
# {PeerRegistry::Entry}. This keeps the send queue's item type
|
|
135
|
+
# uniform (Array<String>) across QoS levels, so +batch_bytes+,
|
|
136
|
+
# +emit_verbose_msg_sent+, and {RecvPump} need no awareness of QoS.
|
|
137
|
+
#
|
|
138
|
+
module WritableExt
|
|
139
|
+
def send(message)
|
|
140
|
+
qos = @options.qos
|
|
141
|
+
return super if qos.nil? || qos.level < 2
|
|
142
|
+
|
|
143
|
+
parts = message.is_a?(Array) ? message : [message]
|
|
144
|
+
raise ArgumentError, "message has no parts" if parts.empty?
|
|
145
|
+
|
|
146
|
+
parts = parts.map(&:to_str) if parts.any? { |part| !part.is_a?(String) }
|
|
147
|
+
parts.each do |part|
|
|
148
|
+
part.force_encoding(Encoding::BINARY) unless part.frozen? || part.encoding == Encoding::BINARY
|
|
149
|
+
part.freeze
|
|
150
|
+
end
|
|
151
|
+
parts.freeze
|
|
152
|
+
|
|
153
|
+
promise = Async::Promise.new
|
|
154
|
+
qos.enqueue_promise(parts, promise)
|
|
155
|
+
|
|
156
|
+
if @engine.on_io_thread?
|
|
157
|
+
OMQ::Reactor.run(timeout: @options.write_timeout) { @engine.enqueue_send(parts) }
|
|
158
|
+
elsif (timeout = @options.write_timeout)
|
|
159
|
+
Async::Task.current.with_timeout(timeout, IO::TimeoutError) { @engine.enqueue_send(parts) }
|
|
160
|
+
else
|
|
161
|
+
@engine.enqueue_send(parts)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
promise
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
OMQ::Socket.prepend(OMQ::QoS::SocketExt)
|
|
172
|
+
OMQ::Writable.prepend(OMQ::QoS::WritableExt)
|
|
173
|
+
OMQ::Readable.prepend(OMQ::QoS::ReadableExt)
|