mercury_amqp 0.1.9 → 0.2.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
  SHA1:
3
- metadata.gz: b38b3dede66b9e8be986f8b8f3fc480e62d326b6
4
- data.tar.gz: 4b9b9dc78020adb4eefa32e0ec25b1e846088c41
3
+ metadata.gz: 2fd7b0dd970c7160af30546770764c5fc2725bc6
4
+ data.tar.gz: a702ad714aba8bd83308c19905841f0cec380430
5
5
  SHA512:
6
- metadata.gz: de30e69c5f7d06a9579a28d38f5604038b0227ea0b380541c582107400139862bce47ab59c9be1cc6edd19a2d88533a29a3cb0ff8b26c607f67fe2337fbdc656
7
- data.tar.gz: 79ecf41880b13bee8f514aeb9d3aaff18d4b17ad009fdbe336c20a0384ba002db06c99398b9861258f5aef34fe79fa8658d333310f6820f844e8cea6fe01a11a
6
+ metadata.gz: ef2d9c3392044008379014cfe5a12d22849c5f36a0852a3f36dccc6a6677f8b1d65f3c51143dacf9607077164abfb10874f369dc0f62246edc5a2b04fb5c757c
7
+ data.tar.gz: 687e95ee42e0e32aaf9290669ed11ba2e68f3b1b1acf43195990158a2724e285f708028e1ab43d20cfd5d5953c5bd1fbae4a98c75c31a33bc34f0c2123d64c98
data/Gemfile CHANGED
@@ -2,4 +2,3 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in hyperion.gemspec
4
4
  gemspec
5
-
@@ -26,16 +26,22 @@ class Mercury
26
26
  password: 'guest',
27
27
  parallelism: 1,
28
28
  on_error: nil,
29
+ wait_for_publisher_confirms: true,
29
30
  &k)
30
31
  @on_error = on_error
31
32
  AMQP.connect(host: host, port: port, vhost: vhost, username: username, password: password,
32
33
  on_tcp_connection_failure: server_down_error_handler) do |amqp|
33
34
  @amqp = amqp
34
35
  @channel = AMQP::Channel.new(amqp, prefetch: parallelism) do
35
- @channel.confirm_select
36
36
  install_channel_error_handler
37
37
  install_lost_connection_error_handler
38
- k.call(self)
38
+ if wait_for_publisher_confirms
39
+ enable_publisher_confirms do
40
+ k.call(self)
41
+ end
42
+ else
43
+ k.call(self)
44
+ end
39
45
  end
40
46
  end
41
47
  end
@@ -44,10 +50,14 @@ class Mercury
44
50
  def publish(source_name, msg, tag: '', headers: {}, &k)
45
51
  # The amqp gem caches exchange objects, so it's fine to
46
52
  # redeclare the exchange every time we publish.
47
- # TODO: wait for publish confirmations (@channel.on_ack)
48
53
  with_source(source_name) do |exchange|
49
- exchange.publish(write(msg), **Mercury.publish_opts(tag, headers)) do
50
- k.call
54
+ payload = write(msg)
55
+ pub_opts = Mercury.publish_opts(tag, headers)
56
+ if publisher_confirms_enabled
57
+ expect_publisher_confirm(k)
58
+ exchange.publish(payload, **pub_opts)
59
+ else
60
+ exchange.publish(payload, **pub_opts, &k)
51
61
  end
52
62
  end
53
63
  end
@@ -112,6 +122,58 @@ class Mercury
112
122
 
113
123
  private
114
124
 
125
+ # In AMQP, queue consumers ack requests after handling them. Unacked messages
126
+ # are automatically returned to the queue, guaranteeing they are eventually handled.
127
+ # Services often ack one request while publishing related messages. Ideally, these
128
+ # operations would be transactional. If the ack succeeds but the publish does not,
129
+ # the line of processing is abandoned, resulting in processing getting "stuck".
130
+ # The best we can do in AMQP is to use "publisher confirms" to confirm that the publish
131
+ # succeeded before acking the originating request. Since the ack can still fail in this
132
+ # scenario, the system should employ idempotent design, which makes request redelivery
133
+ # harmless.
134
+ #
135
+ # see https://www.rabbitmq.com/confirms.html
136
+ # see http://rubyamqp.info/articles/durability/
137
+ def enable_publisher_confirms(&k)
138
+ @confirm_handlers = {}
139
+ @channel.confirm_select do
140
+ @last_published_delivery_tag = 0
141
+ @channel.on_ack do |basic_ack|
142
+ tag = basic_ack.delivery_tag
143
+ if @confirm_handlers.keys.exclude?(tag)
144
+ raise "Got an unexpected publish confirmation ACK for delivery-tag: #{tag}. Was expecting one of: #{@confirm_handlers.keys.inspect}"
145
+ end
146
+ dispatch_publisher_confirm(basic_ack)
147
+ end
148
+ @channel.on_nack do |basic_nack|
149
+ raise "Delivery failed for message with delivery-tag: #{basic_nack.delivery_tag}"
150
+ end
151
+ k.call
152
+ end
153
+ end
154
+
155
+ def publisher_confirms_enabled
156
+ @confirm_handlers.is_a?(Hash)
157
+ end
158
+
159
+ def expect_publisher_confirm(k)
160
+ expected_delivery_tag = (@last_published_delivery_tag += 1)
161
+ @confirm_handlers[expected_delivery_tag] = k
162
+ expected_delivery_tag
163
+ end
164
+
165
+ def dispatch_publisher_confirm(basic_ack)
166
+ confirmed_tags =
167
+ if basic_ack.multiple
168
+ @confirm_handlers.keys.select { |tag| tag <= basic_ack.delivery_tag }.sort # sort just to be deterministic
169
+ else
170
+ [basic_ack.delivery_tag]
171
+ end
172
+ confirmed_tags.each do |tag|
173
+ @confirm_handlers.delete(tag).call
174
+ end
175
+ end
176
+
115
177
  def make_received_message(payload, metadata, is_ackable)
116
178
  msg = ReceivedMessage.new(read(payload), metadata, is_ackable: is_ackable)
117
179
  Logatron.msg_id = msg.headers['X-Ascent-Log-Id']
@@ -157,11 +219,19 @@ class Mercury
157
219
 
158
220
  def make_error_handler(msg)
159
221
  proc do
160
- Logatron.error(msg)
161
- if @on_error.respond_to?(:call)
162
- @on_error.call(msg)
163
- else
164
- raise msg
222
+ # If an error is already being raised, don't interfere with it.
223
+ # This is actually essential since some versions of EventMachine (notably 1.2.0.1)
224
+ # fail to clean up properly if an error is raised during the `ensure` clean up
225
+ # phase (in EventMachine::run), which zombifies subsequent reactors. (AMQP connection
226
+ # failure handlers are invoked from EventMachine's `ensure`.)
227
+ current_exception = $!
228
+ unless current_exception
229
+ Logatron.error(msg)
230
+ if @on_error.respond_to?(:call)
231
+ @on_error.call(msg)
232
+ else
233
+ raise msg
234
+ end
165
235
  end
166
236
  end
167
237
  end
data/lib/mercury/sync.rb CHANGED
@@ -4,14 +4,17 @@ require 'bunny'
4
4
  class Mercury
5
5
  class Sync
6
6
  class << self
7
- def publish(source_name, msg, tag: '', amqp_opts: {})
7
+ def publish(source_name, msg, tag: '', amqp_opts: {}, wait_for_publisher_confirms: true)
8
8
  conn = Bunny.new(amqp_opts)
9
9
  conn.start
10
10
  ch = conn.create_channel
11
- ch.confirm_select
11
+
12
+ ch.confirm_select if wait_for_publisher_confirms # see http://rubybunny.info/articles/extensions.html and Mercury#enable_publisher_confirms
12
13
  ex = ch.topic(source_name, Mercury.source_opts)
13
14
  ex.publish(WireSerializer.new.write(msg), **Mercury.publish_opts(tag, {}))
14
- ch.wait_for_confirms or raise 'failed to confirm publication'
15
+ if wait_for_publisher_confirms
16
+ ch.wait_for_confirms or raise 'failed to confirm publication'
17
+ end
15
18
  ensure
16
19
  conn.close
17
20
  end
@@ -6,9 +6,9 @@ class Mercury
6
6
  module TestUtils
7
7
  include Cps::Methods
8
8
 
9
- def em
9
+ def em(timeout_seconds: 3)
10
10
  EM.run do
11
- EM.add_timer(in_debug_mode? ? 999999 : 3) { raise 'EM spec timed out' }
11
+ EM.add_timer(in_debug_mode? ? 999999 : timeout_seconds) { raise 'EM spec timed out' }
12
12
  yield
13
13
  end
14
14
  end
@@ -1,3 +1,3 @@
1
1
  class Mercury
2
- VERSION = '0.1.9'
2
+ VERSION = '0.2.0'
3
3
  end
@@ -75,12 +75,85 @@ describe Mercury do
75
75
  end
76
76
  end
77
77
 
78
- def with_mercury(&block)
78
+ # Commented out until this gets fixed: https://github.com/eventmachine/eventmachine/issues/670
79
+ # it 'raises an error when a connection cannot be established' do
80
+ # expect { em { Mercury.open(port: 31999) { done } } }.to raise_error /Failed to establish connection/
81
+ # expect(EM.reactor_running?).to be false
82
+ # end
83
+
84
+ it 'raises an error when the connection breaks' do
85
+ expect { em { Mercury.open { done } } }.to raise_error /Lost connection/
86
+ expect(EM.reactor_running?).to be false # make sure we're not triggering EventMachine cleanup bugs
87
+ end
88
+
89
+ it 'does not obscure exceptions thrown inside the reactor' do
90
+ expect { em { Mercury.open { raise 'oops' } } }.to raise_error 'oops'
91
+ expect(EM.reactor_running?).to be false # make sure we're not triggering EventMachine cleanup bugs
92
+ end
93
+
94
+ describe '#publish' do
95
+ context 'docker assumptions' do
96
+ after(:each) { start_rabbitmq_server }
97
+ # This test is commented out because it make assumptions about, and manipulates, a local docker orchestration.
98
+ # Uncomment and run it in a suitable environment to do semi-automated testing on publisher confirms.
99
+ # it 'waits to invoke its continuation until after the message is confirmed' do
100
+ # got_confirmation = false
101
+ # expect {
102
+ # with_mercury(timeout_seconds: 15) do |m|
103
+ # m.publish(source, 'hello') do # cause `source` to be declared so subsequent publishes simply publish
104
+ # stop_rabbitmq_server
105
+ # m.publish(source, 'hello') do
106
+ # got_confirmation = true
107
+ # m.close { done }
108
+ # end
109
+ # end
110
+ # end
111
+ # }.to raise_error /Lost connection to AMQP server/
112
+ # expect(got_confirmation).to be false
113
+ # end
114
+ end
115
+ it 'allows multiple outstanding confirmations' do
116
+ log = []
117
+ with_mercury do |m|
118
+ m.publish(source, 'hello') do # cause `source` to be declared so subsequent publishes simply publish
119
+ publish_and_confirm(m, 'a', log)
120
+ publish_and_confirm(m, 'b', log)
121
+ em_wait_until(proc { log.size == 4 }) do
122
+ expect(log).to eql ['publish a', 'publish b', 'confirm a', 'confirm b']
123
+ m.close { done }
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ def publish_and_confirm(m, name, log)
130
+ m.publish(source, name) do
131
+ log << "confirm #{name}"
132
+ end
133
+ log << "publish #{name}"
134
+ end
135
+ end
136
+
137
+ def start_rabbitmq_server
138
+ Dir.chdir(File.expand_path('~/git/docker/local_orchestration')) do
139
+ system('docker-compose start rabbitmq')
140
+ end
141
+ puts 'done.'
142
+ end
143
+
144
+ def stop_rabbitmq_server
145
+ Dir.chdir(File.expand_path('~/git/docker/local_orchestration')) do
146
+ system('docker-compose stop rabbitmq')
147
+ end
148
+ puts 'done.'
149
+ end
150
+
151
+ def with_mercury(timeout_seconds: 3, &block)
79
152
  sources = [source]
80
153
  queues = [queue]
81
- em { delete_sources_and_queues_cps(sources, queues).run{done} }
82
- em { Mercury.open(&block) }
83
- em { delete_sources_and_queues_cps(sources, queues).run{done} }
154
+ em(timeout_seconds: timeout_seconds) { delete_sources_and_queues_cps(sources, queues).run{done} }
155
+ em(timeout_seconds: timeout_seconds) { Mercury.open(&block) }
156
+ em(timeout_seconds: timeout_seconds) { delete_sources_and_queues_cps(sources, queues).run{done} }
84
157
  end
85
158
 
86
159
  end
@@ -78,8 +78,8 @@ describe Mercury::Monadic do
78
78
  end
79
79
  seql do
80
80
  and_then { m.start_worker('worker1', source1, handle_msg) }
81
- and_then { m.publish(source1, {'id' => 1, 'sleep_seconds' => 0.01}) }
82
- and_then { m.publish(source1, {'id' => 2, 'sleep_seconds' => 0.01}) }
81
+ and_then { m.publish(source1, {'id' => 1, 'sleep_seconds' => 0.1}) }
82
+ and_then { m.publish(source1, {'id' => 2, 'sleep_seconds' => 0.1}) }
83
83
  and_then { wait_until { events.size == 4 } }
84
84
  and_lift do
85
85
  expect(events).to eql ['received 1', 'received 2', 'finished 1', 'finished 2']
@@ -128,8 +128,8 @@ describe Mercury::Monadic do
128
128
  msgs = []
129
129
  seql do
130
130
  and_then { m.start_listener(source, &msgs.method(:push)) }
131
+ and_lift { EM.next_tick { Logatron.msg_id = 'fake_msg_id' } } # we want this to happen right after publishing but before getting the response
131
132
  and_then { m.publish(source, msg) }
132
- and_lift { Logatron.msg_id = 'fake_msg_id' }
133
133
  and_then { wait_until { msgs.size == 1 } }
134
134
  and_lift do
135
135
  expect(msgs[0].headers['X-Ascent-Log-Id']).to eql real_msg_id
@@ -188,14 +188,21 @@ describe Mercury::Monadic do
188
188
  end
189
189
  end
190
190
 
191
+ def push_and_ack(array)
192
+ proc do |msg|
193
+ array.push(msg)
194
+ msg.ack
195
+ end
196
+ end
197
+
191
198
  itt 'workers can specify tag filters' do
192
199
  test_with_mercury do |m|
193
200
  seql do
194
201
  let(:m2) { Mercury::Monadic.open }
195
202
  work1 = []
196
203
  work2 = []
197
- and_then { m.start_worker(worker, source, tag_filter: 'success', &push_and_ack(work1)) }
198
- and_then { m2.start_worker(worker, source, tag_filter: 'failure', &push_and_ack(work2)) }
204
+ and_then { m.start_worker(worker, source, tag_filter: 'success', &work1.method(:push)) }
205
+ and_then { m2.start_worker(worker, source, tag_filter: 'failure', &work2.method(:push)) }
199
206
  and_then { m.publish(source, msg1, tag: 'success') }
200
207
  and_then { m.publish(source, msg2, tag: 'failure') }
201
208
  and_then { wait_until { work1.size == 1 && work2.size == 1 } }
@@ -208,13 +215,6 @@ describe Mercury::Monadic do
208
215
  end
209
216
  end
210
217
 
211
- def push_and_ack(array)
212
- proc do |msg|
213
- array.push(msg)
214
- msg.ack
215
- end
216
- end
217
-
218
218
  itt 'a worker must ack before receiving another message' do
219
219
  test_with_mercury do |m|
220
220
  msgs = []
@@ -7,17 +7,20 @@ describe Mercury::Sync do
7
7
  let!(:source) { 'test-exchange1' }
8
8
  let!(:queue) { 'test-queue1' }
9
9
  describe '::publish' do
10
- it 'publishes synchronously' do
11
- sent = {'a' => 1}
12
- received = []
13
- test_with_mercury do |m|
14
- seql do
15
- and_then { m.start_listener(source, received.method(:push)) }
16
- and_lift { Mercury::Sync.publish(source, sent) }
17
- and_then { wait_until { received.any? } }
18
- and_lift do
19
- expect(received.size).to eql 1
20
- expect(received[0].content).to eql sent
10
+ %w{with without}.each do |w|
11
+ it "publishes synchronously (#{w} publisher confirms)" do
12
+ use_publisher_confirms = w == 'with'
13
+ sent = {'a' => 1}
14
+ received = []
15
+ test_with_mercury(wait_for_publisher_confirms: use_publisher_confirms) do |m|
16
+ seql do
17
+ and_then { m.start_listener(source, received.method(:push)) }
18
+ and_lift { Mercury::Sync.publish(source, sent) }
19
+ and_then { wait_until { received.any? } }
20
+ and_lift do
21
+ expect(received.size).to eql 1
22
+ expect(received[0].content).to eql sent
23
+ end
21
24
  end
22
25
  end
23
26
  end
@@ -25,9 +28,9 @@ describe Mercury::Sync do
25
28
  end
26
29
 
27
30
  # the block must return a Cps
28
- def test_with_mercury(&block)
31
+ def test_with_mercury(**kws, &block)
29
32
  sources = [source]
30
33
  queues = [queue]
31
- test_with_mercury_cps(sources, queues, &block)
34
+ test_with_mercury_cps(sources, queues, **kws, &block)
32
35
  end
33
36
  end
data/spec/spec_helper.rb CHANGED
@@ -4,10 +4,10 @@ require 'mercury/fake'
4
4
  include Mercury::TestUtils
5
5
 
6
6
  # the block must return a Cps
7
- def test_with_mercury_cps(sources, queues, parallelism: 1, &block)
7
+ def test_with_mercury_cps(sources, queues, **kws, &block)
8
8
  em do
9
9
  seql do
10
- let(:m) { Mercury::Monadic.open(parallelism: parallelism) }
10
+ let(:m) { Mercury::Monadic.open(**kws) }
11
11
  and_then { delete_sources_and_queues_cps(sources, queues) }
12
12
  and_then { block.call(m) }
13
13
  and_then { delete_sources_and_queues_cps(sources, queues) }
@@ -26,6 +26,15 @@ module MercuryFakeSpec
26
26
  # runs a test once with real mercury and once with Mercury::Fake
27
27
  def itt(name, &block)
28
28
  it(name, &block)
29
+ context 'without publisher confirms' do
30
+ before :each do
31
+ real_open = Mercury.method(:open)
32
+ allow(Mercury).to receive(:open) do |**kws, &k|
33
+ real_open.call(**kws.merge(wait_for_publisher_confirms: false), &k)
34
+ end
35
+ end
36
+ it(name, &block)
37
+ end
29
38
  context 'with Mercury::Fake' do
30
39
  before :each do
31
40
  allow(Mercury).to receive(:open) do |parallelism:1, &k|
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mercury_amqp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Winton
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-03-12 00:00:00.000000000 Z
11
+ date: 2016-04-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler