mercury_amqp 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  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