amqp-client 2.0.0 → 2.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c07d38a543ff2c0e6e57dfc4ce4921ba74dbac1091cd55a7d06fc791a4ba501e
4
- data.tar.gz: 1909a126c2c6c21d36990cff7b0c623ad4e843afe52ddd5cad69299487286f0c
3
+ metadata.gz: 768fa0e71de1ae6cc2ae41c902c4d61ee505f2dbf400552371fde1185a0afc60
4
+ data.tar.gz: 52b30e22ac20f6ab94d30b24457a930b9dd72d36c7196d26f255badaa6cc1220
5
5
  SHA512:
6
- metadata.gz: 6628d1283167e699482baf15ae84bc336c59015b9a86921090ea2a15513267e1fe290788640cef65f92e88f779b039288b05fa6a50d25a449144268edeac7003
7
- data.tar.gz: 1684f2e5ef0ffb7c8a67371fe80031904681ccac21e164d190a87605f52b5b73560b4dbd2862f4047f944238f1126aa6de139cdbd1fa1b9acdd39324f8d71c56
6
+ metadata.gz: f5e29bcd3dc25e4af06a2f5401a839a3b0d6796e8265783994facbf1a042504d351c9af5e34f96c2f52ace10e3dfb1f862030eeb1f13dc06164c1099b8bf89a5
7
+ data.tar.gz: e65b267fcd879a029752d724c28a17760ad3a4723ad25657b69baa0e1c53cbaa284f67a57872ef6afa3d8e2522dc64414d5b8a75cc0181d85a0f99feae2c7e1d
@@ -78,6 +78,8 @@ module AMQP
78
78
  # @return [nil]
79
79
  # @api private
80
80
  def closed!(level, code, reason, classid, methodid)
81
+ return if @closed
82
+
81
83
  @closed = [level, code, reason, classid, methodid]
82
84
  @replies.close
83
85
  @basic_gets.close
@@ -348,8 +350,10 @@ module AMQP
348
350
  consume_loop(msg_q, consumer_tag, &blk)
349
351
  nil
350
352
  else
351
- threads = Array.new(worker_threads) do
352
- Thread.new { consume_loop(msg_q, consumer_tag, &blk) }
353
+ threads = Array.new(worker_threads) do |i|
354
+ t = Thread.new { consume_loop(msg_q, consumer_tag, &blk) }
355
+ t.name = @connection.thread_name(role: "consumer", detail: "ch=#{@id} tag=#{consumer_tag} ##{i + 1}")
356
+ t
353
357
  end
354
358
  @consumers[consumer_tag] =
355
359
  ConsumeOk.new(channel_id: @id, consumer_tag:, worker_threads: threads, msg_q:, on_cancel:)
@@ -467,8 +471,12 @@ module AMQP
467
471
  def wait_for_confirms
468
472
  @unconfirmed_lock.synchronize do
469
473
  until @unconfirmed.empty?
470
- @unconfirmed_empty.wait(@unconfirmed_lock)
474
+ # Check before waiting: if the channel was closed (and the
475
+ # @unconfirmed_empty broadcast from #closed! fired) before we got
476
+ # here, the wakeup is already gone and #wait would block forever.
471
477
  raise Error::Closed.new(@id, *@closed) if @closed
478
+
479
+ @unconfirmed_empty.wait(@unconfirmed_lock)
472
480
  end
473
481
  result = !@nacked
474
482
  @nacked = false # Reset for next round of publishes
@@ -481,15 +489,22 @@ module AMQP
481
489
  def confirm(args)
482
490
  ack_or_nack, delivery_tag, multiple = *args
483
491
  @unconfirmed_lock.synchronize do
484
- case multiple
485
- when true
486
- idx = @unconfirmed.index(delivery_tag) || raise("Delivery tag not found")
487
- @unconfirmed.shift(idx + 1)
488
- when false
489
- @unconfirmed.delete(delivery_tag) || raise("Delivery tag not found")
492
+ # A tag we're not tracking (a duplicate, out-of-order, or broker
493
+ # quirk) is logged and ignored, not raised: #confirm runs on the
494
+ # read_loop thread, where an exception would tear down the connection.
495
+ confirmed =
496
+ if multiple
497
+ idx = @unconfirmed.index(delivery_tag)
498
+ @unconfirmed.shift(idx + 1) if idx
499
+ else
500
+ @unconfirmed.delete(delivery_tag)
501
+ end
502
+ if confirmed
503
+ @nacked = true if ack_or_nack == :nack
504
+ @unconfirmed_empty.broadcast if @unconfirmed.empty?
505
+ else
506
+ warn "AMQP-Client received #{ack_or_nack} for unknown delivery tag #{delivery_tag} on channel #{@id}"
490
507
  end
491
- @nacked = true if ack_or_nack == :nack
492
- @unconfirmed_empty.broadcast if @unconfirmed.empty?
493
508
  end
494
509
  end
495
510
 
@@ -589,7 +604,8 @@ module AMQP
589
604
  next_msg = @next_msg
590
605
  if next_msg.is_a? ReturnMessage
591
606
  if @on_return
592
- Thread.new { @on_return.call(next_msg) }
607
+ t = Thread.new { @on_return.call(next_msg) }
608
+ t.name = @connection.thread_name(role: "on_return", detail: "ch=#{@id}")
593
609
  else
594
610
  warn "AMQP-Client message returned: #{next_msg.inspect}"
595
611
  end
@@ -622,6 +638,11 @@ module AMQP
622
638
  while (msg = queue.pop)
623
639
  begin
624
640
  yield msg
641
+ rescue Error::ConnectionClosed, Error::ChannelClosed
642
+ # The connection or channel closed while the message was being processed (e.g. an
643
+ # ack/reject/publish from the consumer raced a shutdown). The worker can't make
644
+ # progress and there's no bug to surface, so stop quietly instead of crashing it.
645
+ return
625
646
  rescue StandardError # cancel the consumer if an uncaught exception is raised
626
647
  begin
627
648
  close("Unexpected exception in consumer #{tag} thread", 500)
@@ -17,6 +17,9 @@ module AMQP
17
17
  # otherwise the user have to run it explicitly, without {#read_loop} the connection won't function
18
18
  # @param codec_registry [MessageCodecRegistry] Registry for message codecs
19
19
  # @param strict_coding [Boolean] Whether to raise errors on unsupported codecs
20
+ # @param name [String, nil] Instance identifier embedded in thread names
21
+ # (e.g. "amqp.read_loop[name] host:port") and lifecycle log prefixes.
22
+ # Usually sourced from the URL's `?name=` query param by {Client}.
20
23
  # @option options [Boolean] connection_name (PROGRAM_NAME) Set a name for the connection to be able to identify
21
24
  # the client from the broker
22
25
  # @option options [Boolean] verify_peer (true) Verify broker's TLS certificate, set to false for self-signed certs
@@ -28,7 +31,7 @@ module AMQP
28
31
  # Maxium allowed is 65_536. The smallest of the client's and the broker's value will be used.
29
32
  # @option options [String] keepalive (60:10:3) TCP keepalive setting, 60s idle, 10s interval between probes, 3 probes
30
33
  # @return [Connection]
31
- def initialize(uri = "", read_loop_thread: true, codec_registry: nil, strict_coding: false, **options)
34
+ def initialize(uri = "", read_loop_thread: true, codec_registry: nil, strict_coding: false, name: nil, **options)
32
35
  uri = URI.parse(uri)
33
36
  tls = uri.scheme == "amqps"
34
37
  port = port_from_env || uri.port || (tls ? 5671 : 5672)
@@ -38,6 +41,10 @@ module AMQP
38
41
  vhost = URI.decode_www_form_component(uri.path[1..] || "/")
39
42
  options = URI.decode_www_form(uri.query || "").map! { |k, v| [k.to_sym, v] }.to_h.merge(options)
40
43
 
44
+ @host = host
45
+ @port = port
46
+ @name = name
47
+
41
48
  socket = open_socket(host, port, tls, options)
42
49
  channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, options)
43
50
 
@@ -59,7 +66,20 @@ module AMQP
59
66
  # Only used with heartbeats
60
67
  @last_activity_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
61
68
 
62
- Thread.new { read_loop } if read_loop_thread
69
+ return unless read_loop_thread
70
+
71
+ t = Thread.new { read_loop }
72
+ t.name = thread_name(role: "read_loop")
73
+ end
74
+
75
+ # Build a thread name for a role attached to this connection.
76
+ # Format: "amqp.<role>[<name>] <host>:<port>[ <detail>]" — the `[<name>]`
77
+ # segment is only present when the connection has a `name:`.
78
+ # @api private
79
+ def thread_name(role:, detail: nil)
80
+ suffix = @name ? "[#{@name}]" : ""
81
+ base = "amqp.#{role}#{suffix} #{@host}:#{@port}"
82
+ detail ? "#{base} #{detail}" : base
63
83
  end
64
84
 
65
85
  # Indicates that the server is blocking publishes.
@@ -223,7 +243,7 @@ module AMQP
223
243
 
224
244
  # make sure that the frame end is correct
225
245
  frame_end = socket.readchar.ord
226
- raise Error::UnexpectedFrameTypeEnd, frame_end if frame_end != 206
246
+ raise Error::UnexpectedFrameEnd, frame_end if frame_end != 206
227
247
 
228
248
  # parse the frame, will return false if a close frame was received
229
249
  parse_frame(type, channel_id, frame_buffer) || return
@@ -236,6 +256,10 @@ module AMQP
236
256
  ensure
237
257
  @closed ||= [400, "unknown"]
238
258
  @replies.close
259
+ # Wake channels still blocked in #expect / #wait_for_confirms: an abrupt
260
+ # socket close means no channel/connection close frame ever reached them.
261
+ code, reason = @closed.first(2)
262
+ @channels_lock.synchronize { @channels.values }.each { |ch| ch.closed!(:connection, code, reason, 0, 0) }
239
263
  begin
240
264
  if @write_lock.owned? # if connection is blocked
241
265
  @socket.close
@@ -451,7 +475,7 @@ module AMQP
451
475
 
452
476
  # Start the heartbeat background thread (called from connection#tune)
453
477
  def start_heartbeats(period)
454
- Thread.new do
478
+ t = Thread.new do
455
479
  Thread.current.abort_on_exception = true # Raising an unhandled exception is a bug
456
480
  interval = period / 2.0
457
481
  loop do
@@ -471,6 +495,7 @@ module AMQP
471
495
  end
472
496
  end
473
497
  end
498
+ t.name = thread_name(role: "heartbeat")
474
499
  end
475
500
 
476
501
  def send_heartbeat
@@ -526,13 +551,16 @@ module AMQP
526
551
  loop do # rubocop:disable Metrics/BlockLength
527
552
  begin
528
553
  socket.readpartial(4096, buf)
554
+ # The 7-byte frame header may arrive split across reads (seen on JRuby); buffer it
555
+ # fully before unpacking, else frame_size is nil and frame_end below fails on nil.
556
+ buf << socket.readpartial(4096) while buf.bytesize < 7
529
557
  rescue *READ_EXCEPTIONS => e
530
558
  raise Error, "Could not establish AMQP connection: #{e.message}"
531
559
  end
532
560
 
533
561
  type, channel_id, frame_size = buf.unpack("C S> L>")
534
562
  frame_end = buf.getbyte(frame_size + 7)
535
- raise Error::UnexpectedFrameTypeEnd, frame_end if frame_end != 206
563
+ raise Error::UnexpectedFrameEnd, frame_end if frame_end != 206
536
564
 
537
565
  case type
538
566
  when 1 # method frame
@@ -38,6 +38,40 @@ module AMQP
38
38
 
39
39
  def decode(data, _properties) = Zlib.inflate(data)
40
40
  end.new
41
+
42
+ # Raw DEFLATE coder (RFC 1951 -- no zlib header, no Adler-32 checksum).
43
+ # Not registered by default. Use to interoperate with producers that emit
44
+ # raw DEFLATE under content_encoding "deflate", the same ambiguity HTTP
45
+ # has carried for decades and that Zlib::Deflate.new(level, -MAX_WBITS)
46
+ # exists for.
47
+ #
48
+ # @example Override the built-in deflate coder
49
+ # AMQP::Client.configure do |config|
50
+ # config.enable_builtin_codecs
51
+ # config.register_coder(content_encoding: "deflate",
52
+ # coder: AMQP::Client::Coders::DeflateRaw)
53
+ # end
54
+ DeflateRaw = Class.new do
55
+ def encode(data, _properties)
56
+ return data if data.encoding == Encoding::BINARY
57
+
58
+ deflater = Zlib::Deflate.new(Zlib::DEFAULT_COMPRESSION, -Zlib::MAX_WBITS)
59
+ begin
60
+ deflater.deflate(data, Zlib::FINISH)
61
+ ensure
62
+ deflater.close
63
+ end
64
+ end
65
+
66
+ def decode(data, _properties)
67
+ inflater = Zlib::Inflate.new(-Zlib::MAX_WBITS)
68
+ begin
69
+ inflater.inflate(data)
70
+ ensure
71
+ inflater.close
72
+ end
73
+ end
74
+ end.new
41
75
  end
42
76
  end
43
77
  end
@@ -3,6 +3,6 @@
3
3
  module AMQP
4
4
  class Client
5
5
  # Version of the client library
6
- VERSION = "2.0.0"
6
+ VERSION = "2.1.0"
7
7
  end
8
8
  end
data/lib/amqp/client.rb CHANGED
@@ -32,9 +32,14 @@ module AMQP
32
32
  # the smallest of the client's and the broker's values will be used
33
33
  # @option options [Integer] channel_max (2048) Maximum number of channels the client will be allowed to have open.
34
34
  # Maximum allowed is 65_536. The smallest of the client's and the broker's value will be used.
35
+ # @option options [#info, #warn, #error] logger (nil) Logger for {#start} lifecycle events
36
+ # (connected/reconnected/disconnected/reconnect errors). When nil, reconnect errors are
37
+ # written to stderr via Kernel#warn for backwards compatibility.
35
38
  def initialize(uri = "", **options)
36
39
  @uri = uri
37
40
  @options = options
41
+ @logger = options[:logger]
42
+ @name = parse_name(uri)
38
43
  @queues = {}
39
44
  @exchanges = {}
40
45
  @consumers = {}
@@ -57,7 +62,8 @@ module AMQP
57
62
  # @example
58
63
  # connection = AMQP::Client.new("amqps://server.rmq.cloudamqp.com", connection_name: "My connection").connect
59
64
  def connect(read_loop_thread: true)
60
- Connection.new(@uri, read_loop_thread:, codec_registry: @codec_registry, strict_coding: @strict_coding, **@options)
65
+ Connection.new(@uri, read_loop_thread:, name: @name,
66
+ codec_registry: @codec_registry, strict_coding: @strict_coding, **@options)
61
67
  end
62
68
 
63
69
  # Opens an AMQP connection using the high level API, will try to reconnect if successfully connected at first
@@ -74,14 +80,19 @@ module AMQP
74
80
 
75
81
  @supervisor_started = true
76
82
  @stopped = false
77
- Thread.new(connect(read_loop_thread: false)) do |conn|
83
+ initial_conn = connect(read_loop_thread: false)
84
+ log_lifecycle(:info, "connected")
85
+ supervisor = Thread.new(initial_conn) do |conn| # rubocop:disable Metrics/BlockLength
78
86
  Thread.current.abort_on_exception = true # Raising an unhandled exception is a bug
79
- loop do
87
+ loop do # rubocop:disable Metrics/BlockLength
80
88
  break if @stopped
81
89
 
82
- conn ||= connect(read_loop_thread: false)
90
+ unless conn
91
+ conn = connect(read_loop_thread: false)
92
+ log_lifecycle(:info, "reconnected")
93
+ end
83
94
 
84
- Thread.new do
95
+ setup = Thread.new do
85
96
  # restore connection in another thread, read_loop have to run
86
97
  conn.channel(1) # reserve channel 1 for publishes
87
98
  @consumers.each_value do |consumer|
@@ -97,15 +108,18 @@ module AMQP
97
108
  # Remove consumers whose internal queues were already closed (e.g. cancelled during reconnect window)
98
109
  @consumers.delete_if { |_, c| c.closed? }
99
110
  end
111
+ setup.name = thread_name("reconnect_setup")
100
112
  conn.read_loop # blocks until connection is closed, then reconnect
113
+ log_lifecycle(:warn, "disconnected")
101
114
  rescue Error => e
102
- warn "AMQP-Client reconnect error: #{e.inspect}"
115
+ log_reconnect_error(e)
103
116
  sleep @options[:reconnect_interval] || 1
104
117
  ensure
105
118
  @connq.clear
106
119
  conn = nil
107
120
  end
108
121
  end
122
+ supervisor.name = thread_name("supervisor")
109
123
  end
110
124
  self
111
125
  end
@@ -331,8 +345,9 @@ module AMQP
331
345
  # @return [Message, nil] The message from the queue or nil if the queue is empty
332
346
  def get(queue, no_ack: false)
333
347
  with_connection do |conn|
334
- ch = conn.channel
335
- ch.basic_get(queue, no_ack:)
348
+ conn.with_channel do |ch|
349
+ ch.basic_get(queue, no_ack:)
350
+ end
336
351
  end
337
352
  end
338
353
 
@@ -556,12 +571,51 @@ module AMQP
556
571
  def cancel_consumer(consumer)
557
572
  @consumers.delete(consumer.id)
558
573
  with_connection do |conn|
559
- conn.channel(consumer.channel_id).basic_cancel(consumer.tag)
574
+ ch = conn.channel(consumer.channel_id)
575
+ begin
576
+ ch.basic_cancel(consumer.tag)
577
+ ensure
578
+ ch.close
579
+ end
560
580
  end
561
581
  end
562
582
 
563
583
  private
564
584
 
585
+ def parse_name(uri)
586
+ return nil if uri.nil? || uri.empty?
587
+
588
+ query = URI.parse(uri).query
589
+ return nil unless query
590
+
591
+ URI.decode_www_form(query).each { |k, v| return v if k == "name" }
592
+ nil
593
+ rescue URI::InvalidURIError
594
+ nil
595
+ end
596
+
597
+ def log_lifecycle(level, event)
598
+ return unless @logger
599
+
600
+ @logger.public_send(level, "#{lifecycle_prefix}: #{event}")
601
+ end
602
+
603
+ def log_reconnect_error(err)
604
+ if @logger
605
+ @logger.warn("#{lifecycle_prefix}: reconnect error: #{err.inspect}")
606
+ else
607
+ warn "AMQP-Client reconnect error: #{err.inspect}"
608
+ end
609
+ end
610
+
611
+ def thread_name(role)
612
+ @name ? "amqp.#{role}[#{@name}]" : "amqp.#{role}"
613
+ end
614
+
615
+ def lifecycle_prefix
616
+ @name ? "AMQP::Client[#{@name}]" : "AMQP::Client"
617
+ end
618
+
565
619
  def default_content_properties
566
620
  {
567
621
  content_type: @default_content_type,
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amqp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - CloudAMQP
@@ -49,14 +49,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
49
49
  requirements:
50
50
  - - ">="
51
51
  - !ruby/object:Gem::Version
52
- version: 3.2.0
52
+ version: 3.3.0
53
53
  required_rubygems_version: !ruby/object:Gem::Requirement
54
54
  requirements:
55
55
  - - ">="
56
56
  - !ruby/object:Gem::Version
57
57
  version: '0'
58
58
  requirements: []
59
- rubygems_version: 3.6.9
59
+ rubygems_version: 4.0.10
60
60
  specification_version: 4
61
61
  summary: AMQP 0-9-1 client
62
62
  test_files: []