Class: AceMQ::AMQP::Connection
- Inherits:
-
Object
- Object
- AceMQ::AMQP::Connection
- Defined in:
- lib/acemq/amqp/connection.rb
Overview
A connection to a broker.
Everything a service does with AceMQ goes through one of these: it holds the codec, the origin stamped on what it publishes, and the retry policy its consumers use unless they say otherwise. One per process is the usual shape — a connection is a socket and a heartbeat, and opening one per publish is how a service ends up with a thousand of them.
mq = Connection.open("amqp://guest:guest@localhost:5672",
origin: "checkout@#{Socket.gethostname}",
retry_policy: RetryPolicy.exponential(5, 1, 60))
mq.publish({ "order_id" => "A-1" }, to: "orders.new")
mq.consume("orders.new") do ||
place(.payload) ? Ack.accept : Ack.retry("the warehouse said no")
end
Constant Summary collapse
- DEFAULT_PREFETCH =
How many unacknowledged messages a consumer holds by default.
20- DRAIN_TIMEOUT =
Seconds #close will spend draining, across every consumer together.
Twenty, because the number that matters is not this one: it is whatever will kill the process if the drain outlasts it. Kubernetes sends SIGTERM, waits
terminationGracePeriodSeconds— thirty by default — and then SIGKILLs; systemd hasTimeoutStopSec;docker stopwaits ten. Twenty inside a thirty-second grace period leaves ten seconds for the web server, for whatever else is shutting down alongside this, and for the process to actually exit. Setting the two equal means the orchestrator wins the race sometimes, and a shutdown that is correct on most deployments is one nobody debugs until it is not.The same twenty Go's
docs/lifecycle.mdrecommends, for the same reason. Raise it for handlers that genuinely take longer, and raise the grace period with it. 20
Instance Attribute Summary collapse
-
#codec ⇒ Object
readonly
Returns the value of attribute codec.
-
#interceptors ⇒ Object
readonly
Returns the value of attribute interceptors.
-
#origin ⇒ Object
readonly
Returns the value of attribute origin.
-
#prefetch ⇒ Object
readonly
Returns the value of attribute prefetch.
-
#retry_policy ⇒ Object
readonly
Returns the value of attribute retry_policy.
-
#retry_threshold ⇒ Object
readonly
Returns the value of attribute retry_threshold.
-
#telemetry ⇒ Object
readonly
Returns the value of attribute telemetry.
-
#transport ⇒ Object
readonly
Returns the value of attribute transport.
Class Method Summary collapse
-
.default_origin ⇒ Object
Names the machine but not the service, which is the most an unconfigured library can honestly say.
-
.open(url, codec: JSONCodec.new, origin: nil, retry_policy: RetryPolicy.none, prefetch: DEFAULT_PREFETCH, telemetry: nil, retry_threshold: RetryLadder::DEFAULT_THRESHOLD, **transport_options) ⇒ Connection
Opens a connection to a broker.
Instance Method Summary collapse
-
#apply(topology) ⇒ Topology
Declares everything in a topology.
- #bind(**options) ⇒ Object
-
#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(timeout: DRAIN_TIMEOUT) ⇒ nil
Stops every consumer and closes the connection, within one deadline.
-
#consume(queue, codec: nil, retry_policy: nil, prefetch: nil, concurrency: 1, tag: nil, arguments: {}, retry_threshold: nil, dead_letters: true, &handler) ⇒ Consumer
Reads messages from a queue until the returned consumer is cancelled.
-
#consumers ⇒ Object
The consumers started on this connection, as they stand.
- #declare_exchange(name, **options) ⇒ Object
-
#declare_queue(name, queue_type: nil, durable: true, auto_delete: false, exclusive: false, arguments: {}) ⇒ Object
Declares a durable quorum queue, which is what a queue is here unless something says otherwise.
- #delete_queue(name) ⇒ Object
-
#health ⇒ Health::Report
Whether this process can still do what it is running to do.
-
#initialize(transport:, codec: JSONCodec.new, origin: nil, retry_policy: RetryPolicy.none, prefetch: DEFAULT_PREFETCH, retry_threshold: RetryLadder::DEFAULT_THRESHOLD, telemetry: nil) ⇒ Connection
constructor
Wraps a transport that is already open.
-
#intercept_consume(interceptor = nil, order: nil, &block) ⇒ Connection
Adds an interceptor to every consumer on this connection.
-
#intercept_publish(interceptor = nil, order: nil, &block) ⇒ Connection
Adds an interceptor to every publish on this connection.
- #message_count(queue) ⇒ Object
-
#publish(payload, to:, exchange: "", envelope: nil, codec: nil, persistent: true, reply_to: nil, mandatory: false, **fields) ⇒ Envelope
Publishes one message.
-
#publish_all(payloads, to:, exchange: "", envelopes: nil, codec: nil, persistent: true, reply_to: nil, mandatory: false, **fields) ⇒ Array<Envelope>
Publishes a batch, and waits for every confirm together.
- #pull(queue) ⇒ Object
- #queue_exists?(name) ⇒ Boolean
Constructor Details
#initialize(transport:, codec: JSONCodec.new, origin: nil, retry_policy: RetryPolicy.none, prefetch: DEFAULT_PREFETCH, retry_threshold: RetryLadder::DEFAULT_THRESHOLD, telemetry: nil) ⇒ Connection
Wraps a transport that is already open.
open covers the ordinary case. This is for a transport that is not reached by a URL — a fake in a test, most of all, which is what makes the retry arithmetic below testable without a broker in the room.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/acemq/amqp/connection.rb', line 221 def initialize(transport:, codec: JSONCodec.new, origin: nil, retry_policy: RetryPolicy.none, prefetch: DEFAULT_PREFETCH, retry_threshold: RetryLadder::DEFAULT_THRESHOLD, telemetry: nil) @telemetry = Telemetry::Reporter.for(telemetry) @transport = transport @codec = Codec.check!(codec) @origin = origin.nil? || origin.to_s.empty? ? self.class.default_origin : origin.to_s @retry_policy = retry_policy @prefetch = prefetch @retry_threshold = retry_threshold @interceptors = Interceptors.new @consumers = [] @lock = Mutex.new end |
Instance Attribute Details
#codec ⇒ Object (readonly)
Returns the value of attribute codec.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def codec @codec end |
#interceptors ⇒ Object (readonly)
Returns the value of attribute interceptors.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def interceptors @interceptors end |
#origin ⇒ Object (readonly)
Returns the value of attribute origin.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def origin @origin end |
#prefetch ⇒ Object (readonly)
Returns the value of attribute prefetch.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def prefetch @prefetch end |
#retry_policy ⇒ Object (readonly)
Returns the value of attribute retry_policy.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def retry_policy @retry_policy end |
#retry_threshold ⇒ Object (readonly)
Returns the value of attribute retry_threshold.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def retry_threshold @retry_threshold end |
#telemetry ⇒ Object (readonly)
Returns the value of attribute telemetry.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def telemetry @telemetry end |
#transport ⇒ Object (readonly)
Returns the value of attribute transport.
188 189 190 |
# File 'lib/acemq/amqp/connection.rb', line 188 def transport @transport end |
Class Method Details
.default_origin ⇒ Object
Names the machine but not the service, which is the most an
unconfigured library can honestly say. Set origin: to say more: it is
the field somebody reads first when a message arrives from a fleet and
the question is which of forty pods sent it.
273 274 275 276 277 |
# File 'lib/acemq/amqp/connection.rb', line 273 def self.default_origin "acemq@#{Socket.gethostname}" rescue StandardError "acemq@unknown" end |
.open(url, codec: JSONCodec.new, origin: nil, retry_policy: RetryPolicy.none, prefetch: DEFAULT_PREFETCH, telemetry: nil, retry_threshold: RetryLadder::DEFAULT_THRESHOLD, **transport_options) ⇒ Connection
Opens a connection to a broker.
208 209 210 211 212 213 214 |
# File 'lib/acemq/amqp/connection.rb', line 208 def self.open(url, codec: JSONCodec.new, origin: nil, retry_policy: RetryPolicy.none, prefetch: DEFAULT_PREFETCH, telemetry: nil, retry_threshold: RetryLadder::DEFAULT_THRESHOLD, **) new(transport: Transport.open(url, **), codec: codec, origin: origin, retry_policy: retry_policy, prefetch: prefetch, retry_threshold: retry_threshold, telemetry: telemetry) end |
Instance Method Details
#apply(topology) ⇒ Topology
Declares everything in a topology.
465 |
# File 'lib/acemq/amqp/connection.rb', line 465 def apply(topology) = topology.apply(self) |
#bind(**options) ⇒ Object
532 |
# File 'lib/acemq/amqp/connection.rb', line 532 def bind(**) = @transport.bind(**) |
#blocked? ⇒ Boolean
Whether the broker has asked this connection to stop publishing.
497 |
# File 'lib/acemq/amqp/connection.rb', line 497 def blocked? = !blocked_reason.nil? |
#blocked_reason ⇒ String?
Why the broker has asked this connection to stop publishing, or nil.
RabbitMQ blocks a connection when it is running low on memory or disk, and stops reading the socket. A publish on a connection that is already blocked is declined with PublishingPausedError rather than written, so this is the difference between an operator seeing "the broker is out of disk" and seeing a service whose publishes are being declined.
Free: a flag the broker pushed, read out of memory. Unlike #health it costs no round trip, so it is fine to ask per request.
It is not a reason to fail a readiness probe, and Health deliberately does not: see there for why restarting a producer into a broker that is already under pressure helps nobody.
494 |
# File 'lib/acemq/amqp/connection.rb', line 494 def blocked_reason = Health.blocked_reason(@transport) |
#close(timeout: DRAIN_TIMEOUT) ⇒ nil
Stops every consumer and closes the connection, within one deadline.
Consumers are stopped first and their handlers allowed to finish, so a message being worked on when this is called is acknowledged rather than returned to the queue for somebody else to redo.
The deadline is for the whole drain, not for each consumer. That distinction is the entire point of the keyword: a per-consumer wait is not a bound on anything a process can be held to, because eight consumers each given thirty seconds is four minutes, and four minutes into a shutdown Kubernetes has long since sent SIGKILL — killing every handler mid-flight and leaving everything it was holding unsettled, which is the exact outcome draining exists to avoid. Every consumer is stopped at once, and then all of their handlers are waited for against the one deadline.
When the deadline expires with handlers still running, this closes their channels anyway and raises DrainTimeout once the socket is shut. Nothing is lost — an unacknowledged delivery goes back to the broker and is redelivered — but the drain did not do what it was asked, and saying so is the honest answer. Reporting success would mean an operator whose grace period is too short never finds out. The handlers themselves run on to the end and their outcome is discarded; AceMQ::AMQP::Consumer#cancel says why.
568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 |
# File 'lib/acemq/amqp/connection.rb', line 568 def close(timeout: DRAIN_TIMEOUT) consumers = @lock.synchronize do taken = @consumers @consumers = [] taken end failure, stranded = Drain.of(consumers, timeout) @transport.close # The transport is closed before either of these is raised, and that # order is deliberate: a shutdown that reports a problem having left the # socket open is a process that will not exit. raise failure if failure raise DrainTimeout.new(stranded, timeout) unless stranded.empty? nil end |
#consume(queue, codec: nil, retry_policy: nil, prefetch: nil, concurrency: 1, tag: nil, arguments: {}, retry_threshold: nil, dead_letters: true, &handler) ⇒ Consumer
Reads messages from a queue until the returned consumer is cancelled.
consumer = mq.consume("orders.new") do ||
place(.payload)
Ack.accept
end
The handler is called on the transport's threads, not this one, and
must return an Ack. A handler that raises is treated as Ack.retry,
except for FatalError, which is treated as Ack.reject — raising
that is how a handler says "stop now" without having to know how many
attempts remain.
440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 |
# File 'lib/acemq/amqp/connection.rb', line 440 def consume(queue, codec: nil, retry_policy: nil, prefetch: nil, concurrency: 1, tag: nil, arguments: {}, retry_threshold: nil, dead_letters: true, &handler) unless handler raise ArgumentError, "consume(#{queue.inspect}) needs a block to handle messages" end consumer = Consumer.new( transport: @transport, queue: queue, handler: handler, codec: codec.nil? ? @codec : Codec.check!(codec), retry_policy: retry_policy || @retry_policy, retry_threshold: retry_threshold || @retry_threshold, interceptors: @interceptors, telemetry: @telemetry ) consumer.start(prefetch: prefetch || @prefetch, concurrency: concurrency, tag: tag, arguments: arguments, dead_letters: dead_letters) @lock.synchronize { @consumers << consumer } consumer end |
#consumers ⇒ Object
The consumers started on this connection, as they stand.
A copy, taken under the lock. Handing back the list itself would let a caller iterate it while a consumer is being added on another thread, which is the sort of bug that only appears under load.
504 |
# File 'lib/acemq/amqp/connection.rb', line 504 def consumers = @lock.synchronize { @consumers.dup } |
#declare_exchange(name, **options) ⇒ Object
506 |
# File 'lib/acemq/amqp/connection.rb', line 506 def declare_exchange(name, **) = @transport.declare_exchange(name, **) |
#declare_queue(name, queue_type: nil, durable: true, auto_delete: false, exclusive: false, arguments: {}) ⇒ Object
Declares a durable quorum queue, which is what a queue is here unless something says otherwise.
The same default as Java's declareQueue, and the reason is interop
rather than taste: x-queue-type is part of a queue's identity to the
broker, so two services sharing orders have to declare the same kind
or the second one is refused with PRECONDITION_FAILED and cannot
consume at all. QueueType has the rest of the rule — a queue that is
exclusive, auto-deleting or transient stays classic because RabbitMQ
allows nothing else, and a x-queue-type already in arguments is
honoured, which is how a stream is declared.
522 523 524 525 526 527 528 529 530 |
# File 'lib/acemq/amqp/connection.rb', line 522 def declare_queue(name, queue_type: nil, durable: true, auto_delete: false, exclusive: false, arguments: {}) type, arguments = QueueType.resolve(name: name, requested: queue_type, durable: durable, exclusive: exclusive, auto_delete: auto_delete, arguments: arguments) @transport.declare_queue(name, queue_type: type, durable: durable, auto_delete: auto_delete, exclusive: exclusive, arguments: arguments) end |
#delete_queue(name) ⇒ Object
536 |
# File 'lib/acemq/amqp/connection.rb', line 536 def delete_queue(name) = @transport.delete_queue(name) |
#health ⇒ Health::Report
Whether this process can still do what it is running to do.
Costs a round trip to the broker, so it belongs on a readiness probe rather than in a request. See Health for what the statuses mean and why a stopped consumer is degraded rather than down.
474 |
# File 'lib/acemq/amqp/connection.rb', line 474 def health = Health.of(self) |
#intercept_consume(interceptor = nil, order: nil, &block) ⇒ Connection
Adds an interceptor to every consumer on this connection.
mq.intercept_consume(Tracing.new)
264 265 266 267 |
# File 'lib/acemq/amqp/connection.rb', line 264 def intercept_consume(interceptor = nil, order: nil, &block) @interceptors.add_consume(interceptor, order: order, &block) self end |
#intercept_publish(interceptor = nil, order: nil, &block) ⇒ Connection
Adds an interceptor to every publish on this connection.
mq.intercept_publish { |context| context.set_header("tenant", Current.tenant) }
See Interceptors for the hooks an object may answer, what raising from
each one means, and how order decides which runs first.
Registration is expected at start-up. A publisher reads the list at the moment it publishes rather than copying it, so an interceptor added later does apply to publishers that already exist — but a message already on its way will not see it.
251 252 253 254 |
# File 'lib/acemq/amqp/connection.rb', line 251 def intercept_publish(interceptor = nil, order: nil, &block) @interceptors.add_publish(interceptor, order: order, &block) self end |
#message_count(queue) ⇒ Object
534 |
# File 'lib/acemq/amqp/connection.rb', line 534 def (queue) = @transport.(queue) |
#publish(payload, to:, exchange: "", envelope: nil, codec: nil, persistent: true, reply_to: nil, mandatory: false, **fields) ⇒ Envelope
Publishes one message.
The envelope is built here unless one is passed: an identifier, a type falling back to the routing key, a correlation falling back to the identifier, this connection's origin, and the current time. Envelope fields given as keywords override those.
mq.publish(order, to: "orders.new", type: "order.placed.v2",
correlation_id: request_id)
318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 |
# File 'lib/acemq/amqp/connection.rb', line 318 def publish(payload, to:, exchange: "", envelope: nil, codec: nil, persistent: true, reply_to: nil, mandatory: false, **fields) started = Process.clock_gettime(Process::CLOCK_MONOTONIC) if envelope && !fields.empty? raise ArgumentError, "publish was given both an envelope and the fields to build one " \ "(#{fields.keys.join(", ")}); pass one or the other" end codec = codec.nil? ? @codec : Codec.check!(codec) envelope ||= Envelope.new(origin: @origin, **fields) # Built whether or not anything is registered. It is one small object # against a round trip to a broker, and the alternative is two code # paths through the one method every message in the process goes down. context = PublishContext.new(exchange: exchange, routing_key: to, envelope: envelope, payload: payload, reply_to: reply_to, mandatory: mandatory) send_intercepted(context, codec, persistent, started) rescue StandardError => e # Counted before the interceptors are told, so a publish refused by an # interceptor is counted too. It did not reach the broker, which is the # thing this metric is about. # # +unroutable+ rather than +failed+ for a message the broker took and # could not route, because those are two different problems: a failed # publish is a broker or a network, an unroutable one is a binding # nobody made, and an operator told +failed+ goes looking in the wrong # place. The span reads the same word off the same failure. record_publish(context, Telemetry::Outcome.of_publish_failure(e), started, exchange: exchange, to: to) @interceptors.on_publish_error(context, e) if context raise end |
#publish_all(payloads, to:, exchange: "", envelopes: nil, codec: nil, persistent: true, reply_to: nil, mandatory: false, **fields) ⇒ Array<Envelope>
Publishes a batch, and waits for every confirm together.
What bulk publishing actually wants: the throughput of pipelining with the safety of having waited. Every message is handed to the broker before any confirm is asked for, and then all of them are waited on at once. A loop around #publish is the other thing — a round trip to the broker and back per message — and it is the thing a caller can already write, which is why this method exists at all.
mq.publish_all(orders, to: "order.placed", exchange: "orders-events")
It is not atomic. AMQP has no such thing: there is no way to publish a hundred messages such that all or none of them arrive, and a library offering one would be lying. A batch that half arrived is the ordinary outcome of a broker problem partway through, so the failure says how many did arrive rather than only that something went wrong — a caller told nothing but "it failed" republishes messages that are already on a queue.
Every message goes through the publish interceptors exactly as a single
publish does, once each, and mandatory: means for each message what it
means for one: a message the broker had nowhere to route is that
message's failure, carrying PublishError#unroutable?, and the batch
counts it among the ones that did not arrive.
Nothing here bounds how many messages may be unconfirmed at once — the batch is as big as the array handed in. A batch of a hundred thousand is a hundred thousand messages held in memory by this process and by the broker, so split a very large one yourself.
400 401 402 403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/acemq/amqp/connection.rb', line 400 def publish_all(payloads, to:, exchange: "", envelopes: nil, codec: nil, persistent: true, reply_to: nil, mandatory: false, **fields) started = Process.clock_gettime(Process::CLOCK_MONOTONIC) payloads = check_batch!(payloads, envelopes, fields) return [] if payloads.empty? codec = codec.nil? ? @codec : Codec.check!(codec) contexts = payloads.each_with_index.map do |payload, index| envelope = envelopes ? envelopes[index] : Envelope.new(origin: @origin, **fields) PublishContext.new(exchange: exchange, routing_key: to, envelope: envelope, payload: payload, reply_to: reply_to, mandatory: mandatory) end send_all_intercepted(contexts, codec, persistent, started) end |
#pull(queue) ⇒ Object
533 |
# File 'lib/acemq/amqp/connection.rb', line 533 def pull(queue) = @transport.pull(queue) |
#queue_exists?(name) ⇒ Boolean
535 |
# File 'lib/acemq/amqp/connection.rb', line 535 def queue_exists?(name) = @transport.queue_exists?(name) |