amqp-client 0.3.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30006ba97b26d7e73cf9503841e2248744db3d9e86d1a32fe4c9abecd9ee69f7
4
- data.tar.gz: a59d667d971da49c2657e57efe385853adbef958fd191a01f0d1f4dbaaa1b8a8
3
+ metadata.gz: 5e6dd3fef130c286e97eadd9bb6d3d4de0fa67599361604cec3560879b9b4bdf
4
+ data.tar.gz: c2ce00f897c68d3085427854b08cc98d3487b40bb5327ce38bb216c98357e366
5
5
  SHA512:
6
- metadata.gz: 7ddb8f409abf9e773a6551ff53629b702e8324ea7ff794f6b14f09ef9dd7cb27a752e853bb312a8d9da88b826a7714e84006d5bb01b5efeef7bbaed56f14323a
7
- data.tar.gz: 642e332c0a031d27d7f364b66e0ae5beefef4bad402336f24052eef2679e306eec219369d7b8b2c1d8324eabafd0c40d45aa8feb474c3cd11ddc4578203f5bea
6
+ metadata.gz: 9b62e8ee74ec542be5617b08fbd0e36e5fc16294f99cd0ec61781c7c7425005c426e8358a74abf45a1a24ecb88451b94ef50ee7524403ac7097748b2e94b55b5
7
+ data.tar.gz: 5c3e63369578923e20b441fbe9728b374e18150c75ed155291b802c42f80f668b704b39e80b144b90346a8d769d9b71f031b9fca712e6ac52c100a88e46380e3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.0.0] - 2021-08-27
4
+
5
+ - Verify TLS certificate matches hostname
6
+ - TLS thread-safety
7
+ - Assemble Messages in the (single threaded) read_loop thread
8
+ - Give read_loop_thread higher priority so that channel errors crop up faster
9
+ - One less Thread required per Consumer
10
+ - Read exactly one frame at a time, not trying to split/assemble frames over socket reads
11
+ - Heafty speedup for message assembling with StringIO
12
+ - Channel#queue_declare returns a struct for nicer API (still backward compatible)
13
+ - AMQP::Client#publish_and_forget for fast, non confirmed publishes
14
+ - Allow Properties#timestamp to be an integer (in addition to Time)
15
+ - Bug fix allow Properties#expiration to be an Integer
16
+ - Consistent use of named parameters
17
+ - High level Exchange API
18
+ - Don't try to reconnect if first connect fails
19
+ - Bug fix: Close all channels when connection is closed by server
20
+ - Raise error if run out of channels
21
+ - Improved retry in high level client
22
+ - Bug fix: Support channel_max 0
23
+
3
24
  ## [0.3.0] - 2021-08-20
4
25
 
5
26
  - Channel#wait_for_confirms is a smarter way of waiting for publish confirms
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AMQP::Client
2
2
 
3
- An AMQP 0-9-1 client alternative, trying to keep things as simple as possible.
3
+ An AMQP 0-9-1 Ruby client, trying to keep things as simple as possible.
4
4
 
5
5
  ## Installation
6
6
 
@@ -34,7 +34,7 @@ msg = ch.basic_get q[:queue_name]
34
34
  puts msg.body
35
35
  ```
36
36
 
37
- High level API, is an easier and safer API, that only deal with durable queues and persisted messages. All methods are blocking in the case of connection loss etc. It's also fully thread-safe. Don't expect it to be extreme throughput, be expect 100% delivery guarantees (messages might be deliviered twice, in the unlikely event of a connection loss between message publish and message confirmed by the server).
37
+ High level API, is an easier and safer API, that only deal with durable queues and persisted messages. All methods are blocking in the case of connection loss etc. It's also fully thread-safe. Don't expect it to have extreme throughput, but expect 100% delivery guarantees (messages might be delivered twice, in the unlikely event of connection loss between message publish and message confirmation by the server).
38
38
 
39
39
  ```ruby
40
40
  amqp = AMQP::Client.new("amqp://localhost")
@@ -46,8 +46,8 @@ q = amqp.queue("myqueue")
46
46
  # Bind the queue to any exchange, with any binding key
47
47
  q.bind("amq.topic", "my.events.*")
48
48
 
49
- # The message will be reprocessed if the client lost connection to the server
50
- # between the message arrived and the message was supposed to be ack:ed.
49
+ # The message will be reprocessed if the client loses connection to the server
50
+ # between message arrival and when the message was supposed to be ack'ed.
51
51
  q.subscribe(prefetch: 20) do |msg|
52
52
  process(JSON.parse(msg.body))
53
53
  msg.ack
@@ -16,16 +16,22 @@ module AMQP
16
16
  @confirm = nil
17
17
  @unconfirmed = ::Queue.new
18
18
  @unconfirmed_empty = ::Queue.new
19
+ @basic_gets = ::Queue.new
19
20
  end
20
21
 
21
- attr_reader :id, :consumers
22
+ def inspect
23
+ "#<#{self.class} @id=#{@id} @open=#{@open} @closed=#{@closed} confirm_selected=#{!@confirm.nil?}"\
24
+ " consumer_count=#{@consumers.size} replies_count=#{@replies.size} unconfirmed_count=#{@unconfirmed.size}>"
25
+ end
26
+
27
+ attr_reader :id
22
28
 
23
29
  def open
24
30
  return self if @open
25
31
 
32
+ @open = true
26
33
  write_bytes FrameBytes.channel_open(@id)
27
34
  expect(:channel_open_ok)
28
- @open = true
29
35
  self
30
36
  end
31
37
 
@@ -33,21 +39,25 @@ module AMQP
33
39
  return if @closed
34
40
 
35
41
  write_bytes FrameBytes.channel_close(@id, reason, code)
36
- expect :channel_close_ok
37
42
  @closed = [code, reason]
43
+ expect :channel_close_ok
44
+ @replies.close
45
+ @basic_gets.close
46
+ @unconfirmed_empty.close
47
+ @consumers.each_value(&:close)
38
48
  end
39
49
 
40
- # Called when closed by server
50
+ # Called when channel is closed by server
41
51
  def closed!(code, reason, classid, methodid)
42
- write_bytes FrameBytes.channel_close_ok(@id)
43
52
  @closed = [code, reason, classid, methodid]
44
53
  @replies.close
45
- @consumers.each { |_, q| q.close }
46
- @consumers.clear
54
+ @basic_gets.close
55
+ @unconfirmed_empty.close
56
+ @consumers.each_value(&:close)
47
57
  end
48
58
 
49
- def exchange_declare(name, type, passive: false, durable: true, auto_delete: false, internal: false, **args)
50
- write_bytes FrameBytes.exchange_declare(@id, name, type, passive, durable, auto_delete, internal, args)
59
+ def exchange_declare(name, type, passive: false, durable: true, auto_delete: false, internal: false, arguments: {})
60
+ write_bytes FrameBytes.exchange_declare(@id, name, type, passive, durable, auto_delete, internal, arguments)
51
61
  expect :exchange_declare_ok
52
62
  end
53
63
 
@@ -56,16 +66,18 @@ module AMQP
56
66
  expect :exchange_delete_ok
57
67
  end
58
68
 
59
- def exchange_bind(destination, source, binding_key, arguments = {})
69
+ def exchange_bind(destination, source, binding_key, arguments: {})
60
70
  write_bytes FrameBytes.exchange_bind(@id, destination, source, binding_key, false, arguments)
61
71
  expect :exchange_bind_ok
62
72
  end
63
73
 
64
- def exchange_unbind(destination, source, binding_key, arguments = {})
74
+ def exchange_unbind(destination, source, binding_key, arguments: {})
65
75
  write_bytes FrameBytes.exchange_unbind(@id, destination, source, binding_key, false, arguments)
66
76
  expect :exchange_unbind_ok
67
77
  end
68
78
 
79
+ QueueOk = Struct.new(:queue_name, :message_count, :consumer_count)
80
+
69
81
  def queue_declare(name = "", passive: false, durable: true, exclusive: false, auto_delete: false, arguments: {})
70
82
  durable = false if name.empty?
71
83
  exclusive = true if name.empty?
@@ -73,11 +85,8 @@ module AMQP
73
85
 
74
86
  write_bytes FrameBytes.queue_declare(@id, name, passive, durable, exclusive, auto_delete, arguments)
75
87
  name, message_count, consumer_count = expect(:queue_declare_ok)
76
- {
77
- queue_name: name,
78
- message_count: message_count,
79
- consumer_count: consumer_count
80
- }
88
+
89
+ QueueOk.new(name, message_count, consumer_count)
81
90
  end
82
91
 
83
92
  def queue_delete(name, if_unused: false, if_empty: false, no_wait: false)
@@ -86,7 +95,7 @@ module AMQP
86
95
  message_count
87
96
  end
88
97
 
89
- def queue_bind(name, exchange, binding_key, arguments = {})
98
+ def queue_bind(name, exchange, binding_key, arguments: {})
90
99
  write_bytes FrameBytes.queue_bind(@id, name, exchange, binding_key, false, arguments)
91
100
  expect :queue_bind_ok
92
101
  end
@@ -96,29 +105,17 @@ module AMQP
96
105
  expect :queue_purge_ok unless no_wait
97
106
  end
98
107
 
99
- def queue_unbind(name, exchange, binding_key, arguments = {})
108
+ def queue_unbind(name, exchange, binding_key, arguments: {})
100
109
  write_bytes FrameBytes.queue_unbind(@id, name, exchange, binding_key, arguments)
101
110
  expect :queue_unbind_ok
102
111
  end
103
112
 
104
113
  def basic_get(queue_name, no_ack: true)
105
114
  write_bytes FrameBytes.basic_get(@id, queue_name, no_ack)
106
- frame, *rest = @replies.shift
107
- case frame
108
- when :basic_get_ok
109
- delivery_tag, exchange_name, routing_key, _message_count, redelivered = rest
110
- body_size, properties = expect(:header)
111
- pos = 0
112
- body = String.new("", capacity: body_size)
113
- while pos < body_size
114
- body_part, = expect(:body)
115
- body += body_part
116
- pos += body_part.bytesize
117
- end
118
- Message.new(self, delivery_tag, exchange_name, routing_key, properties, body, redelivered)
115
+ case (msg = @basic_gets.pop)
116
+ when Message then msg
119
117
  when :basic_get_empty then nil
120
118
  when nil then raise AMQP::Client::ChannelClosedError.new(@id, *@closed)
121
- else raise AMQP::Client::UnexpectedFrame.new(%i[basic_get_ok basic_get_empty], frame)
122
119
  end
123
120
  end
124
121
 
@@ -126,8 +123,12 @@ module AMQP
126
123
  frame_max = @connection.frame_max - 8
127
124
  id = @id
128
125
  mandatory = properties.delete(:mandatory) || false
126
+ case properties.delete(:persistent)
127
+ when true then properties[:delivery_mode] = 2
128
+ when false then properties[:delivery_mode] = 1
129
+ end
129
130
 
130
- if 0 < body.bytesize && body.bytesize <= frame_max
131
+ if body.bytesize.between?(1, frame_max)
131
132
  write_bytes FrameBytes.basic_publish(id, exchange, routing_key, mandatory),
132
133
  FrameBytes.header(id, body.bytesize, properties),
133
134
  FrameBytes.body(id, body)
@@ -156,22 +157,19 @@ module AMQP
156
157
 
157
158
  # Consume from a queue
158
159
  # worker_threads: 0 => blocking, messages are executed in the thread calling this method
159
- def basic_consume(queue, tag: "", no_ack: true, exclusive: false, arguments: {},
160
- worker_threads: 1)
160
+ def basic_consume(queue, tag: "", no_ack: true, exclusive: false, arguments: {}, worker_threads: 1)
161
161
  write_bytes FrameBytes.basic_consume(@id, queue, tag, no_ack, exclusive, arguments)
162
162
  tag, = expect(:basic_consume_ok)
163
163
  q = @consumers[tag] = ::Queue.new
164
- msgs = ::Queue.new
165
- Thread.new { recv_deliveries(tag, q, msgs) }
166
164
  if worker_threads.zero?
167
- while (msg = msgs.shift)
168
- yield msg
165
+ loop do
166
+ yield (q.pop || break)
169
167
  end
170
168
  else
171
169
  threads = Array.new(worker_threads) do
172
170
  Thread.new do
173
- while (msg = msgs.shift)
174
- yield(msg)
171
+ loop do
172
+ yield (q.pop || break)
175
173
  end
176
174
  end
177
175
  end
@@ -222,9 +220,14 @@ module AMQP
222
220
  def wait_for_confirms
223
221
  return true if @unconfirmed.empty?
224
222
 
225
- @unconfirmed_empty.pop
223
+ case @unconfirmed_empty.pop
224
+ when true then true
225
+ when false then false
226
+ else raise AMQP::Client::ChannelClosedError.new(@id, *@closed)
227
+ end
226
228
  end
227
229
 
230
+ # Called by Connection when received ack/nack from server
228
231
  def confirm(args)
229
232
  ack_or_nack, delivery_tag, multiple = *args
230
233
  loop do
@@ -239,7 +242,7 @@ module AMQP
239
242
  return unless @unconfirmed.empty?
240
243
 
241
244
  @unconfirmed_empty.num_waiting.times do
242
- @unconfirmed_empty << ack_or_nack == :ack
245
+ @unconfirmed_empty << (ack_or_nack == :ack)
243
246
  end
244
247
  end
245
248
 
@@ -258,47 +261,66 @@ module AMQP
258
261
  expect :tx_rollback_ok
259
262
  end
260
263
 
264
+ def on_return(&block)
265
+ @on_return = block
266
+ end
267
+
261
268
  def reply(args)
262
269
  @replies.push(args)
263
270
  end
264
271
 
265
272
  def message_returned(reply_code, reply_text, exchange, routing_key)
266
- Thread.new do
267
- body_size, properties = expect(:header)
268
- body = String.new("", capacity: body_size)
269
- while body.bytesize < body_size
270
- body_part, = expect(:body)
271
- body += body_part
272
- end
273
- msg = ReturnMessage.new(reply_code, reply_text, exchange, routing_key, properties, body)
273
+ @next_msg = ReturnMessage.new(reply_code, reply_text, exchange, routing_key, nil, "")
274
+ end
274
275
 
275
- if @on_return
276
- @on_return.call(msg)
277
- else
278
- puts "[WARN] Message returned: #{msg.inspect}"
279
- end
276
+ def message_delivered(consumer_tag, delivery_tag, redelivered, exchange, routing_key)
277
+ @next_msg = Message.new(self, delivery_tag, exchange, routing_key, nil, "", redelivered, consumer_tag)
278
+ end
279
+
280
+ def basic_get_empty
281
+ @basic_gets.push :basic_get_empty
282
+ end
283
+
284
+ def header_delivered(body_size, properties)
285
+ @next_msg.properties = properties
286
+ if body_size.zero?
287
+ next_message_finished!
288
+ else
289
+ @next_body = StringIO.new(String.new(capacity: body_size))
290
+ @next_body_size = body_size
280
291
  end
281
292
  end
282
293
 
283
- def on_return(&block)
284
- @on_return = block
294
+ def body_delivered(body_part)
295
+ @next_body.write(body_part)
296
+ return unless @next_body.pos == @next_body_size
297
+
298
+ @next_msg.body = @next_body.string
299
+ next_message_finished!
300
+ end
301
+
302
+ def close_consumer(tag)
303
+ @consumers.fetch(tag).close
285
304
  end
286
305
 
287
306
  private
288
307
 
289
- def recv_deliveries(consumer_tag, deliver_queue, msgs)
290
- loop do
291
- _, delivery_tag, redelivered, exchange, routing_key = deliver_queue.shift || raise(ClosedQueueError)
292
- body_size, properties = expect(:header)
293
- body = String.new("", capacity: body_size)
294
- while body.bytesize < body_size
295
- body_part, = expect(:body)
296
- body += body_part
308
+ def next_message_finished!
309
+ next_msg = @next_msg
310
+ if next_msg.is_a? ReturnMessage
311
+ if @on_return
312
+ Thread.new { @on_return.call(next_msg) }
313
+ else
314
+ warn "AMQP-Client message returned: #{msg.inspect}"
297
315
  end
298
- msgs.push Message.new(self, delivery_tag, exchange, routing_key, properties, body, redelivered, consumer_tag)
316
+ elsif next_msg.consumer_tag.nil?
317
+ @basic_gets.push next_msg
318
+ else
319
+ Thread.pass until (consumer = @consumers[next_msg.consumer_tag])
320
+ consumer.push next_msg
299
321
  end
300
322
  ensure
301
- msgs.close
323
+ @next_msg = @next_body = @next_body_size = nil
302
324
  end
303
325
 
304
326
  def write_bytes(*bytes)
@@ -308,13 +330,11 @@ module AMQP
308
330
  end
309
331
 
310
332
  def expect(expected_frame_type)
311
- loop do
312
- frame_type, *args = @replies.shift
313
- raise AMQP::Client::ChannelClosedError.new(@id, *@closed) if frame_type.nil?
314
- return args if frame_type == expected_frame_type
333
+ frame_type, *args = @replies.pop
334
+ raise AMQP::Client::ChannelClosedError.new(@id, *@closed) if frame_type.nil?
335
+ raise AMQP::Client::UnexpectedFrame.new(expected_frame_type, frame_type) unless frame_type == expected_frame_type
315
336
 
316
- @replies.push [frame_type, *args]
317
- end
337
+ args
318
338
  end
319
339
  end
320
340
  end
@@ -10,9 +10,7 @@ require_relative "./errors"
10
10
  module AMQP
11
11
  # Represents a single AMQP connection
12
12
  class Connection
13
- def self.connect(uri, **options)
14
- read_loop_thread = options[:read_loop_thread] || true
15
-
13
+ def self.connect(uri, read_loop_thread: true, **options)
16
14
  uri = URI.parse(uri)
17
15
  tls = uri.scheme == "amqps"
18
16
  port = port_from_env || uri.port || (tls ? 5671 : 5672)
@@ -34,6 +32,7 @@ module AMQP
34
32
  socket.sync_close = true # closing the TLS socket also closes the TCP socket
35
33
  socket.hostname = host # SNI host
36
34
  socket.connect
35
+ socket.post_connection_check(host) || raise(AMQP::Client::Error, "TLS certificate hostname doesn't match requested")
37
36
  end
38
37
  channel_max, frame_max, heartbeat = establish(socket, user, password, vhost, **options)
39
38
  Connection.new(socket, channel_max, frame_max, heartbeat, read_loop_thread: read_loop_thread)
@@ -41,22 +40,35 @@ module AMQP
41
40
 
42
41
  def initialize(socket, channel_max, frame_max, heartbeat, read_loop_thread: true)
43
42
  @socket = socket
44
- @channel_max = channel_max
43
+ @channel_max = channel_max.zero? ? 65_536 : channel_max
45
44
  @frame_max = frame_max
46
45
  @heartbeat = heartbeat
47
46
  @channels = {}
48
47
  @closed = false
49
48
  @replies = Queue.new
49
+ @write_lock = Mutex.new
50
50
  Thread.new { read_loop } if read_loop_thread
51
51
  end
52
52
 
53
53
  attr_reader :frame_max
54
54
 
55
+ def inspect
56
+ "#<#{self.class} @closed=#{@closed} channel_count=#{@channels.size}>"
57
+ end
58
+
55
59
  def channel(id = nil)
60
+ raise ArgumentError, "Channel ID cannot be 0" if id&.zero?
61
+ raise ArgumentError, "Channel ID higher than connection's channel max #{@channel_max}" if id && id > @channel_max
62
+
56
63
  if id
57
64
  ch = @channels[id] ||= Channel.new(self, id)
58
65
  else
59
- id = 1.upto(@channel_max) { |i| break i unless @channels.key? i }
66
+ id = nil
67
+ 1.upto(@channel_max) do |i|
68
+ break id = i unless @channels.key? i
69
+ end
70
+ raise AMQP::Client::Error, "Max channels reached" if id.nil?
71
+
60
72
  ch = @channels[id] = Channel.new(self, id)
61
73
  end
62
74
  ch.open
@@ -75,9 +87,10 @@ module AMQP
75
87
  def close(reason = "", code = 200)
76
88
  return if @closed
77
89
 
90
+ @closed = true
78
91
  write_bytes FrameBytes.connection_close(code, reason)
92
+ @channels.each_value { |ch| ch.closed!(code, reason, 0, 0) }
79
93
  expect(:close_ok)
80
- @closed = true
81
94
  end
82
95
 
83
96
  def closed?
@@ -85,47 +98,51 @@ module AMQP
85
98
  end
86
99
 
87
100
  def write_bytes(*bytes)
88
- @socket.write(*bytes)
101
+ if @socket.is_a? OpenSSL::SSL::SSLSocket
102
+ @write_lock.synchronize do
103
+ @socket.write(*bytes)
104
+ end
105
+ else
106
+ @socket.write(*bytes)
107
+ end
89
108
  rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
90
- raise AMQP::Client::Error.new("Could not write to socket", cause: e)
109
+ raise AMQP::Client::Error, "Could not write to socket, #{e.message}"
91
110
  end
92
111
 
93
112
  # Reads from the socket, required for any kind of progress. Blocks until the connection is closed
94
113
  def read_loop
114
+ # read more often than write so that channel errors crop up early
115
+ Thread.current.priority += 1
95
116
  socket = @socket
96
117
  frame_max = @frame_max
97
- buffer = String.new(capacity: frame_max)
118
+ frame_start = String.new(capacity: 7)
119
+ frame_buffer = String.new(capacity: frame_max)
98
120
  loop do
99
- begin
100
- socket.readpartial(frame_max, buffer)
101
- rescue IOError, OpenSSL::OpenSSLError, SystemCallError
102
- break
121
+ socket.read(7, frame_start)
122
+ type, channel_id, frame_size = frame_start.unpack("C S> L>")
123
+ if frame_size > frame_max
124
+ raise AMQP::Client::Error, "Frame size #{frame_size} is larger than negotiated max frame size #{frame_max}"
103
125
  end
104
126
 
105
- pos = 0
106
- while pos < buffer.bytesize
107
- buffer += socket.read(pos + 8 - buffer.bytesize) if pos + 8 > buffer.bytesize
108
- type, channel_id, frame_size = buffer.byteslice(pos, 7).unpack("C S> L>")
109
- if frame_size > frame_max
110
- raise AMQP::Client::Error, "Frame size #{frame_size} larger than negotiated max frame size #{frame_max}"
111
- end
127
+ # read the frame content
128
+ socket.read(frame_size, frame_buffer)
112
129
 
113
- frame_end_pos = pos + 7 + frame_size
114
- buffer += socket.read(frame_end_pos - buffer.bytesize + 1) if frame_end_pos + 1 > buffer.bytesize
115
- frame_end = buffer[frame_end_pos].ord
116
- raise AMQP::Client::UnexpectedFrameEnd, frame_end if frame_end != 206
130
+ # make sure that the frame end is correct
131
+ frame_end = socket.readchar.ord
132
+ raise AMQP::Client::UnexpectedFrameEnd, frame_end if frame_end != 206
117
133
 
118
- buf = buffer.byteslice(pos, frame_size + 8)
119
- pos += frame_size + 8
120
- parse_frame(type, channel_id, frame_size, buf) || return
121
- end
134
+ # parse the frame, will return false if a close frame was received
135
+ parse_frame(type, channel_id, frame_size, frame_buffer) || return
122
136
  end
137
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError => e
138
+ warn "AMQP-Client read error: #{e.inspect}"
139
+ nil # ignore read errors
123
140
  ensure
124
141
  @closed = true
125
142
  @replies.close
126
143
  begin
127
144
  @socket.close
128
- rescue IOError
145
+ rescue IOError, OpenSSL::OpenSSLError, SystemCallError
129
146
  nil
130
147
  end
131
148
  end
@@ -135,20 +152,26 @@ module AMQP
135
152
  def parse_frame(type, channel_id, frame_size, buf)
136
153
  case type
137
154
  when 1 # method frame
138
- class_id, method_id = buf.unpack("@7 S> S>")
155
+ class_id, method_id = buf.unpack("S> S>")
139
156
  case class_id
140
157
  when 10 # connection
141
158
  raise AMQP::Client::Error, "Unexpected channel id #{channel_id} for Connection frame" if channel_id != 0
142
159
 
143
160
  case method_id
144
161
  when 50 # connection#close
145
- code, text_len = buf.unpack("@11 S> C")
146
- text = buf.byteslice(14, text_len).force_encoding("utf-8")
147
- error_class_id, error_method_id = buf.byteslice(14 + text_len, 4).unpack("S> S>")
148
- warn "Connection closed #{code} #{text} #{error_class_id} #{error_method_id}"
149
- write_bytes FrameBytes.connection_close_ok
162
+ @closed = true
163
+ code, text_len = buf.unpack("@4 S> C")
164
+ text = buf.byteslice(7, text_len).force_encoding("utf-8")
165
+ error_class_id, error_method_id = buf.byteslice(7 + text_len, 4).unpack("S> S>")
166
+ @channels.each_value { |ch| ch.closed!(code, text, error_class_id, error_method_id) }
167
+ begin
168
+ write_bytes FrameBytes.connection_close_ok
169
+ rescue AMQP::Client::Error
170
+ nil # rabbitmq closes the socket after sending Connection::Close, so ignore write errors
171
+ end
150
172
  return false
151
173
  when 51 # connection#close-ok
174
+ @closed = true
152
175
  @replies.push [:close_ok]
153
176
  return false
154
177
  else raise AMQP::Client::UnsupportedMethodFrame, class_id, method_id
@@ -158,11 +181,12 @@ module AMQP
158
181
  when 11 # channel#open-ok
159
182
  @channels[channel_id].reply [:channel_open_ok]
160
183
  when 40 # channel#close
161
- reply_code, reply_text_len = buf.unpack("@11 S> C")
162
- reply_text = buf.byteslice(14, reply_text_len).force_encoding("utf-8")
163
- classid, methodid = buf.byteslice(14 + reply_text_len, 4).unpack("S> S>")
184
+ reply_code, reply_text_len = buf.unpack("@4 S> C")
185
+ reply_text = buf.byteslice(7, reply_text_len).force_encoding("utf-8")
186
+ classid, methodid = buf.byteslice(7 + reply_text_len, 4).unpack("S> S>")
164
187
  channel = @channels.delete(channel_id)
165
188
  channel.closed!(reply_code, reply_text, classid, methodid)
189
+ write_bytes FrameBytes.channel_close_ok(channel_id)
166
190
  when 41 # channel#close-ok
167
191
  channel = @channels.delete(channel_id)
168
192
  channel.reply [:channel_close_ok]
@@ -183,16 +207,16 @@ module AMQP
183
207
  when 50 # queue
184
208
  case method_id
185
209
  when 11 # declare-ok
186
- queue_name_len = buf.unpack1("@11 C")
187
- queue_name = buf.byteslice(12, queue_name_len).force_encoding("utf-8")
188
- message_count, consumer_count = buf.byteslice(12 + queue_name_len, 8).unpack1("L> L>")
210
+ queue_name_len = buf.unpack1("@4 C")
211
+ queue_name = buf.byteslice(5, queue_name_len).force_encoding("utf-8")
212
+ message_count, consumer_count = buf.byteslice(5 + queue_name_len, 8).unpack("L> L>")
189
213
  @channels[channel_id].reply [:queue_declare_ok, queue_name, message_count, consumer_count]
190
214
  when 21 # bind-ok
191
215
  @channels[channel_id].reply [:queue_bind_ok]
192
216
  when 31 # purge-ok
193
217
  @channels[channel_id].reply [:queue_purge_ok]
194
218
  when 41 # delete-ok
195
- message_count = buf.unpack1("@11 L>")
219
+ message_count = buf.unpack1("@4 L>")
196
220
  @channels[channel_id].reply [:queue_delete, message_count]
197
221
  when 51 # unbind-ok
198
222
  @channels[channel_id].reply [:queue_unbind_ok]
@@ -203,22 +227,22 @@ module AMQP
203
227
  when 11 # qos-ok
204
228
  @channels[channel_id].reply [:basic_qos_ok]
205
229
  when 21 # consume-ok
206
- tag_len = buf.unpack1("@11 C")
207
- tag = buf.byteslice(12, tag_len).force_encoding("utf-8")
230
+ tag_len = buf.unpack1("@4 C")
231
+ tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
208
232
  @channels[channel_id].reply [:basic_consume_ok, tag]
209
233
  when 30 # cancel
210
- tag_len = buf.unpack1("@11 C")
211
- tag = buf.byteslice(12, tag_len).force_encoding("utf-8")
212
- no_wait = buf[12 + tag_len].ord
213
- @channels[channel_id].consumers.fetch(tag).close
234
+ tag_len = buf.unpack1("@4 C")
235
+ tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
236
+ no_wait = buf[5 + tag_len].ord
237
+ @channels[channel_id].close_consumer(tag)
214
238
  write_bytes FrameBytes.basic_cancel_ok(@id, tag) unless no_wait == 1
215
239
  when 31 # cancel-ok
216
- tag_len = buf.unpack1("@11 C")
217
- tag = buf.byteslice(12, tag_len).force_encoding("utf-8")
240
+ tag_len = buf.unpack1("@4 C")
241
+ tag = buf.byteslice(5, tag_len).force_encoding("utf-8")
218
242
  @channels[channel_id].reply [:basic_cancel_ok, tag]
219
243
  when 50 # return
220
- reply_code, reply_text_len = buf.unpack("@11 S> C")
221
- pos = 14
244
+ reply_code, reply_text_len = buf.unpack("@4 S> C")
245
+ pos = 7
222
246
  reply_text = buf.byteslice(pos, reply_text_len).force_encoding("utf-8")
223
247
  pos += reply_text_len
224
248
  exchange_len = buf[pos].ord
@@ -230,9 +254,9 @@ module AMQP
230
254
  routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
231
255
  @channels[channel_id].message_returned(reply_code, reply_text, exchange, routing_key)
232
256
  when 60 # deliver
233
- ctag_len = buf[11].ord
234
- consumer_tag = buf.byteslice(12, ctag_len).force_encoding("utf-8")
235
- pos = 12 + ctag_len
257
+ ctag_len = buf[4].ord
258
+ consumer_tag = buf.byteslice(5, ctag_len).force_encoding("utf-8")
259
+ pos = 5 + ctag_len
236
260
  delivery_tag, redelivered, exchange_len = buf.byteslice(pos, 10).unpack("Q> C C")
237
261
  pos += 8 + 1 + 1
238
262
  exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
@@ -240,35 +264,27 @@ module AMQP
240
264
  rk_len = buf[pos].ord
241
265
  pos += 1
242
266
  routing_key = buf.byteslice(pos, rk_len).force_encoding("utf-8")
243
- loop do
244
- if (consumer = @channels[channel_id].consumers[consumer_tag])
245
- consumer.push [:deliver, delivery_tag, redelivered == 1, exchange, routing_key]
246
- break
247
- else
248
- Thread.pass
249
- end
250
- end
267
+ @channels[channel_id].message_delivered(consumer_tag, delivery_tag, redelivered == 1, exchange, routing_key)
251
268
  when 71 # get-ok
252
- delivery_tag, redelivered, exchange_len = buf.unpack("@11 Q> C C")
253
- pos = 21
269
+ delivery_tag, redelivered, exchange_len = buf.unpack("@4 Q> C C")
270
+ pos = 14
254
271
  exchange = buf.byteslice(pos, exchange_len).force_encoding("utf-8")
255
272
  pos += exchange_len
256
273
  routing_key_len = buf[pos].ord
257
274
  pos += 1
258
275
  routing_key = buf.byteslice(pos, routing_key_len).force_encoding("utf-8")
259
276
  pos += routing_key_len
260
- message_count = buf.byteslice(pos, 4).unpack1("L>")
261
- redelivered = redelivered == 1
262
- @channels[channel_id].reply [:basic_get_ok, delivery_tag, exchange, routing_key, message_count, redelivered]
277
+ _message_count = buf.byteslice(pos, 4).unpack1("L>")
278
+ @channels[channel_id].message_delivered(nil, delivery_tag, redelivered == 1, exchange, routing_key)
263
279
  when 72 # get-empty
264
- @channels[channel_id].reply [:basic_get_empty]
280
+ @channels[channel_id].basic_get_empty
265
281
  when 80 # ack
266
- delivery_tag, multiple = buf.unpack("@11 Q> C")
282
+ delivery_tag, multiple = buf.unpack("@4 Q> C")
267
283
  @channels[channel_id].confirm [:ack, delivery_tag, multiple == 1]
268
284
  when 111 # recover-ok
269
285
  @channels[channel_id].reply [:basic_recover_ok]
270
286
  when 120 # nack
271
- delivery_tag, multiple, requeue = buf.unpack("@11 Q> C C")
287
+ delivery_tag, multiple, requeue = buf.unpack("@4 Q> C C")
272
288
  @channels[channel_id].confirm [:nack, delivery_tag, multiple == 1, requeue == 1]
273
289
  else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
274
290
  end
@@ -291,20 +307,19 @@ module AMQP
291
307
  else raise AMQP::Client::UnsupportedMethodFrame.new class_id, method_id
292
308
  end
293
309
  when 2 # header
294
- body_size = buf.unpack1("@11 Q>")
295
- properties = Properties.decode(buf.byteslice(19, buf.bytesize - 20))
296
- @channels[channel_id].reply [:header, body_size, properties]
310
+ body_size = buf.unpack1("@4 Q>")
311
+ properties = Properties.decode(buf.byteslice(12, buf.bytesize - 12))
312
+ @channels[channel_id].header_delivered body_size, properties
297
313
  when 3 # body
298
- body = buf.byteslice(7, frame_size)
299
- @channels[channel_id].reply [:body, body]
314
+ @channels[channel_id].body_delivered buf
300
315
  else raise AMQP::Client::UnsupportedFrameType, type
301
316
  end
302
317
  true
303
318
  end
304
319
 
305
320
  def expect(expected_frame_type)
306
- frame_type, args = @replies.shift
307
- frame_type == expected_frame_type || raise(UnexpectedFrame.new(expected_frame_type, frame_type))
321
+ frame_type, args = @replies.pop
322
+ frame_type == expected_frame_type || raise(AMQP::Client::UnexpectedFrame.new(expected_frame_type, frame_type))
308
323
  args
309
324
  end
310
325
 
@@ -320,7 +335,7 @@ module AMQP
320
335
  end
321
336
 
322
337
  type, channel_id, frame_size = buf.unpack("C S> L>")
323
- frame_end = buf.unpack1("@#{frame_size + 7} C")
338
+ frame_end = buf[frame_size + 7].ord
324
339
  raise UnexpectedFrameEndError, frame_end if frame_end != 206
325
340
 
326
341
  case type
@@ -367,7 +382,7 @@ module AMQP
367
382
  socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPINTVL, 10)
368
383
  socket.setsockopt(Socket::SOL_TCP, Socket::TCP_KEEPCNT, 3)
369
384
  rescue StandardError => e
370
- warn "amqp-client: Could not enable TCP keepalive on socket. #{e.inspect}"
385
+ warn "AMQP-Client could not enable TCP keepalive on socket. #{e.inspect}"
371
386
  end
372
387
 
373
388
  def self.port_from_env
@@ -7,7 +7,7 @@ module AMQP
7
7
  end
8
8
 
9
9
  def reject(requeue: false)
10
- channel.basic_reject(delivery_tag, requeue)
10
+ channel.basic_reject(delivery_tag, requeue: requeue)
11
11
  end
12
12
  end
13
13
 
@@ -38,7 +38,8 @@ module AMQP
38
38
  end
39
39
 
40
40
  if delivery_mode
41
- headers.is_a?(Integer) || raise(ArgumentError, "delivery_mode must be an int")
41
+ delivery_mode.is_a?(Integer) || raise(ArgumentError, "delivery_mode must be an int")
42
+ delivery_mode.between?(0, 2) || raise(ArgumentError, "delivery_mode must be be between 0 and 2")
42
43
 
43
44
  flags |= (1 << 12)
44
45
  arr << delivery_mode
@@ -69,7 +70,7 @@ module AMQP
69
70
  end
70
71
 
71
72
  if expiration
72
- expiration = expiration.to_s if expiration.is_a?(Integer)
73
+ self.expiration = expiration.to_s if expiration.is_a?(Integer)
73
74
  expiration.is_a?(String) || raise(ArgumentError, "expiration must be a string or integer")
74
75
 
75
76
  flags |= (1 << 8)
@@ -86,7 +87,7 @@ module AMQP
86
87
  end
87
88
 
88
89
  if timestamp
89
- timestamp.is_a?(Time) || raise(ArgumentError, "timestamp must be a time")
90
+ timestamp.is_a?(Integer) || timestamp.is_a?(Time) || raise(ArgumentError, "timestamp must be an Integer or a Time")
90
91
 
91
92
  flags |= (1 << 6)
92
93
  arr << timestamp.to_i
@@ -2,6 +2,6 @@
2
2
 
3
3
  module AMQP
4
4
  class Client
5
- VERSION = "0.3.0"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
data/lib/amqp/client.rb CHANGED
@@ -12,31 +12,41 @@ module AMQP
12
12
  @options = options
13
13
 
14
14
  @queues = {}
15
+ @exchanges = {}
15
16
  @subscriptions = Set.new
16
17
  @connq = SizedQueue.new(1)
17
18
  end
18
19
 
20
+ # Opens an AMQP connection, does not try to reconnect
19
21
  def connect(read_loop_thread: true)
20
- Connection.connect(@uri, **@options.merge(read_loop_thread: read_loop_thread))
22
+ Connection.connect(@uri, read_loop_thread: read_loop_thread, **@options)
21
23
  end
22
24
 
25
+ # Opens an AMQP connection using the high level API, will try to reconnect
23
26
  def start
24
27
  @stopped = false
25
- Thread.new do
28
+ Thread.new(connect(read_loop_thread: false)) do |conn|
29
+ Thread.abort_on_exception = true # Raising an unhandled exception is a bug
26
30
  loop do
27
31
  break if @stopped
28
32
 
29
- conn = connect(read_loop_thread: false)
33
+ conn ||= connect(read_loop_thread: false)
30
34
  Thread.new do
31
35
  # restore connection in another thread, read_loop have to run
32
36
  conn.channel(1) # reserve channel 1 for publishes
33
- @subscriptions.each { |args| subscribe(*args) }
37
+ @subscriptions.each do |queue_name, no_ack, prefetch, wt, args, blk|
38
+ ch = conn.channel
39
+ ch.basic_qos(prefetch)
40
+ ch.basic_consume(queue_name, no_ack: no_ack, worker_threads: wt, arguments: args, &blk)
41
+ end
34
42
  @connq << conn
35
43
  end
36
44
  conn.read_loop # blocks until connection is closed, then reconnect
37
- rescue => e
45
+ rescue AMQP::Client::Error => e
38
46
  warn "AMQP-Client reconnect error: #{e.inspect}"
39
47
  sleep @options[:reconnect_interval] || 1
48
+ ensure
49
+ conn = nil
40
50
  end
41
51
  end
42
52
  self
@@ -49,21 +59,58 @@ module AMQP
49
59
  nil
50
60
  end
51
61
 
52
- def queue(name, arguments: {})
62
+ def queue(name, durable: true, exclusive: false, auto_delete: false, arguments: {})
53
63
  raise ArgumentError, "Currently only supports named, durable queues" if name.empty?
54
64
 
55
65
  @queues.fetch(name) do
56
66
  with_connection do |conn|
57
67
  conn.with_channel do |ch| # use a temp channel in case the declaration fails
58
- ch.queue_declare(name, arguments: arguments)
68
+ ch.queue_declare(name, durable: durable, exclusive: exclusive, auto_delete: auto_delete, arguments: arguments)
59
69
  end
60
70
  end
61
71
  @queues[name] = Queue.new(self, name)
62
72
  end
63
73
  end
64
74
 
75
+ def exchange(name, type, durable: true, auto_delete: false, internal: false, arguments: {})
76
+ @exchanges.fetch(name) do
77
+ with_connection do |conn|
78
+ conn.with_channel do |ch|
79
+ ch.exchange_declare(name, type, durable: durable, auto_delete: auto_delete, internal: internal, arguments: arguments)
80
+ end
81
+ end
82
+ @exchanges[name] = Exchange.new(self, name)
83
+ end
84
+ end
85
+
86
+ # High level representation of an exchange
87
+ class Exchange
88
+ def initialize(client, name)
89
+ @client = client
90
+ @name = name
91
+ end
92
+
93
+ def publish(body, routing_key, arguments: {})
94
+ @client.publish(body, @name, routing_key, arguments: arguments)
95
+ end
96
+
97
+ # Bind to another exchange
98
+ def bind(exchange, routing_key, arguments: {})
99
+ @client.exchange_bind(@name, exchange, routing_key, arguments: arguments)
100
+ end
101
+
102
+ # Unbind from another exchange
103
+ def unbind(exchange, routing_key, arguments: {})
104
+ @client.exchange_unbind(@name, exchange, routing_key, arguments: arguments)
105
+ end
106
+
107
+ def delete
108
+ @client.delete_exchange(@name)
109
+ end
110
+ end
111
+
65
112
  def subscribe(queue_name, no_ack: false, prefetch: 1, worker_threads: 1, arguments: {}, &blk)
66
- @subscriptions.add? [queue_name, no_ack, prefetch, arguments, blk]
113
+ @subscriptions.add? [queue_name, no_ack, prefetch, worker_threads, arguments, blk]
67
114
 
68
115
  with_connection do |conn|
69
116
  ch = conn.channel
@@ -74,28 +121,49 @@ module AMQP
74
121
  end
75
122
  end
76
123
 
124
+ # Publish a (persistent) message and wait for confirmation
77
125
  def publish(body, exchange, routing_key, **properties)
78
126
  with_connection do |conn|
79
- # Use channel 1 for publishes
127
+ properties = { delivery_mode: 2 }.merge!(properties)
80
128
  conn.channel(1).basic_publish_confirm(body, exchange, routing_key, **properties)
81
- rescue
82
- conn.channel(1) # reopen channel 1 if it raised
83
- raise
84
129
  end
85
- rescue => e
86
- warn "AMQP-Client error publishing, retrying (#{e.inspect})"
87
- retry
88
130
  end
89
131
 
90
- def bind(queue, exchange, routing_key, **headers)
132
+ # Publish a (persistent) message but don't wait for a confirmation
133
+ def publish_and_forget(body, exchange, routing_key, **properties)
91
134
  with_connection do |conn|
92
- conn.channel(1).queue_bind(queue, exchange, routing_key, **headers)
135
+ properties = { delivery_mode: 2 }.merge!(properties)
136
+ conn.channel(1).basic_publish(body, exchange, routing_key, **properties)
93
137
  end
94
138
  end
95
139
 
96
- def unbind(queue, exchange, routing_key, **headers)
140
+ def wait_for_confirms
97
141
  with_connection do |conn|
98
- conn.channel(1).queue_unbind(queue, exchange, routing_key, **headers)
142
+ conn.channel(1).wait_for_confirms
143
+ end
144
+ end
145
+
146
+ def bind(queue, exchange, routing_key, arguments: {})
147
+ with_connection do |conn|
148
+ conn.channel(1).queue_bind(queue, exchange, routing_key, arguments: arguments)
149
+ end
150
+ end
151
+
152
+ def unbind(queue, exchange, routing_key, arguments: {})
153
+ with_connection do |conn|
154
+ conn.channel(1).queue_unbind(queue, exchange, routing_key, arguments: arguments)
155
+ end
156
+ end
157
+
158
+ def exchange_bind(destination, source, routing_key, arguments: {})
159
+ with_connection do |conn|
160
+ conn.channel(1).exchange_bind(destination, source, routing_key, arguments: arguments)
161
+ end
162
+ end
163
+
164
+ def exchange_unbind(destination, source, routing_key, arguments: {})
165
+ with_connection do |conn|
166
+ conn.channel(1).exchange_unbind(destination, source, routing_key, arguments: arguments)
99
167
  end
100
168
  end
101
169
 
@@ -105,9 +173,17 @@ module AMQP
105
173
  end
106
174
  end
107
175
 
108
- def delete_queue(queue)
176
+ def delete_queue(name)
177
+ with_connection do |conn|
178
+ conn.channel(1).queue_delete(name)
179
+ @queues.delete(name)
180
+ end
181
+ end
182
+
183
+ def delete_exchange(name)
109
184
  with_connection do |conn|
110
- conn.channel(1).queue_delete(queue)
185
+ conn.channel(1).exchange_delete(name)
186
+ @exchanges.delete(name)
111
187
  end
112
188
  end
113
189
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: amqp-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Hörberg
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-08-20 00:00:00.000000000 Z
11
+ date: 2021-08-27 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Work in progress
14
14
  email:
@@ -61,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
61
  - !ruby/object:Gem::Version
62
62
  version: '0'
63
63
  requirements: []
64
- rubygems_version: 3.2.15
64
+ rubygems_version: 3.2.22
65
65
  signing_key:
66
66
  specification_version: 4
67
67
  summary: AMQP 0-9-1 client