Class: AceMQ::AMQP::Transport
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Transport
- Defined in:
- lib/acemq/amqp/transport.rb
Overview
The broker, over AMQP 0-9-1.
Everything above this class is about envelopes, codecs and retries and knows nothing about channels; everything below it is bunny. The seam is here so the consumer's retry arithmetic can be tested without a broker, by handing Connection something else that answers these methods.
Defined Under Namespace
Classes: Subscription
Constant Summary collapse
- UNEXPLAINED_BLOCK =
What #blocked_reason says when the broker has blocked the connection and this process cannot say why.
Two ways that happens: a session started elsewhere and handed to #initialize after RabbitMQ had already blocked it, so the frame carrying the reason came and went before anything here was listening; or an application that registered its own
on_blockedon the session afterwards, which replaces the callback below — bunny keeps one. Neither is worth a silence: that the connection is blocked at all is the half an operator acts on. "the broker did not say why"
Instance Attribute Summary collapse
-
#session ⇒ Object
readonly
The bunny session, for the things this class deliberately does not wrap.
Class Method Summary collapse
-
.default_vhost(url) ⇒ Object
private
A URL whose path is a bare slash, with the slash taken off.
-
.load_driver! ⇒ Object
How this transport reaches RabbitMQ.
-
.open(url, heartbeat: :server, connection_timeout: 10, security: nil, credentials: nil, max_outstanding_publishes: PublishPermits::DEFAULT, **options) ⇒ Transport
Opens a connection.
-
.redact(url) ⇒ Object
private
A URL with its password taken out, for an error message that will be logged.
-
.single_host?(options) ⇒ Boolean
private
Whether bunny will have one address to connect to, read the way bunny's own
addresses_fromreads it: the first of these it finds, or the URL.
Instance Method Summary collapse
-
#bind(queue:, exchange:, routing_key: "") ⇒ Object
Routes messages matching a key from an exchange to a queue.
-
#blocked? ⇒ Boolean
Whether the broker has asked this connection to stop publishing.
-
#blocked_reason ⇒ String?
Why the broker has asked this connection to stop publishing, or nil.
-
#close ⇒ Object
Cancels every subscription and closes the connection.
-
#declare_exchange(name, kind:, durable: true, auto_delete: false, arguments: {}) ⇒ Object
Creates an exchange unless it is already there.
-
#declare_queue(name, queue_type: nil, durable: true, auto_delete: false, exclusive: false, arguments: {}) ⇒ Object
Creates a queue unless it is already there.
-
#delete_exchange(name) ⇒ Object
Removes an exchange.
-
#delete_queue(name) ⇒ Object
Removes a queue and every message still on it.
-
#initialize(session, max_outstanding_publishes: PublishPermits::DEFAULT, single_host: false) ⇒ Transport
constructor
A new instance of Transport.
-
#max_outstanding_publishes ⇒ Object
How many publishes may be waiting for a confirm on this connection at once.
-
#message_count(queue) ⇒ Object
How many messages are waiting on a queue.
-
#open? ⇒ Boolean
Whether the connection is up.
-
#publish(exchange:, routing_key:, body:, content_type: nil, message_id: nil, headers: {}, persistent: true, reply_to: nil, mandatory: false) ⇒ String
Sends one message and waits for the broker to take responsibility.
-
#publish_all(messages) ⇒ Array<String, PublishError>
Sends a batch, and waits for every confirm at the end.
-
#pull(queue) ⇒ Delivery?
Takes one message off a queue, without subscribing to it.
-
#queue_exists?(name) ⇒ Boolean
Whether a queue is on the broker, creating nothing.
-
#subscribe(queue, prefetch: 20, concurrency: 1, tag: nil, arguments: {}) {|delivery| ... } ⇒ Subscription
Delivers messages until the returned subscription is cancelled.
Constructor Details
#initialize(session, max_outstanding_publishes: PublishPermits::DEFAULT, single_host: false) ⇒ Transport
Returns a new instance of Transport.
1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 |
# File 'lib/acemq/amqp/transport.rb', line 1017 def initialize(session, max_outstanding_publishes: PublishPermits::DEFAULT, single_host: false) @session = session @single_host = single_host @permits = PublishPermits.new(max_outstanding_publishes) @lock = Mutex.new # A lock of its own, because a pull holds messages unacknowledged across # a whole pass and settling one has to reach the channel it came down. # Sharing the publish lock would mean a replay's own republish waiting # on the channel it is about to acknowledge from. @pull_lock = Mutex.new @subscriptions = [] @returns = ReturnedMessages.new @confirms = ConfirmWait.new # A lock of its own again, and the smallest one in the class: the # broker's blocked callback runs on bunny's reader thread, and a health # probe reads it from a web server's. Sharing the publish lock would # mean a readiness probe queuing behind a publish that is itself waiting # for the broker that has just blocked it. @blocked_lock = Mutex.new @blocked_reason = nil # Its own lock again, and for the same reason: this one is written from # bunny's recovery thread and read by every thread that publishes. @recovery_lock = Mutex.new @recovering = false watch_blocked watch_recovery end |
Instance Attribute Details
#session ⇒ Object (readonly)
The bunny session, for the things this class deliberately does not wrap.
1047 1048 1049 |
# File 'lib/acemq/amqp/transport.rb', line 1047 def session @session end |
Class Method Details
.default_vhost(url) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
A URL whose path is a bare slash, with the slash taken off.
amqp://host:5672/ is the URL the Go and Python libraries print in
their documentation and connect to the default vhost with, and the one
the TCK hands every driver. Bunny reads the empty path after that slash
as a vhost named "", which the broker refuses with NOT_ALLOWED, so the
same URL worked there and not here. Without the slash bunny uses "/",
as they do; a vhost named in the path, %2F included, is left alone.
988 989 990 991 992 |
# File 'lib/acemq/amqp/transport.rb', line 988 def self.default_vhost(url) return url unless url.is_a?(String) url.sub(%r{\A(amqps?://[^/?#]+)/(?=[?#]|\z)}i, '\1') end |
.load_driver! ⇒ Object
How this transport reaches RabbitMQ.
Required lazily, and only here. The gem declares no runtime dependencies because reading an AceMQ envelope should not oblige a process to install a broker client it will never open a socket with — an audit tool that parses headers out of a log has no business compiling in an AMQP stack. The cost of that choice is that the failure moves from install time to the first connection, so it is caught here and re-raised saying what to install.
913 914 915 916 917 918 919 920 |
# File 'lib/acemq/amqp/transport.rb', line 913 def self.load_driver! require "bunny" rescue LoadError => e raise DependencyMissing, "the AceMQ transport needs the bunny gem, which is not installed. " \ "Add `gem \"bunny\", \"~> 2.23\"` to your Gemfile, or run " \ "`gem install bunny`. (#{e.})" end |
.open(url, heartbeat: :server, connection_timeout: 10, security: nil, credentials: nil, max_outstanding_publishes: PublishPermits::DEFAULT, **options) ⇒ Transport
Opens a connection.
The URL decides how the connection is protected unless a Security says
otherwise: amqps:// is encrypted and the broker is verified against
the machine's trust store, amqp:// is plaintext. Pass security: for
a private certificate authority, a client certificate, or the deliberate
absence of verification; pass credentials: to keep the password out of
the URL, and so out of the error message two lines below this one.
The security options are merged over anything in options rather than
under it. A caller who reaches past Security to bunny's own TLS keys
is describing the same thing twice, and of the two answers the one that
went through the checks in this library is the one to honour.
952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 |
# File 'lib/acemq/amqp/transport.rb', line 952 def self.open(url, heartbeat: :server, connection_timeout: 10, security: nil, credentials: nil, max_outstanding_publishes: PublishPermits::DEFAULT, **) load_driver! url = default_vhost(url) security = Security.for_connection(url, security: security, credentials: credentials) # Built before the socket is opened: a ceiling of nought is a # configuration mistake, and finding it out after a connection has been # established means an error that mentions the broker for a reason that # has nothing to do with it. permits = PublishPermits.new(max_outstanding_publishes) # Ours goes first so a caller's own `topology_recovery_filter` in +options+ # replaces it rather than being silently overridden. session = Bunny.new(url, topology_recovery_filter: RecoverLiveChannelsOnly.new, heartbeat: heartbeat, connection_timeout: connection_timeout, **, **security.) security.configure(session) session.start new(session, max_outstanding_publishes: permits.limit, single_host: single_host?()) rescue DependencyMissing, ConfigurationError raise rescue StandardError => e raise TransportError, "cannot reach the broker at #{redact(url)}: #{e.}" end |
.redact(url) ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
A URL with its password taken out, for an error message that will be logged. A credential that reaches a log is a credential that has to be rotated.
1008 1009 1010 |
# File 'lib/acemq/amqp/transport.rb', line 1008 def self.redact(url) url.to_s.sub(%r{(://[^:/@]+):[^@]*@}, '\1:***@') end |
.single_host?(options) ⇒ Boolean
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Whether bunny will have one address to connect to, read the way bunny's
own addresses_from reads it: the first of these it finds, or the URL.
998 999 1000 1001 |
# File 'lib/acemq/amqp/transport.rb', line 998 def self.single_host?() hosts = [:host] || [:hostname] || [:addresses] || [:hosts] Array(hosts).size <= 1 end |
Instance Method Details
#bind(queue:, exchange:, routing_key: "") ⇒ Object
Routes messages matching a key from an exchange to a queue.
1084 1085 1086 1087 1088 |
# File 'lib/acemq/amqp/transport.rb', line 1084 def bind(queue:, exchange:, routing_key: "") with_admin_channel do |channel| channel.queue_bind(queue, exchange, routing_key: routing_key) end end |
#blocked? ⇒ Boolean
Whether the broker has asked this connection to stop publishing.
1333 |
# File 'lib/acemq/amqp/transport.rb', line 1333 def blocked? = !blocked_reason.nil? |
#blocked_reason ⇒ String?
Why the broker has asked this connection to stop publishing, or nil.
RabbitMQ sends connection.blocked when it is low on memory or disk and
connection.unblocked when the alarm clears. Between the two the broker
stops reading, so #publish declines with PublishingPausedError
rather than writing into a socket nobody reads — and this is the
difference between an operator seeing "the broker is out of disk" and
seeing a service whose publishes are being declined.
The reason is the broker's own words, "low on disk space" or
"low on memory", recorded from the frame that carried it. Bunny keeps
the flag but not the reason, so both are asked for: bunny's blocked?
decides whether, which means a reason recorded before an automatic
recovery cannot outlive the connection it belonged to, and the callback
below supplies why.
1326 1327 1328 1329 1330 |
# File 'lib/acemq/amqp/transport.rb', line 1326 def blocked_reason return nil unless @session.respond_to?(:blocked?) && @session.blocked? @blocked_lock.synchronize { @blocked_reason } || UNEXPLAINED_BLOCK end |
#close ⇒ Object
Cancels every subscription and closes the connection.
1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 |
# File 'lib/acemq/amqp/transport.rb', line 1336 def close # Taken out under the lock and cancelled outside it. A handler still # running is about to want this same lock to publish a dead letter, and # holding it across a cancel that waits on the broker is how a shutdown # becomes a deadlock nobody can reproduce. subscriptions = @lock.synchronize do taken = @subscriptions @subscriptions = [] taken end subscriptions.each(&:cancel) @lock.synchronize do @publish_channel.close if @publish_channel&.open? @publish_channel = nil end # Anything a pass was still holding goes back to its queue when this # closes, which is the whole reason a declined message is held rather # than returned one at a time. @pull_lock.synchronize do @pull_channel.close if @pull_channel&.open? @pull_channel = nil end @session.close nil rescue StandardError => e raise TransportError, "cannot close the connection cleanly: #{e.}" end |
#declare_exchange(name, kind:, durable: true, auto_delete: false, arguments: {}) ⇒ Object
Creates an exchange unless it is already there.
1057 1058 1059 1060 1061 1062 |
# File 'lib/acemq/amqp/transport.rb', line 1057 def declare_exchange(name, kind:, durable: true, auto_delete: false, arguments: {}) with_admin_channel do |channel| channel.exchange_declare(name, kind.to_s, durable: durable, auto_delete: auto_delete, arguments: stringify(arguments)) end end |
#declare_queue(name, queue_type: nil, durable: true, auto_delete: false, exclusive: false, arguments: {}) ⇒ Object
Creates a queue unless it is already there.
Deliberately without the quorum default Connection#declare_queue has:
this is the layer that does what it is told, and a caller who reached
past the connection to it is declaring exactly what they wrote down.
queue_type is honoured when it is given, because AceMQ::AMQP::Topology#apply can
be handed a transport and its plan says which kind each queue is.
1074 1075 1076 1077 1078 1079 1080 1081 |
# File 'lib/acemq/amqp/transport.rb', line 1074 def declare_queue(name, queue_type: nil, durable: true, auto_delete: false, exclusive: false, arguments: {}) arguments = QueueType.table(queue_type, arguments) if queue_type with_admin_channel do |channel| channel.queue_declare(name, durable: durable, auto_delete: auto_delete, exclusive: exclusive, arguments: stringify(arguments)) end end |
#delete_exchange(name) ⇒ Object
Removes an exchange.
1302 1303 1304 |
# File 'lib/acemq/amqp/transport.rb', line 1302 def delete_exchange(name) with_admin_channel { |channel| channel.exchange_delete(name) } end |
#delete_queue(name) ⇒ Object
Removes a queue and every message still on it.
For tests and for tools. A service that deletes queues has usually confused a queue with a session, and the messages go with it — including the ones somebody was about to be paid for.
1297 1298 1299 |
# File 'lib/acemq/amqp/transport.rb', line 1297 def delete_queue(name) with_admin_channel { |channel| channel.queue_delete(name) } end |
#max_outstanding_publishes ⇒ Object
How many publishes may be waiting for a confirm on this connection at once. See PublishPermits.
1051 |
# File 'lib/acemq/amqp/transport.rb', line 1051 def max_outstanding_publishes = @permits.limit |
#message_count(queue) ⇒ Object
How many messages are waiting on a queue.
A number for a dashboard or a test, not a decision to make in a handler: it is a snapshot of a queue that is still moving, and it is already wrong by the time it is read anywhere else.
1277 1278 1279 1280 1281 |
# File 'lib/acemq/amqp/transport.rb', line 1277 def (queue) with_admin_channel do |channel| channel.queue_declare(queue, passive: true). end end |
#open? ⇒ Boolean
Whether the connection is up.
1307 |
# File 'lib/acemq/amqp/transport.rb', line 1307 def open? = @session.open? |
#publish(exchange:, routing_key:, body:, content_type: nil, message_id: nil, headers: {}, persistent: true, reply_to: nil, mandatory: false) ⇒ String
Sends one message and waits for the broker to take responsibility.
The wait is the point. Without publisher confirms a successful publish means the bytes reached a socket, which is not the same as the broker having them, and the difference only ever shows up as messages that were never anywhere.
mandatory buys the other half of that. A confirm says the broker has
the message; it does not say the message reached a queue, and a publish
to an exchange with no matching binding is confirmed and dropped in the
same breath. Asking for mandatory makes the broker hand such a message
back as basic.return before it confirms it, which is the only way
AMQP will tell you. It costs a round trip only when the message really
is unroutable.
1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 |
# File 'lib/acemq/amqp/transport.rb', line 1115 def publish(exchange:, routing_key:, body:, content_type: nil, message_id: nil, headers: {}, persistent: true, reply_to: nil, mandatory: false) refuse_while_blocked!(, exchange, routing_key) returned = nil confirmed = publish_channel([, exchange, routing_key]) do |channel| @returns.arm(channel, exchange) if mandatory ok = write_and_wait(channel, body.to_s, exchange, routing_key, content_type: content_type, message_id: , reply_to: presence(reply_to), mandatory: mandatory, headers: stringify(headers), persistent: persistent) # The return, when there is one, arrives ahead of the confirm and is # dispatched on the reader thread while this one waits — so by the # time the wait is over it is already waiting to be taken. returned = @returns.take if mandatory ok end unless confirmed raise PublishError, PublishFailure.not_confirmed(, exchange, routing_key) end return if returned.nil? raise PublishError.new( PublishFailure.not_routed(, exchange, routing_key, returned), unroutable: true ) rescue PublishError raise rescue StandardError => e raise PublishError, PublishFailure.not_published(, exchange, routing_key, e.) end |
#publish_all(messages) ⇒ Array<String, PublishError>
Sends a batch, and waits for every confirm at the end.
The difference from a loop around #publish is the whole point of the method: there, every message costs a round trip to the broker and back before the next one is written, because every message waits for its own confirm. Here every message is handed to the channel first and the wait happens once, for all of them — which is what publisher confirms were designed for and what makes a thousand-message publish take about as long as one.
It is not atomic, and nothing in AMQP could make it so. A batch that half arrived is the ordinary outcome of a broker problem partway through, so each message gets its own answer rather than the batch getting one between them.
1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 |
# File 'lib/acemq/amqp/transport.rb', line 1166 def publish_all() return [] if .empty? # Answered per message rather than raised, because that is what this # method promises for every other failure. if (reason = blocked_reason) return .map do |m| PublishingPausedError.new( PublishFailure.paused(m[:message_id], m[:exchange], m[:routing_key], reason) ) end end batch = BatchPublish.new(, @returns, @confirms) publish_channel { |channel| batch.run(channel, @permits) } rescue PublishingPausedError => e # Refused before anything was written -- a recovery in progress -- so # every message gets the same answer, as it would for a blocked broker. .map do |m| PublishingPausedError.new( PublishFailure.not_published(m[:message_id], m[:exchange], m[:routing_key], e.) ) end end |
#pull(queue) ⇒ Delivery?
Takes one message off a queue, without subscribing to it.
A subscription is a standing arrangement; this is a single read, which is what a pass over a queue with a beginning and an end needs. Draining a dead-letter queue with a consumer means writing the code that decides when to stop, and getting it wrong means a tool that never exits.
The delivery is unacknowledged, and settling it is the caller's job. That is deliberate: a message left unacknowledged is held by the broker rather than lost, so a tool that dies half way through a pass returns everything it was holding.
1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 |
# File 'lib/acemq/amqp/transport.rb', line 1260 def pull(queue) @pull_lock.synchronize do channel = pull_channel info, properties, body = channel.basic_get(queue, manual_ack: true) next nil if info.nil? delivery_from(channel, info, properties, body, lock: @pull_lock) end rescue StandardError => e raise TransportError, "cannot read a message from #{queue.inspect}: #{e.}" end |
#queue_exists?(name) ⇒ Boolean
Whether a queue is on the broker, creating nothing.
Its own question because a declaration cannot answer it: declaring a queue that is missing creates it, so anything built on declarations would create the very queues it was asked only to look for.
1288 1289 1290 |
# File 'lib/acemq/amqp/transport.rb', line 1288 def queue_exists?(name) @session.queue_exists?(name) end |
#subscribe(queue, prefetch: 20, concurrency: 1, tag: nil, arguments: {}) {|delivery| ... } ⇒ Subscription
Delivers messages until the returned subscription is cancelled.
The block runs on bunny's consumer pool rather than on the caller's
thread, so concurrency is how many messages this subscription works
on at once. One by default, which keeps a queue's messages in the order
the broker offers them.
1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 |
# File 'lib/acemq/amqp/transport.rb', line 1201 def subscribe(queue, prefetch: 20, concurrency: 1, tag: nil, arguments: {}, &handler) # No shutdown timeout on the channel's work pool, and that +nil+ is what # keeps a drain inside its deadline. # # Cancelling the last consumer on a bunny channel shuts its work pool down # and, given a timeout, waits that long for a busy worker -- sixty seconds # by default -- inside +basic_cancel+, before {Connection#close} has looked # at its own deadline at all. Measured on 0.7.5: close(timeout: 0.5) on a # 1.5s handler took 1.5s and raised nothing; on a 63s handler it took 60.5s # and then said "within 0.5s". With +nil+ the pool is told to stop and the # cancel returns, so the only wait left is the library's own, against one # deadline. The fourth positional argument has meant this since bunny 2.x # and still does in 3.4. channel = @session.create_channel(nil, concurrency, false, nil) channel.prefetch(prefetch) if prefetch.positive? # A consumer tag of our own when the caller gives none, rather than leaving it # to the broker. # # bunny 3.x records every consumer for topology recovery and keys the record by # tag, and recovery re-subscribes -- which records the consumer again. With a # broker-assigned tag the new registration lands under a *new* key, so the # registry grows instead of being replaced: measured at 1, 2, 4 recorded # consumers over three recoveries. Each recorded consumer then gets its own # `maybe_reinitialize_consumer_pool!`, and all but the last of those pools is # orphaned with its threads still parked -- which is the thread growth a soak # found in the Ruby standing load. A plain bunny consumer does not grow this # way, which is what showed the tag was ours to fix. # # Generated once here, so it survives every recovery of this subscription and # the record is replaced rather than added to. tag ||= "acemq-#{queue}-#{SecureRandom.hex(8)}" arguments = stringify(arguments) position = StreamPosition.for(arguments) consumer = channel.queue(queue, passive: true).subscribe( manual_ack: true, block: false, consumer_tag: tag, arguments: arguments ) do |info, properties, body| delivery = delivery_from(channel, info, properties, body) handler.call(position ? position.track(delivery) : delivery) end track(Subscription.new(channel, consumer, position)) rescue StandardError => e channel.close if channel&.open? raise TransportError, "cannot consume from #{queue.inspect}: #{e.}" end |