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,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)
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OMQ
4
+ # Per-hop delivery guarantees (QoS) extension for OMQ sockets.
5
+ class QoS
6
+ VERSION = "0.3.2"
7
+ end
8
+ end