async-rabbitmq 0.4.0

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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+
5
+ module AsyncRabbitMQ
6
+ # One connection to every node of a RabbitMQ cluster, behind the Session API.
7
+ #
8
+ # A Session is on one node, so every channel it opens lands there, and so
9
+ # does every exclusive queue declared through it, because the broker always
10
+ # places those on the connecting node. Losing that node loses them all. A
11
+ # process holding a channel per client, with many clients, wants neither.
12
+ #
13
+ # A Cluster pins one Session to each address it is given, opens each new
14
+ # channel on the node with the fewest, and retries a node that is down until
15
+ # it is back, so new channels drift to it. Nothing is ever moved: a channel
16
+ # stays on its node for life.
17
+ #
18
+ # cluster = AsyncRabbitMQ::Cluster.new(addresses: %w[rabbit1:5672 rabbit2:5672 rabbit3:5672],
19
+ # username: "app", password: secret, on_node_down: :drop)
20
+ # cluster.connect # every node; the ones that are down are retried
21
+ # channel = cluster.open_channel # on the node with the fewest channels
22
+ #
23
+ # Same constructor options and public methods as Session, plus two:
24
+ #
25
+ # * +on_node_down:+ what happens to the channels on a node whose connection
26
+ # is lost. +:park+ (the default, and what a Session does) keeps them: they
27
+ # wait, and resume on the same node when it returns. +:drop+ closes them at
28
+ # once, so the fibers using them see the loss immediately and can start
29
+ # again on another node through #open_channel. A callable receives
30
+ # (session, channels, error) while the channels are parked and closes the
31
+ # ones it wants dropped; the rest wait.
32
+ # * +clear_topology_on_drop:+ with +:drop+, forget everything the lost
33
+ # connection had declared (default true), so nothing is re-declared when
34
+ # the node returns. false keeps the Session's registry rules, for
35
+ # connections that also carry durable topology.
36
+ #
37
+ # Ordinary publishing and shared topology are expected to live on a plain
38
+ # Session alongside; this object is for the mass of per-client channels.
39
+ class Cluster
40
+ # Every node's registry, read-only, behind the TopologyRegistry readers.
41
+ class TopologyView
42
+ def initialize(registries)
43
+ @registries = registries
44
+ end
45
+
46
+ def exchanges = @registries.flat_map(&:exchanges)
47
+ def queues = @registries.flat_map(&:queues)
48
+ def queue_bindings = @registries.flat_map(&:queue_bindings)
49
+ def exchange_bindings = @registries.flat_map(&:exchange_bindings)
50
+ def consumers = @registries.map(&:consumers).reduce({}, :merge)
51
+ def empty? = @registries.all?(&:empty?)
52
+ end
53
+
54
+ # [host, port] pairs, one Session each, in the order given.
55
+ attr_reader :addresses
56
+ attr_reader :vhost, :username
57
+
58
+ # Structured events from every node and channel; see Session#on_event.
59
+ attr_reader :notifier
60
+
61
+ # Build a Cluster from one AMQP URI per node; see Session.from_uri.
62
+ def self.from_uri(*uri_strings, **kwargs)
63
+ new(**Session.options_from_uris(*uri_strings, **kwargs))
64
+ end
65
+
66
+ def initialize(on_node_down: :park, clear_topology_on_drop: true, **session_opts)
67
+ unless %i[park drop].include?(on_node_down) || on_node_down.respond_to?(:call)
68
+ raise ArgumentError, "on_node_down must be :park, :drop or a callable (got #{on_node_down.inspect})"
69
+ end
70
+ if session_opts[:auto_recover] == false
71
+ raise ArgumentError, "a Cluster needs auto_recover: a node that is down is retried until it is back"
72
+ end
73
+
74
+ @on_node_down_policy = on_node_down
75
+ @clear_topology_on_drop = clear_topology_on_drop
76
+ @logger = session_opts[:logger] || Log.new
77
+ @notifier = session_opts[:notifier] || Notifier.new(logger: @logger)
78
+ if (instrumenter = session_opts[:instrumenter])
79
+ @notifier.subscribe { |name, payload| instrumenter.call(name, payload) }
80
+ end
81
+ @addresses = Session.address_list(host: session_opts.fetch(:host, "localhost"),
82
+ port: session_opts.fetch(:port, 5672),
83
+ hosts: session_opts[:hosts], addresses: session_opts[:addresses])
84
+ @vhost = session_opts.fetch(:vhost, "/")
85
+ @username = session_opts.fetch(:username, "guest")
86
+ @retry_interval = session_opts.fetch(:recovery_interval, Session::RECOVERY_INITIAL)
87
+ @retry_max_interval = session_opts.fetch(:recovery_max_interval, Session::RECOVERY_MAX)
88
+
89
+ node_opts = session_opts.except(:host, :port, :hosts, :addresses, :hosts_shuffle_strategy,
90
+ :instrumenter, :logger, :notifier)
91
+ @sessions = @addresses.map do |host, port|
92
+ Session.new(**node_opts, host: host, port: port, logger: @logger, notifier: @notifier).tap { |s| watch(s) }
93
+ end
94
+
95
+ @closed = true
96
+ @supervisors = {} # session => the task connecting it, until it is up
97
+ @wakeups = {} # session => Async::Condition, to cut a retry sleep short on close
98
+ @on_node_down = @on_node_up = @on_connection_lost = nil
99
+ @on_blocked = @on_unblocked = @on_recovery_attempt = @on_recovery = @on_recovery_exhausted = nil
100
+ end
101
+
102
+ # The Session on each node, in address order.
103
+ def sessions
104
+ @sessions.dup
105
+ end
106
+
107
+ # A Session reports the address it is on; a Cluster is on all of them, so
108
+ # these are the first address. See #addresses.
109
+ def host = @addresses.first[0]
110
+ def port = @addresses.first[1]
111
+
112
+ # Connect every node, concurrently. Returns once each one is either
113
+ # connected or has failed a first attempt, so the channels opened next are
114
+ # spread over every node that is reachable rather than piling onto whichever
115
+ # answered first. A node that did not answer keeps being retried in the
116
+ # background with the recovery backoff and takes channels once it is up, so
117
+ # the wait is bounded by one connect_timeout however many nodes are down.
118
+ # Raises the last error if none can be reached, and AuthenticationError as
119
+ # soon as a node reports it, since the others would say the same.
120
+ def connect
121
+ raise NotOpenError, "Cluster is already connected" unless @closed
122
+ @closed = false
123
+ pending = @sessions.size
124
+ errors = {}
125
+ reported = Async::Condition.new
126
+ # Parent the per-node supervisors at the reactor, not at whichever task
127
+ # called connect. A caller that finishes, or is stopped, would otherwise
128
+ # take every node's supervisor down with it — and with them the retry
129
+ # loop that brings a node back — without raising anything. Transient so
130
+ # they never hold the reactor open on their own; #close stops them.
131
+ root = Async::Task.current.root
132
+ @sessions.each do |session|
133
+ @supervisors[session] = root.async(transient: true) do
134
+ supervise(session) do |error|
135
+ errors[session] = error
136
+ pending -= 1
137
+ reported.signal
138
+ end
139
+ end
140
+ end
141
+
142
+ loop do
143
+ if (refused = errors.values.find { |e| e.is_a?(AuthenticationError) })
144
+ close
145
+ raise refused
146
+ end
147
+ break if pending.zero?
148
+ reported.wait
149
+ end
150
+ return if open?
151
+
152
+ close
153
+ raise errors.values.compact.last || ConnectionTimeoutError.new("Could not connect to any node")
154
+ end
155
+
156
+ # True while at least one node is connected.
157
+ def open?
158
+ !@closed && @sessions.any?(&:open?)
159
+ end
160
+
161
+ # True before connect and after close. A connected cluster whose nodes are
162
+ # all down is neither open nor closed, like a recovering Session.
163
+ def closed?
164
+ @closed
165
+ end
166
+
167
+ # Close every node's connection and stop retrying the ones that are down.
168
+ def close
169
+ return if @closed
170
+ @closed = true
171
+ @sessions.each { |s| s.close rescue nil }
172
+ @wakeups.values.each { |c| c.signal rescue nil }
173
+ # The supervisors are parented at the reactor, so dropping the references
174
+ # would not stop them; a node still in its retry backoff would keep
175
+ # reconnecting after the cluster was closed.
176
+ @supervisors.each_value { |task| task.stop rescue nil }
177
+ @supervisors.clear
178
+ end
179
+
180
+ # Open a channel on the connected node with the fewest channels.
181
+ def open_channel(pool_size: 1)
182
+ least_loaded_node.open_channel(pool_size: pool_size)
183
+ end
184
+
185
+ def with_channel(pool_size: 1)
186
+ ch = open_channel(pool_size: pool_size)
187
+ begin
188
+ yield ch
189
+ ensure
190
+ ch.close rescue nil
191
+ end
192
+ end
193
+
194
+ # Every channel on every node.
195
+ def channels
196
+ @sessions.flat_map(&:channels)
197
+ end
198
+
199
+ def channel_count
200
+ @sessions.sum(&:channel_count)
201
+ end
202
+
203
+ # Rotate the secret on every node: sent to the connected ones, stored for
204
+ # the next connect on the ones that are down.
205
+ # Rotate the credential on every node. The new secret is stored on all of
206
+ # them first, so a node that refuses the live update — or is down — still
207
+ # reconnects with the new secret rather than the old one. Every node is
208
+ # attempted; the failures are collected and raised together, so one bad
209
+ # node cannot leave the ones after it on the previous secret.
210
+ def update_secret(new_secret, reason = "secret update")
211
+ raise NotOpenError, "No cluster node is connected" unless open?
212
+
213
+ @sessions.each { |s| s.store_secret(new_secret) }
214
+
215
+ errors = {}
216
+ @sessions.each do |s|
217
+ next unless s.open?
218
+
219
+ begin
220
+ s.update_secret(new_secret, reason)
221
+ rescue => e
222
+ errors[s] = e
223
+ end
224
+ end
225
+
226
+ unless errors.empty?
227
+ detail = errors.map { |s, e| "#{s.host}:#{s.port} (#{e.class}: #{e.message})" }.join(", ")
228
+ raise ClusterError, "update_secret failed on #{errors.size} of #{@sessions.size} node(s): #{detail}"
229
+ end
230
+
231
+ true
232
+ end
233
+
234
+ def store_secret(new_secret)
235
+ @sessions.each { |s| s.store_secret(new_secret) }
236
+ end
237
+
238
+ def queue_exists?(name)
239
+ least_loaded_node.queue_exists?(name)
240
+ end
241
+
242
+ def exchange_exists?(name)
243
+ least_loaded_node.exchange_exists?(name)
244
+ end
245
+
246
+ def topology
247
+ TopologyView.new(@sessions.map(&:topology))
248
+ end
249
+
250
+ def frame_max
251
+ (@sessions.find(&:open?) || @sessions.first).frame_max
252
+ end
253
+
254
+ # --- callbacks, fanned in from every node -------------------------------
255
+
256
+ def on_blocked(&block)
257
+ @on_blocked = block
258
+ end
259
+
260
+ def on_unblocked(&block)
261
+ @on_unblocked = block
262
+ end
263
+
264
+ def on_recovery_attempt(&block)
265
+ @on_recovery_attempt = block
266
+ end
267
+
268
+ # The block receives the node's Session.
269
+ def on_recovery(&block)
270
+ @on_recovery = block
271
+ end
272
+
273
+ def on_recovery_exhausted(&block)
274
+ @on_recovery_exhausted = block
275
+ end
276
+
277
+ # The block receives the node's Session and the error.
278
+ def on_connection_lost(&block)
279
+ @on_connection_lost = block
280
+ end
281
+
282
+ def on_event(pattern = nil, &block)
283
+ @notifier.subscribe(pattern, &block)
284
+ end
285
+
286
+ # Register a callback invoked when a node's connection is lost, after the
287
+ # +on_node_down:+ policy has been applied. The block receives the node's
288
+ # Session, the channels that were on it and the error; declare a fourth
289
+ # parameter to also receive the UnconfirmedMessage records the broker never
290
+ # acked, so they can be republished on another node. Under +:drop+ that is
291
+ # the only chance to see them.
292
+ def on_node_down(&block)
293
+ @on_node_down = block
294
+ end
295
+
296
+ # Register a callback invoked when a node that was down is connected
297
+ # again, by recovery or by the background retry. The block receives the
298
+ # node's Session.
299
+ def on_node_up(&block)
300
+ @on_node_up = block
301
+ end
302
+
303
+ # --- Async::Pool resource interface ---
304
+
305
+ def reusable?
306
+ @sessions.any?(&:reusable?)
307
+ end
308
+
309
+ def viable?
310
+ open?
311
+ end
312
+
313
+ # Channels across every node.
314
+ def concurrency
315
+ @sessions.sum(&:concurrency)
316
+ end
317
+
318
+ private
319
+
320
+ def least_loaded_node
321
+ @sessions.select(&:open?).min_by(&:channel_count) or
322
+ raise NotOpenError, "No cluster node is connected"
323
+ end
324
+
325
+ def watch(session)
326
+ session.on_connection_lost { |s, error| node_down(s, error) }
327
+ session.on_recovery { |s| node_up(s); @on_recovery&.call(s) }
328
+ session.on_recovery_attempt { |n| @on_recovery_attempt&.call(n) }
329
+ session.on_recovery_exhausted { |s| @on_recovery_exhausted&.call(s) }
330
+ session.on_blocked { |reason| @on_blocked&.call(reason) }
331
+ session.on_unblocked { @on_unblocked&.call }
332
+ end
333
+
334
+ # One per node: connects it, retrying with the recovery backoff until it is
335
+ # up, and reports the first attempt's outcome to #connect. Once the node is
336
+ # connected its Session looks after itself, so the task ends. A plain
337
+ # Session, like Bunny's start, tries its address list once and leaves
338
+ # retrying to the caller; here a node that is down at startup is nothing
339
+ # special.
340
+ def supervise(session)
341
+ delay = @retry_interval
342
+ attempt = 0
343
+ until @closed
344
+ attempt += 1
345
+ error = try_connect(session)
346
+ yield error if attempt == 1
347
+ if @closed # closed while this connect was in flight
348
+ session.close rescue nil
349
+ return
350
+ end
351
+ if error.nil?
352
+ node_up(session) if attempt > 1
353
+ return
354
+ end
355
+ return if error.is_a?(AuthenticationError)
356
+
357
+ @logger.warn("Node #{session.host}:#{session.port} unreachable (#{error.class}: #{error.message}); " \
358
+ "next attempt in #{delay.round(1)}s")
359
+ wait_for(session, delay)
360
+ delay = [delay * 2, @retry_max_interval].min
361
+ end
362
+ end
363
+
364
+ def try_connect(session)
365
+ session.connect
366
+ nil
367
+ rescue AuthenticationError => e
368
+ @logger.error("Node #{session.host}:#{session.port} refused the credentials: #{e.message}")
369
+ e
370
+ rescue Error, SystemCallError, IOError => e
371
+ e
372
+ end
373
+
374
+ # Sleep for +seconds+, give or take the recovery jitter, cut short by #close.
375
+ def wait_for(session, seconds)
376
+ condition = @wakeups[session] = Async::Condition.new
377
+ jitter = seconds * Session::RECOVERY_JITTER * (rand * 2 - 1)
378
+ Async::Task.current.with_timeout(seconds + jitter) { condition.wait }
379
+ rescue Async::TimeoutError
380
+ nil
381
+ ensure
382
+ @wakeups.delete(session) if @wakeups[session].equal?(condition)
383
+ end
384
+
385
+ def node_down(session, error)
386
+ channels = session.channels
387
+ # Collected before the policy runs: :drop discards the channels, and
388
+ # with them any record of what the broker never confirmed.
389
+ unconfirmed = channels.flat_map { |ch| ch.unconfirmed_messages rescue [] }
390
+
391
+ begin
392
+ case @on_node_down_policy
393
+ when :park then nil
394
+ when :drop then drop_channels(session, channels, error)
395
+ else @on_node_down_policy.call(session, channels, error)
396
+ end
397
+ rescue => e
398
+ @logger.error("on_node_down failed for #{session.host}:#{session.port}: #{e.class}: #{e.message}")
399
+ end
400
+ @on_connection_lost&.call(session, error)
401
+ notify_node_down(session, channels, error, unconfirmed)
402
+ end
403
+
404
+ # on_node_down took three arguments before unconfirmed messages were passed
405
+ # to it, so a block that declares three still works.
406
+ def notify_node_down(session, channels, error, unconfirmed)
407
+ return unless @on_node_down
408
+
409
+ if @on_node_down.arity >= 0 && @on_node_down.arity <= 3
410
+ @on_node_down.call(session, channels, error)
411
+ else
412
+ @on_node_down.call(session, channels, error, unconfirmed)
413
+ end
414
+ end
415
+
416
+ def drop_channels(session, channels, error)
417
+ lost = ConnectionError.new(code: 0, text: "Connection to #{session.host}:#{session.port} lost: #{error.message}")
418
+ channels.each { |ch| ch.drop!(lost) }
419
+ # Only what was tied to this connection. Durable exchanges, queues and
420
+ # bindings stay recorded: if this node comes back from an empty data
421
+ # directory they still need re-declaring.
422
+ session.topology.clear_transient if @clear_topology_on_drop
423
+ @logger.info("Dropped #{channels.size} channel(s) on #{session.host}:#{session.port}")
424
+ end
425
+
426
+ def node_up(session)
427
+ @logger.info("Node #{session.host}:#{session.port} connected")
428
+ @on_node_up&.call(session)
429
+ rescue => e
430
+ @logger.error("on_node_up failed for #{session.host}:#{session.port}: #{e.class}: #{e.message}")
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,113 @@
1
+ module AsyncRabbitMQ
2
+ # Base error class
3
+ class Error < StandardError; end
4
+
5
+ # Raised when a TCP connection cannot be established or times out during
6
+ # the AMQP connection.start / tune / open negotiation.
7
+ class ConnectionTimeoutError < Error; end
8
+
9
+ # Raised on hard AMQP errors (501-541) — the broker closed the connection.
10
+ # Callers must reconnect.
11
+ class ConnectionError < Error
12
+ attr_reader :code, :text
13
+
14
+ def initialize(msg = nil, code: nil, text: nil)
15
+ @code = code
16
+ @text = text
17
+ super(msg || "Connection error #{code}: #{text}")
18
+ end
19
+ end
20
+
21
+ # Raised on soft AMQP errors (311/312/313/403/404/405/406) — the broker closed
22
+ # the channel. The connection stays alive; callers may open a new channel.
23
+ #
24
+ # When the error comes from a broker-sent channel.close, +close_method+ is the
25
+ # decoded AMQ::Protocol::Channel::Close and the predicates below identify the
26
+ # common causes without callers having to pattern-match +text+.
27
+ class ChannelError < Error
28
+ attr_reader :code, :text, :channel_id, :close_method
29
+
30
+ def initialize(msg = nil, code: nil, text: nil, channel_id: nil, close_method: nil)
31
+ @code = code
32
+ @text = text
33
+ @channel_id = channel_id
34
+ @close_method = close_method
35
+ super(msg || "Channel error #{code}: #{text}")
36
+ end
37
+
38
+ # Consumer delivery acknowledgement timeout (406 PRECONDITION_FAILED).
39
+ # See https://www.rabbitmq.com/docs/consumers#acknowledgement-timeout
40
+ def delivery_ack_timeout?
41
+ @close_method.respond_to?(:delivery_ack_timeout?) && @close_method.delivery_ack_timeout?
42
+ end
43
+
44
+ # Unknown delivery tag, e.g. a double ack (406 PRECONDITION_FAILED).
45
+ def unknown_delivery_tag?
46
+ @close_method.respond_to?(:unknown_delivery_tag?) && @close_method.unknown_delivery_tag?
47
+ end
48
+
49
+ # Message exceeded the broker's configured maximum size (406 PRECONDITION_FAILED).
50
+ def message_too_large?
51
+ @close_method.respond_to?(:message_too_large?) && @close_method.message_too_large?
52
+ end
53
+ end
54
+
55
+ # Raised when no heartbeat is received within 2× the negotiated interval.
56
+ class HeartbeatTimeoutError < ConnectionError; end
57
+
58
+ # Raised when an operation is attempted on a closed channel or session.
59
+ class NotOpenError < Error; end
60
+
61
+ # Raised by Session#open_channel when every channel id up to the negotiated
62
+ # channel_max is in use. Opening one more would make the broker close the
63
+ # whole connection (530 NOT_ALLOWED), so the client refuses first.
64
+ class ChannelLimitError < Error; end
65
+
66
+ # Raised by wait_for_confirms on a channel in confirm-tracking mode when the
67
+ # broker nacked at least one message in the cycle (see Channel#confirm_select).
68
+ class MessageNacked < Error
69
+ attr_reader :nacked_tags, :channel_id
70
+
71
+ def initialize(msg = nil, nacked_tags: [], channel_id: nil)
72
+ @nacked_tags = nacked_tags
73
+ @channel_id = channel_id
74
+ super(msg || "Broker nacked #{nacked_tags.size} message(s) on channel #{channel_id}: tags #{nacked_tags.inspect}")
75
+ end
76
+ end
77
+
78
+ # Raised when a cluster-wide operation did not succeed on every node, e.g.
79
+ # a credential rotation that some nodes refused. The message names which.
80
+ class ClusterError < Error; end
81
+
82
+ # Raised by Channel#wait_for_confirms(timeout:) when the broker has not
83
+ # confirmed every outstanding publish in time. The messages named by
84
+ # #unconfirmed_tags are in an unknown state: the broker may still have them.
85
+ class ConfirmTimeoutError < Error
86
+ attr_reader :unconfirmed_tags, :channel_id
87
+
88
+ def initialize(msg = nil, unconfirmed_tags: [], channel_id: nil)
89
+ @unconfirmed_tags = unconfirmed_tags
90
+ @channel_id = channel_id
91
+ super(msg || "Broker did not confirm #{unconfirmed_tags.size} message(s) on channel " \
92
+ "#{channel_id} in time: tags #{unconfirmed_tags.inspect}")
93
+ end
94
+ end
95
+
96
+ # Raised when the broker does not answer a synchronous channel operation
97
+ # (queue.declare, basic.consume, ...) within the session's rpc_timeout.
98
+ # Bunny calls this a continuation timeout.
99
+ class RpcTimeoutError < Error; end
100
+
101
+ # Raised when authentication fails: no SASL mechanism in common with the
102
+ # broker, or the broker refused the credentials / vhost access with a
103
+ # connection.close 403 ACCESS_REFUSED (then +code+ and +text+ are set).
104
+ class AuthenticationError < Error
105
+ attr_reader :code, :text
106
+
107
+ def initialize(msg = nil, code: nil, text: nil)
108
+ @code = code
109
+ @text = text
110
+ super(msg || "Authentication failed (#{code}): #{text}")
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,70 @@
1
+ module AsyncRabbitMQ
2
+ # Represents an AMQP exchange bound to a channel.
3
+ # Created via channel.exchange("name", type: :direct, durable: true).
4
+ class Exchange
5
+ attr_reader :name, :type
6
+
7
+ # Built-in and commonly used exchange types (Bunny parity).
8
+ TYPE_DIRECT = "direct"
9
+ TYPE_FANOUT = "fanout"
10
+ TYPE_TOPIC = "topic"
11
+ TYPE_HEADERS = "headers"
12
+ TYPE_CONSISTENT_HASH = "x-consistent-hash"
13
+ TYPE_MODULUS_HASH = "x-modulus-hash"
14
+ TYPE_RANDOM = "x-random"
15
+ TYPE_LOCAL_RANDOM = "x-local-random"
16
+
17
+ PREDEFINED_EXCHANGES = %w[
18
+ amq.direct amq.fanout amq.topic amq.headers amq.match amq.rabbitmq.trace
19
+ ].freeze
20
+
21
+ def initialize(name, type, channel, durable: false, auto_delete: false, internal: false)
22
+ @name = name
23
+ @type = type
24
+ @channel = channel
25
+ @durable = durable
26
+ @auto_delete = auto_delete
27
+ @internal = internal
28
+ end
29
+
30
+ def durable?
31
+ @durable
32
+ end
33
+
34
+ def auto_delete?
35
+ @auto_delete
36
+ end
37
+
38
+ def internal?
39
+ @internal
40
+ end
41
+
42
+ def predefined?
43
+ @name == "" || PREDEFINED_EXCHANGES.include?(@name)
44
+ end
45
+
46
+ def on_return(&block)
47
+ @channel.on_return(&block)
48
+ end
49
+
50
+ def publish(payload, routing_key: "", **opts)
51
+ @channel.basic_publish(payload, exchange: @name, routing_key: routing_key, **opts)
52
+ end
53
+
54
+ def delete(if_unused: false)
55
+ @channel.exchange_delete(@name, if_unused: if_unused)
56
+ end
57
+
58
+ def bind(destination:, routing_key: "", arguments: {})
59
+ @channel.exchange_bind(destination: destination, source: @name,
60
+ routing_key: routing_key, arguments: arguments)
61
+ self
62
+ end
63
+
64
+ def unbind(destination:, routing_key: "", arguments: {})
65
+ @channel.exchange_unbind(destination: destination, source: @name,
66
+ routing_key: routing_key, arguments: arguments)
67
+ self
68
+ end
69
+ end
70
+ end