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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +61 -0
- data/LICENSE +1 -1
- data/README.md +42 -42
- data/lib/hutch/acknowledgements/nack_on_all_failures.rb +1 -1
- data/lib/hutch/adapters/bunny.rb +24 -0
- data/lib/hutch/adapters/march_hare.rb +21 -1
- data/lib/hutch/broker.rb +67 -34
- data/lib/hutch/cli.rb +1 -4
- data/lib/hutch/config.rb +3 -3
- data/lib/hutch/consumer.rb +3 -3
- data/lib/hutch/error_handlers.rb +0 -1
- data/lib/hutch/serializers/json.rb +3 -3
- data/lib/hutch/tracers/datadog.rb +2 -9
- data/lib/hutch/version.rb +1 -1
- data/lib/hutch/worker.rb +39 -4
- data/lib/yard-settings/yard-settings.rb +2 -2
- data/spec/hutch/broker_spec.rb +166 -2
- data/spec/hutch/cli_spec.rb +12 -9
- data/spec/hutch/error_handlers/airbrake_spec.rb +3 -0
- data/spec/hutch/message_spec.rb +1 -1
- data/spec/hutch/worker_spec.rb +62 -8
- data/spec/integration/channel_recovery_spec.rb +71 -23
- data/spec/integration/publish_consume_spec.rb +1 -2
- metadata +6 -36
- data/.github/workflows/test.yml +0 -74
- data/.gitignore +0 -10
- data/.rspec +0 -1
- data/.yardopts +0 -5
- data/AGENTS.md +0 -127
- data/Gemfile +0 -35
- data/Guardfile +0 -14
- data/Rakefile +0 -21
- data/examples/consumer.rb +0 -13
- data/examples/producer.rb +0 -10
- data/hutch.gemspec +0 -27
- data/lib/hutch/error_handlers/sentry_raven.rb +0 -34
- data/spec/hutch/error_handlers/sentry_raven_spec.rb +0 -37
- data/spec/spec_helper.rb +0 -42
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
require '
|
|
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
|
-
::
|
|
9
|
+
::JSON.generate(payload)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def self.decode(payload)
|
|
13
|
-
::
|
|
13
|
+
::JSON.parse(payload).with_indifferent_access
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def self.binary? ; false ; end
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
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
|
|
15
|
-
|
|
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
|
-
|
|
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.
|
|
2
|
-
|
|
1
|
+
YARD::Templates::Engine.register_template_path(File.join(__dir__, '../../templates'))
|
|
2
|
+
require_relative 'handler'
|
data/spec/hutch/broker_spec.rb
CHANGED
|
@@ -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 }
|
data/spec/hutch/cli_spec.rb
CHANGED
|
@@ -31,9 +31,10 @@ describe Hutch::CLI do
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
context "when the config file exists" do
|
|
34
|
-
let
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
83
|
-
|
|
84
|
-
|
|
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=)
|
data/spec/hutch/message_spec.rb
CHANGED
|
@@ -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) {
|
|
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
|
data/spec/hutch/worker_spec.rb
CHANGED
|
@@ -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 .+
|
|
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' =>
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
119
|
-
it 're-subscribes and consumes later messages after RabbitMQ
|
|
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?(
|
|
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(
|
|
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(
|
|
136
|
-
|
|
137
|
-
|
|
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
|