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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +268 -0
- data/LICENSE +21 -0
- data/README.md +594 -0
- data/exe/async-rabbitmq +12 -0
- data/lib/async_rabbitmq/channel.rb +1422 -0
- data/lib/async_rabbitmq/channel_id_allocator.rb +57 -0
- data/lib/async_rabbitmq/cli.rb +300 -0
- data/lib/async_rabbitmq/cluster.rb +433 -0
- data/lib/async_rabbitmq/errors.rb +113 -0
- data/lib/async_rabbitmq/exchange.rb +70 -0
- data/lib/async_rabbitmq/frame_io.rb +309 -0
- data/lib/async_rabbitmq/log.rb +52 -0
- data/lib/async_rabbitmq/notifier.rb +122 -0
- data/lib/async_rabbitmq/pool.rb +43 -0
- data/lib/async_rabbitmq/queue.rb +88 -0
- data/lib/async_rabbitmq/sasl.rb +123 -0
- data/lib/async_rabbitmq/session.rb +1154 -0
- data/lib/async_rabbitmq/telemetry/open_telemetry.rb +211 -0
- data/lib/async_rabbitmq/tls.rb +82 -0
- data/lib/async_rabbitmq/topology_registry.rb +210 -0
- data/lib/async_rabbitmq/version.rb +3 -0
- data/lib/async_rabbitmq/versioned_delivery_tag.rb +62 -0
- data/lib/async_rabbitmq.rb +18 -0
- metadata +180 -0
|
@@ -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
|