hutch 1.4.0 → 2.0.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.
@@ -1,4 +1,4 @@
1
- require 'multi_json'
1
+ require 'json'
2
2
  require 'active_support/core_ext/hash/indifferent_access'
3
3
 
4
4
  module Hutch
@@ -6,11 +6,11 @@ module Hutch
6
6
  class JSON
7
7
 
8
8
  def self.encode(payload)
9
- ::MultiJson.dump(payload)
9
+ ::JSON.generate(payload)
10
10
  end
11
11
 
12
12
  def self.decode(payload)
13
- ::MultiJson.load(payload).with_indifferent_access
13
+ ::JSON.parse(payload).with_indifferent_access
14
14
  end
15
15
 
16
16
  def self.binary? ; false ; end
@@ -1,12 +1,5 @@
1
- begin
2
- require 'datadog'
3
- require 'datadog/auto_instrument'
4
- rescue LoadError
5
- require 'ddtrace'
6
- require 'ddtrace/auto_instrument'
7
- warn "[DEPRECATION] The ddtrace gem is deprecated and Hutch will require the datadog gem in 2.0. " \
8
- "Please switch to the datadog gem."
9
- end
1
+ require 'datadog'
2
+ require 'datadog/auto_instrument'
10
3
 
11
4
  module Hutch
12
5
  module Tracers
data/lib/hutch/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module Hutch
2
- VERSION = '1.4.0'.freeze
2
+ VERSION = '2.0.0'.freeze
3
3
  end
data/lib/hutch/worker.rb CHANGED
@@ -11,8 +11,9 @@ module Hutch
11
11
  include Logging
12
12
 
13
13
  def initialize(broker, consumers, setup_procs)
14
- @broker = broker
15
- self.consumers = consumers
14
+ @broker = broker
15
+ @recovery_lock = Mutex.new
16
+ self.consumers = consumers
16
17
  self.setup_procs = setup_procs
17
18
  end
18
19
 
@@ -52,12 +53,46 @@ module Hutch
52
53
  queue = @broker.queue(queue_name, consumer.get_options)
53
54
  @broker.bind_queue(queue, consumer.routing_keys)
54
55
 
55
- queue.subscribe(consumer_tag: unique_consumer_tag, manual_ack: true) do |*args|
56
+ on_cancellation = proc { handle_cancellation(queue_name, queue.channel) }
57
+ queue.subscribe(consumer_tag: unique_consumer_tag, manual_ack: true,
58
+ on_cancellation: on_cancellation) do |*args|
56
59
  delivery_info, properties, payload = Hutch::Adapter.decode_message(*args)
57
60
  handle_message(consumer, delivery_info, properties, payload)
58
61
  end
59
62
  end
60
63
 
64
+ # A server-sent `basic.cancel` carries no reason, so a consumer timeout
65
+ # is indistinguishable from a queue deletion. Only the former is recoverable.
66
+ def handle_cancellation(queue_name, cancelled_channel)
67
+ unless @broker.queue_exists?(queue_name)
68
+ logger.error "consumer on queue #{queue_name} was cancelled by the server: the queue no longer exists"
69
+ return
70
+ end
71
+
72
+ logger.warn "consumer on queue #{queue_name} was cancelled by the server, re-subscribing"
73
+ resubscribe_on_a_new_channel(cancelled_channel)
74
+ rescue => ex
75
+ logger.error "consumer re-subscription failed: #{ex.class}: #{ex.message}"
76
+ end
77
+
78
+ # Runs on its own thread: closing the channel kills the consumer work
79
+ # pool this callback runs on. Returns the thread so that tests can join it.
80
+ def resubscribe_on_a_new_channel(cancelled_channel)
81
+ Thread.new do
82
+ begin
83
+ @recovery_lock.synchronize do
84
+ # All consumers share the channel, so only the first cancellation replaces it.
85
+ next unless @broker.channel.equal?(cancelled_channel)
86
+
87
+ @broker.replace_channel!
88
+ setup_queues
89
+ end
90
+ rescue => ex
91
+ logger.error "consumer re-subscription failed: #{ex.class}: #{ex.message}"
92
+ end
93
+ end
94
+ end
95
+
61
96
  # Called internally when a new messages comes in from RabbitMQ. Responsible
62
97
  # for wrapping up the message and passing it to the consumer.
63
98
  def handle_message(consumer, delivery_info, properties, payload)
@@ -73,7 +108,7 @@ module Hutch
73
108
  message = Message.new(delivery_info, properties, payload, serializer)
74
109
  consumer_instance = consumer.new.tap { |c| c.broker, c.delivery_info = @broker, delivery_info }
75
110
  with_tracing(consumer_instance).handle(message)
76
- @broker.ack(delivery_info.delivery_tag) unless consumer_instance.message_rejected?
111
+ @broker.ack(delivery_info.delivery_tag, channel: delivery_info.channel) unless consumer_instance.message_rejected?
77
112
  rescue => ex
78
113
  acknowledge_error(delivery_info, properties, @broker, ex)
79
114
  handle_error(properties, payload, consumer, ex, delivery_info)
@@ -1,2 +1,2 @@
1
- YARD::Templates::Engine.register_template_path(File.dirname(__FILE__) + '/../../templates')
2
- require File.join(File.dirname(__FILE__), 'handler') if RUBY19
1
+ YARD::Templates::Engine.register_template_path(File.join(__dir__, '../../templates'))
2
+ require_relative 'handler'
@@ -128,6 +128,76 @@ describe Hutch::Broker do
128
128
  end
129
129
  end
130
130
 
131
+ describe '#parse_uri' do
132
+ {
133
+ "amqp://host/vhost" => "vhost",
134
+ "amqp://host/v%2fhost" => "v/host",
135
+ "amqp://host/%2f" => "/",
136
+ "amqp://host/" => Hutch::Adapter::DEFAULT_VHOST,
137
+ "amqp://host" => Hutch::Adapter::DEFAULT_VHOST,
138
+ }.each do |uri, vhost|
139
+ it "parses the vhost of #{uri} as #{vhost.inspect}" do
140
+ config[:uri] = uri
141
+ broker.send(:parse_uri)
142
+
143
+ expect(config[:mq_vhost]).to eq(vhost)
144
+ end
145
+ end
146
+
147
+ it 'passes the vhost to the connection unchanged' do
148
+ config[:uri] = "amqp://host/%2f"
149
+
150
+ expect(broker.send(:connection_params)[:vhost]).to eq("/")
151
+ end
152
+
153
+ it 'percent-decodes the username and password' do
154
+ config[:uri] = "amqp://al%23pha:be%20ta@host:10000/vhost"
155
+ broker.send(:parse_uri)
156
+
157
+ expect(config[:mq_username]).to eq("al#pha")
158
+ expect(config[:mq_password]).to eq("be ta")
159
+ end
160
+
161
+ it 'leaves absent credentials nil' do
162
+ config[:uri] = "amqp://host:10000/vhost"
163
+ broker.send(:parse_uri)
164
+
165
+ expect(config[:mq_username]).to be_nil
166
+ expect(config[:mq_password]).to be_nil
167
+ end
168
+ end
169
+
170
+ describe '#bindings', 'vhost filtering' do
171
+ let(:api_bindings) do
172
+ [{ 'source' => 'hutch', 'vhost' => '/', 'destination' => 'q', 'routing_key' => 'key' }]
173
+ end
174
+
175
+ before do
176
+ config[:mq_exchange] = 'hutch'
177
+ broker.api_client = double('api_client', bindings: api_bindings)
178
+ end
179
+
180
+ it 'filters on the default vhost when the configured vhost is blank' do
181
+ config[:mq_vhost] = ''
182
+
183
+ expect(broker.bindings).to eq('q' => ['key'])
184
+ end
185
+ end
186
+
187
+ describe '#sanitized_uri' do
188
+ it 'percent-encodes the decoded username and vhost again' do
189
+ config[:uri] = 'amqp://al%23pha:be%20ta@host:10000/v%2Fhost'
190
+
191
+ expect(broker.send(:sanitized_uri)).to eq('amqp://al%23pha@host:10000/v%2Fhost')
192
+ end
193
+
194
+ it 'renders the default vhost as a bare trailing slash' do
195
+ config[:uri] = 'amqp://alpha:beta@host:10000/'
196
+
197
+ expect(broker.send(:sanitized_uri)).to eq('amqp://alpha@host:10000/')
198
+ end
199
+ end
200
+
131
201
  describe '#open_connection!' do
132
202
  it 'sets the #connection to #open_connection' do
133
203
  connection = double('connection').as_null_object
@@ -199,6 +269,100 @@ describe Hutch::Broker do
199
269
  end
200
270
  end
201
271
 
272
+ describe '#replace_channel!', adapter: :bunny do
273
+ let(:old_channel) { double('Bunny::Channel', open?: true, close: nil) }
274
+ let(:new_channel) { double('Bunny::Channel').as_null_object }
275
+ let(:work_pool) { double('Bunny::ConsumerWorkPool', shutdown: nil, join: nil, kill: nil) }
276
+
277
+ before do
278
+ broker.channel = old_channel
279
+ allow(broker).to receive(:channel_work_pool).and_return(work_pool)
280
+ allow(broker).to receive(:open_channel).and_return(new_channel)
281
+ allow(broker).to receive(:declare_exchange!)
282
+ allow(broker).to receive(:declare_publisher!)
283
+ end
284
+
285
+ it 'drains the work pool before closing the old channel' do
286
+ expect(work_pool).to receive(:shutdown).ordered
287
+ expect(work_pool).to receive(:join).with(config[:graceful_exit_timeout]).ordered
288
+ expect(work_pool).to receive(:kill).ordered
289
+ expect(old_channel).to receive(:close).ordered
290
+
291
+ broker.replace_channel!
292
+ end
293
+
294
+ it 'redeclares the exchange and the publisher on the new channel' do
295
+ expect(broker).to receive(:declare_exchange!)
296
+ expect(broker).to receive(:declare_publisher!)
297
+
298
+ broker.replace_channel!
299
+
300
+ expect(broker.channel).to eq(new_channel)
301
+ end
302
+
303
+ context 'when the old channel is already closed' do
304
+ let(:old_channel) { double('Bunny::Channel', open?: false) }
305
+
306
+ it 'opens a new channel anyway' do
307
+ expect(old_channel).not_to receive(:close)
308
+ expect(work_pool).not_to receive(:shutdown)
309
+
310
+ broker.replace_channel!
311
+
312
+ expect(broker.channel).to eq(new_channel)
313
+ end
314
+ end
315
+
316
+ context 'when the server closes the old channel concurrently' do
317
+ it 'opens a new channel anyway' do
318
+ allow(old_channel).to receive(:close).
319
+ and_raise(Hutch::Adapter::ChannelAlreadyClosed.new('already closed', old_channel))
320
+
321
+ broker.replace_channel!
322
+
323
+ expect(broker.channel).to eq(new_channel)
324
+ end
325
+ end
326
+ end
327
+
328
+ describe 'acknowledgements' do
329
+ let(:current_channel) { double('Channel').as_null_object }
330
+ let(:superseded_channel) { double('Channel') }
331
+
332
+ before { broker.channel = current_channel }
333
+
334
+ it 'sends them on the channel the delivery arrived on' do
335
+ expect(current_channel).to receive(:ack).with('dt', false)
336
+ broker.ack('dt', channel: current_channel)
337
+
338
+ expect(current_channel).to receive(:nack).with('dt', false, false)
339
+ broker.nack('dt', channel: current_channel)
340
+
341
+ expect(current_channel).to receive(:reject).with('dt', false)
342
+ broker.reject('dt', channel: current_channel)
343
+
344
+ expect(current_channel).to receive(:reject).with('dt', true)
345
+ broker.requeue('dt', channel: current_channel)
346
+ end
347
+
348
+ it 'drops them when the channel has been replaced' do
349
+ expect(superseded_channel).not_to receive(:ack)
350
+ expect(superseded_channel).not_to receive(:nack)
351
+ expect(superseded_channel).not_to receive(:reject)
352
+
353
+ broker.ack('dt', channel: superseded_channel)
354
+ broker.nack('dt', channel: superseded_channel)
355
+ broker.reject('dt', channel: superseded_channel)
356
+ broker.requeue('dt', channel: superseded_channel)
357
+ end
358
+
359
+ it 'defaults to the current channel' do
360
+ expect(current_channel).to receive(:ack).with('dt', false)
361
+
362
+ broker.ack('dt')
363
+ end
364
+ end
365
+
202
366
  describe '#declare_exchange' do
203
367
  before do
204
368
  broker.open_connection!
@@ -293,7 +457,7 @@ describe Hutch::Broker do
293
457
 
294
458
  context 'with a binding' do
295
459
  around do |example|
296
- queue = broker.queue('test').bind(broker.exchange, routing_key: 'key')
460
+ queue = broker.queue('test', durable: true).bind(broker.exchange, routing_key: 'key')
297
461
  example.run
298
462
  queue.unbind(broker.exchange, routing_key: 'key').delete
299
463
  end
@@ -323,7 +487,7 @@ describe Hutch::Broker do
323
487
  end
324
488
 
325
489
  context '(rabbitmq integration test)', rabbitmq: true do
326
- let(:queue) { broker.queue('consumer') }
490
+ let(:queue) { broker.queue('consumer', durable: true) }
327
491
  let(:routing_key) { 'key' }
328
492
 
329
493
  before { allow(broker).to receive(:bindings).and_call_original }
@@ -31,9 +31,10 @@ describe Hutch::CLI do
31
31
  end
32
32
 
33
33
  context "when the config file exists" do
34
- let(:file) do
35
- Tempfile.new("hutch-test-config.yaml").to_path
36
- end
34
+ # Kept in a let, so the Tempfile is not garbage collected - and with it
35
+ # unlinked - before the CLI checks that the path exists.
36
+ let(:config_file) { Tempfile.new("hutch-test-config.yaml") }
37
+ let(:file) { config_file.to_path }
37
38
 
38
39
  it "parses the config" do
39
40
  expect(Hutch::Config).to receive(:load_from_file)
@@ -55,9 +56,10 @@ describe Hutch::CLI do
55
56
  end
56
57
 
57
58
  context "when the keyfile file exists" do
58
- let(:file) do
59
- Tempfile.new("hutch-test-key.pem").to_path
60
- end
59
+ # Kept in a let, so the Tempfile is not garbage collected - and with it
60
+ # unlinked - before the CLI checks that the path exists.
61
+ let(:key_file) { Tempfile.new("hutch-test-key.pem") }
62
+ let(:file) { key_file.to_path }
61
63
 
62
64
  it "sets mq_tls_key to the file" do
63
65
  expect(Hutch::Config).to receive(:mq_tls_key=)
@@ -79,9 +81,10 @@ describe Hutch::CLI do
79
81
  end
80
82
 
81
83
  context "when the certfile file exists" do
82
- let(:file) do
83
- Tempfile.new("hutch-test-cert.pem").to_path
84
- end
84
+ # Kept in a let, so the Tempfile is not garbage collected - and with it
85
+ # unlinked - before the CLI checks that the path exists.
86
+ let(:cert_file) { Tempfile.new("hutch-test-cert.pem") }
87
+ let(:file) { cert_file.to_path }
85
88
 
86
89
  it "sets mq_tls_cert to the file" do
87
90
  expect(Hutch::Config).to receive(:mq_tls_cert=)
@@ -1,5 +1,8 @@
1
1
  require 'spec_helper'
2
2
 
3
+ # The airbrake gem is MRI-only, see the Gemfile
4
+ return if defined?(JRUBY_VERSION)
5
+
3
6
  describe Hutch::ErrorHandlers::Airbrake do
4
7
  let(:error_handler) { Hutch::ErrorHandlers::Airbrake.new }
5
8
 
@@ -4,7 +4,7 @@ describe Hutch::Message do
4
4
  let(:delivery_info) { double('Delivery Info') }
5
5
  let(:props) { double('Properties', content_type: "application/json") }
6
6
  let(:body) {{ foo: 'bar' }.with_indifferent_access}
7
- let(:json_body) { MultiJson.dump(body) }
7
+ let(:json_body) { JSON.generate(body) }
8
8
  subject(:message) { Hutch::Message.new(delivery_info, props, json_body, Hutch::Config[:serializer]) }
9
9
 
10
10
  describe '#body' do
@@ -60,7 +60,7 @@ describe Hutch::Worker do
60
60
  end
61
61
 
62
62
  it 'sets up a subscription' do
63
- expect(queue).to receive(:subscribe).with(consumer_tag: %r(^hutch\-.{36}$), manual_ack: true)
63
+ expect(queue).to receive(:subscribe).with(consumer_tag: %r(^hutch\-.{36}$), manual_ack: true, on_cancellation: kind_of(Proc))
64
64
  worker.setup_queue(consumer)
65
65
  end
66
66
 
@@ -68,7 +68,7 @@ describe Hutch::Worker do
68
68
  before { Hutch::Config.set(:consumer_tag_prefix, 'appname') }
69
69
 
70
70
  it 'sets up a subscription with the configured tag prefix' do
71
- expect(queue).to receive(:subscribe).with(consumer_tag: %r(^appname\-.{36}$), manual_ack: true)
71
+ expect(queue).to receive(:subscribe).with(consumer_tag: %r(^appname\-.{36}$), manual_ack: true, on_cancellation: kind_of(Proc))
72
72
  worker.setup_queue(consumer)
73
73
  end
74
74
  end
@@ -84,11 +84,65 @@ describe Hutch::Worker do
84
84
  end
85
85
  end
86
86
 
87
+ describe '#handle_cancellation' do
88
+ let(:cancelled_channel) { double('Channel') }
89
+ let(:new_channel) { double('Channel') }
90
+ let(:log) { StringIO.new }
91
+
92
+ before do
93
+ allow(Hutch::Logging).to receive(:logger).and_return(Logger.new(log))
94
+ allow(broker).to receive(:channel).and_return(cancelled_channel)
95
+ allow(broker).to receive(:replace_channel!) do
96
+ allow(broker).to receive(:channel).and_return(new_channel)
97
+ end
98
+ allow(worker).to receive(:setup_queues)
99
+ end
100
+
101
+ context 'when the queue still exists' do
102
+ before { allow(broker).to receive(:queue_exists?).with('consumer').and_return(true) }
103
+
104
+ it 'replaces the channel and re-subscribes' do
105
+ expect(broker).to receive(:replace_channel!)
106
+ expect(worker).to receive(:setup_queues)
107
+
108
+ worker.handle_cancellation('consumer', cancelled_channel).join
109
+ end
110
+
111
+ it 'replaces the channel once for all the consumers that shared it' do
112
+ expect(broker).to receive(:replace_channel!).once
113
+
114
+ [
115
+ worker.handle_cancellation('consumer', cancelled_channel),
116
+ worker.handle_cancellation('consumer', cancelled_channel)
117
+ ].each(&:join)
118
+ end
119
+
120
+ it 'does not replace a channel that has already been replaced' do
121
+ expect(broker).not_to receive(:replace_channel!)
122
+
123
+ worker.handle_cancellation('consumer', double('An older channel')).join
124
+ end
125
+ end
126
+
127
+ context 'when the queue is gone' do
128
+ before { allow(broker).to receive(:queue_exists?).with('consumer').and_return(false) }
129
+
130
+ it 'does not re-subscribe' do
131
+ expect(broker).not_to receive(:replace_channel!)
132
+
133
+ worker.handle_cancellation('consumer', cancelled_channel)
134
+
135
+ log.rewind
136
+ expect(log.read).to match(/the queue no longer exists/)
137
+ end
138
+ end
139
+ end
140
+
87
141
  describe '#handle_message' do
88
142
  subject { worker.handle_message(consumer, delivery_info, properties, payload) }
89
143
  let(:payload) { '{}' }
90
144
  let(:consumer_instance) { double('Consumer instance') }
91
- let(:delivery_info) { double('Delivery Info', routing_key: '',
145
+ let(:delivery_info) { double('Delivery Info', routing_key: '', channel: nil,
92
146
  delivery_tag: 'dt') }
93
147
  let(:properties) { double('Properties', message_id: nil, content_type: "application/json") }
94
148
  let(:log) { StringIO.new }
@@ -108,7 +162,7 @@ describe Hutch::Worker do
108
162
 
109
163
  it 'acknowledges the message' do
110
164
  allow(consumer_instance).to receive(:process)
111
- expect(broker).to receive(:ack).with(delivery_info.delivery_tag)
165
+ expect(broker).to receive(:ack).with(delivery_info.delivery_tag, channel: delivery_info.channel)
112
166
  expect(consumer_instance).to receive(:message_rejected?).and_return(false)
113
167
  subject
114
168
  end
@@ -141,7 +195,7 @@ describe Hutch::Worker do
141
195
  end
142
196
 
143
197
  it 'rejects the message' do
144
- expect(broker).to receive(:nack).with(delivery_info.delivery_tag)
198
+ expect(broker).to receive(:nack).with(delivery_info.delivery_tag, channel: delivery_info.channel)
145
199
  subject
146
200
  end
147
201
 
@@ -191,14 +245,14 @@ describe Hutch::Worker do
191
245
 
192
246
  context "when the payload is not valid json" do
193
247
  let(:payload) { "Not Valid JSON" }
194
- let(:expected_log) { /ERROR .+ error in consumer .+ MultiJson::ParseError .+ backtrace:/m }
248
+ let(:expected_log) { /ERROR .+ error in consumer .+ JSON::ParserError .+ backtrace:/m }
195
249
 
196
250
  it 'logs the error' do
197
251
  expect { subject }.to change { log.tap(&:rewind).read }.from("").to(expected_log)
198
252
  end
199
253
 
200
254
  it 'rejects the message' do
201
- expect(broker).to receive(:nack).with(delivery_info.delivery_tag)
255
+ expect(broker).to receive(:nack).with(delivery_info.delivery_tag, channel: delivery_info.channel)
202
256
  subject
203
257
  end
204
258
  end
@@ -206,7 +260,7 @@ describe Hutch::Worker do
206
260
 
207
261
 
208
262
  describe '#acknowledge_error' do
209
- let(:delivery_info) { double('Delivery Info', routing_key: '',
263
+ let(:delivery_info) { double('Delivery Info', routing_key: '', channel: nil,
210
264
  delivery_tag: 'dt') }
211
265
  let(:properties) { double('Properties', message_id: 'abc123') }
212
266
 
@@ -1,13 +1,22 @@
1
1
  require 'spec_helper'
2
+
3
+ # Channel recovery is bunny-only, see MarchHareAdapter#install_channel_recovery
4
+ return if defined?(JRUBY_VERSION)
2
5
  require 'hutch/broker'
3
6
  require 'hutch/worker'
4
7
  require 'hutch/consumer'
5
8
  require 'bunny'
6
9
  require 'json'
7
10
  require 'securerandom'
8
- require 'timeout'
9
11
 
10
12
  describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: true, adapter: :bunny do
13
+ CONSUMER_TIMEOUT_MS = 5_000
14
+ # A 4.3 quorum queue times the consumer out on its own timer, which fires at
15
+ # the deadline. Earlier series detect it from the channel tick instead, and
16
+ # `channel_tick_interval` defaults to 60s, so detection lags by up to that much.
17
+ BLOCKED_HANDLER_SECONDS = 20
18
+ BLOCKED_HANDLER_SECONDS_BEFORE_4_3 = 100
19
+
11
20
  let(:log) { StringIO.new }
12
21
  let(:logger) { Logger.new(log) }
13
22
  let(:exchange_name) { "hutch.integration.exchange.#{SecureRandom.hex(4)}" }
@@ -18,12 +27,25 @@ describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: tr
18
27
  let(:processed_lock) { Mutex.new }
19
28
  let(:timed_out_once) { [false] }
20
29
 
30
+ let(:server_version) do
31
+ Gem::Version.new(publisher.server_properties['version'].to_s[/\A\d+(\.\d+)*/])
32
+ end
33
+
34
+ let(:blocked_handler_seconds) do
35
+ if server_version >= Gem::Version.new('4.3')
36
+ BLOCKED_HANDLER_SECONDS
37
+ else
38
+ BLOCKED_HANDLER_SECONDS_BEFORE_4_3
39
+ end
40
+ end
41
+
21
42
  let(:consumer_class) do
22
43
  msgs = processed
23
44
  lock = processed_lock
24
45
  rk = routing_key
25
46
  qn = queue_name
26
47
  timed_out = timed_out_once
48
+ blocked = blocked_handler_seconds
27
49
 
28
50
  Class.new do
29
51
  include Hutch::Consumer
@@ -32,13 +54,15 @@ describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: tr
32
54
  queue_name qn
33
55
  arguments(
34
56
  'x-queue-type' => 'quorum',
35
- 'x-consumer-timeout' => 60_000
57
+ 'x-consumer-timeout' => CONSUMER_TIMEOUT_MS
36
58
  )
37
59
 
60
+ # Both `CONSUMER_TIMEOUT_MS` and this sleep run from the same delivery,
61
+ # so the margin between them does not shrink on a loaded machine.
38
62
  define_method(:process) do |message|
39
63
  if message['id'] == 'trigger-timeout' && !timed_out[0]
40
64
  timed_out[0] = true
41
- sleep 210
65
+ sleep blocked
42
66
  end
43
67
 
44
68
  lock.synchronize { msgs << message['id'] }
@@ -67,25 +91,25 @@ describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: tr
67
91
  Hutch::Config.set(:mq_exchange, exchange_name)
68
92
  Hutch::Config.set(:force_publisher_confirms, false)
69
93
  Hutch::Config.set(:client_logger, logger)
94
+ # Channel replacement waits this long for the deliberately blocked
95
+ # handler: the default of 11s would use up most of the consumption window.
96
+ @graceful_exit_timeout = Hutch::Config[:graceful_exit_timeout]
97
+ Hutch::Config.set(:graceful_exit_timeout, 1)
70
98
  end
71
99
 
72
100
  after do
73
101
  publisher_channel.close rescue nil
74
102
  publisher.close rescue nil
75
103
  broker.disconnect rescue nil
104
+ Hutch::Config.set(:graceful_exit_timeout, @graceful_exit_timeout)
76
105
  Hutch::Logging.logger = Logger.new(File::NULL)
77
106
  end
78
107
 
79
- def wait_for(timeout, label)
80
- Timeout.timeout(timeout) do
81
- loop do
82
- return true if yield
83
- sleep 0.25
84
- end
85
- end
86
- rescue Timeout::Error
108
+ def wait_for(timeout, label, &condition)
109
+ await_condition(timeout, label, &condition)
110
+ rescue AwaitHelpers::ConditionTimeout => e
87
111
  raise <<~MSG
88
- Timed out waiting for: #{label}
112
+ #{e.message}
89
113
 
90
114
  processed_messages=#{processed_messages.inspect}
91
115
  channel_open=#{broker.channel.open? rescue 'unknown'}
@@ -100,9 +124,10 @@ describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: tr
100
124
  processed_lock.synchronize { processed.dup }
101
125
  end
102
126
 
127
+ # `StringIO#string` does not touch the stream position: a rewind here would
128
+ # race with the worker threads that log concurrently and corrupt the buffer.
103
129
  def log_output
104
- log.rewind
105
- log.read
130
+ log.string
106
131
  end
107
132
 
108
133
  def publish_message(id)
@@ -114,26 +139,49 @@ describe 'channel recovery after delivery acknowledgement timeout', rabbitmq: tr
114
139
  )
115
140
  end
116
141
 
142
+ # RabbitMQ up to 4.2 closes the channel on a delivery acknowledgement
143
+ # timeout; 4.3+ quorum queues instead cancel only the timed out consumer.
144
+ let(:cancels_the_consumer) { server_version >= Gem::Version.new('4.3') }
145
+
146
+ let(:recovery_log_pattern) do
147
+ if cancels_the_consumer
148
+ /cancelled by the server, re-subscribing/i
149
+ else
150
+ /delivery acknowledgement on channel \d+ timed out/i
151
+ end
152
+ end
153
+
154
+ # Replacing the channel requeues what the cancelled consumer still held, so
155
+ # consumption resumes at once. Reopening one does not, so it resumes at the
156
+ # next consumer timeout tick.
157
+ let(:recovery_seconds) { cancels_the_consumer ? 15 : 90 }
158
+
117
159
  # This spec is intentionally slow because RabbitMQ enforces delivery
118
- # acknowledgement timeouts on a periodic sweep, not immediately at the deadline.
119
- it 're-subscribes and consumes later messages after RabbitMQ closes the channel for ack timeout' do
160
+ # acknowledgement timeouts periodically on a timer, not immediately at the deadline.
161
+ it 're-subscribes and consumes later messages after RabbitMQ times out an unacknowledged delivery' do
120
162
  broker.connect
121
163
  worker.setup_queues
122
164
 
123
165
  publish_message('trigger-timeout')
124
166
 
125
- wait_for(240, 'delivery acknowledgement timeout') do
126
- log_output.match?(/delivery acknowledgement on channel \d+ timed out/i)
167
+ wait_for(240, 'a delivery acknowledgement timeout to close the channel or cancel the consumer') do
168
+ log_output.match?(recovery_log_pattern)
127
169
  end
128
170
 
129
171
  publish_message('after-recovery')
130
172
 
131
- wait_for(90, 'after-recovery message consumption') do
173
+ wait_for(recovery_seconds, 'after-recovery message consumption') do
132
174
  processed_messages.include?('after-recovery')
133
175
  end
134
176
 
135
- expect(log_output).to match(/delivery acknowledgement on channel \d+ timed out/i)
136
- expect(log_output).to match(/recovered consumer channel after a delivery acknowledgement timeout/i)
137
- expect(processed_messages).to include('after-recovery')
177
+ expect(log_output).to match(recovery_log_pattern)
178
+
179
+ # A consumed message proves the recovery finished, the publisher rebuild
180
+ # included.
181
+ broker.publish(routing_key, 'id' => 'published-after-recovery')
182
+
183
+ wait_for(15, 'published-after-recovery message consumption') do
184
+ processed_messages.include?('published-after-recovery')
185
+ end
138
186
  end
139
- end
187
+ end