mercury_amqp 0.4.0 → 0.5.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: 459b852ab72eefefec630bac89fde701f7fc2018
4
- data.tar.gz: a0ae43b454314876e4dc1be0e6ccf9480d8a9ed6
3
+ metadata.gz: 43d54035bd40df4089510aa9839d554f2e715c8f
4
+ data.tar.gz: c8e309676dbdb1f4488a23c95a060ca4e2d78f3c
5
5
  SHA512:
6
- metadata.gz: 296ef00ec39d0419c4f55b185528a4d0ed905d236014ad0f8a432a310a01033cf4105c6c2c19a82d02cec850c4de3e8af2a96eba8f0e6d8f4761002a1ef971df
7
- data.tar.gz: 7fcbe0aa189f7d5924312210ddaf7eb1fb6111ff0f4f74e38fddc1a7410f26a8820fd871cac8daaf0c93b363bee153cb61b1b85ca4ee760bc8c6240048758ca3
6
+ metadata.gz: c11bd808a2634847b2a7c1b97975a7c3f2601086a82c2b43ba4fc866aea0a068cae96d4d7ce5cd05a3c7678ff8beb69d17a1cc311a16f9c634ba0780184ef917
7
+ data.tar.gz: 0d458c16c5ea7706e56ec0d39549bb6cb762989b3c057c9426f5e401bccd05974b49f567cbdf9ec30d44c06d91caeca91d23d221d0074e59d8635b3c65b3e150
data/README.md CHANGED
@@ -81,13 +81,17 @@ Indicates message handling succeeded. The message is removed from the queue.
81
81
  **nack**(_msg_)
82
82
 
83
83
  Indicates message handling failed, with the assumption that it might succeed at
84
- a later time. The message is returned to the queue.
84
+ a later time. The message is returned to the front of the queue.
85
85
 
86
86
  **reject**(_msg_)
87
87
 
88
88
  Indicates message handling failed, with the assumption that it can never succeed.
89
89
  The message is removed from the queue.
90
90
 
91
+ **republish**(_msg_)
92
+
93
+ Like **nack**, except the message is returned to the _back_ of the queue.
94
+
91
95
  _Note:_ All operations create the referenced constructs if they do not already exist.
92
96
 
93
97
  ### Serialization
@@ -200,6 +204,212 @@ seql do
200
204
  end
201
205
  ```
202
206
 
207
+ Monadic Interface
208
+ -----------------
209
+
210
+ The _monad_ is a versatile design pattern. There is plenty of
211
+ literature online, but for now all you need to know is that mercury
212
+ uses monad principles to chain together asynchronous operations. It
213
+ all starts with a `Cps` object.
214
+
215
+ ```
216
+ 2.2.2 :005 > add1 = Cps.new { |n, &k| k.call(n+1) }
217
+ => #<Mercury::Cps:...>
218
+ ```
219
+
220
+ Much like `Proc.new`, `Cps.new` merely captures some operation in an
221
+ object but does not do any actual work. The key difference is that
222
+ `Cps` captures a "continuation-passing style" ("CPS") operation. That is,
223
+ instead of returning a value to the caller, the operation takes its
224
+ continuation as an additional argument `k`. (The actual name doesn't matter,
225
+ of course, but `k` is traditional.) `k` is simply a `Proc`.
226
+ You can loosely think of it as the "return _`Proc`_", as opposed to
227
+ the usual return _statement_.
228
+
229
+ To invoke a `Proc` we call `Proc#call`. To invoke a `Cps`, we call
230
+ `Cps#run`, passing the normal arguments as well as the continuation
231
+ (the block).
232
+
233
+ ```
234
+ 2.2.2 :006 > add1.run(2) { |result| puts "result = #{result}" }
235
+ result = 3
236
+ ```
237
+
238
+ As you've seen already, asynchronous APIs are closely tied to CPS. In
239
+ the case of mercury, an operation may involve a conversation with the
240
+ server. CPS allows our code to go off and do other things -- namely,
241
+ handle other independent requests -- but also be notified when the
242
+ operation finally completes.
243
+
244
+ ### Combining operations
245
+
246
+ `Cps` provides a means of combining operations into a larger
247
+ operation: `Cps#and_then`.
248
+
249
+ ```ruby
250
+ def add1(n)
251
+ Cps.new { |&k| k.call(n+1) }
252
+ end
253
+
254
+ def print_value(n)
255
+ Cps.new do |&k|
256
+ puts "value = #{n}"
257
+ k.call
258
+ end
259
+ end
260
+
261
+ def add1_and_print(n)
262
+ add1(n).and_then { |v| print_value(v) }
263
+ end
264
+ ```
265
+ ```
266
+ 2.2.2 :028 > add1_and_print(2).run
267
+ value = 3
268
+ ```
269
+
270
+ `Cps#and_then`'s block accepts the result of the previous operation and
271
+ returns the `Cps` object representing the next operation to perform.
272
+
273
+ As it turns out, the best way to factor an operation is as a
274
+ method that
275
+
276
+ - accepts the operation arguments, and
277
+ - returns a `Cps` object
278
+
279
+ as seen above.
280
+
281
+ ### Sequences
282
+
283
+ As you can imagine, long `and_then` chains can get syntactially messy.
284
+ This is where `seq` comes in.
285
+
286
+ ```
287
+ def add1_and_print(n)
288
+ seq do |th|
289
+ th.en { add1(n) }
290
+ th.en { |v| print_value(v) }
291
+ end
292
+ end
293
+ ```
294
+
295
+ This is still not ideal; it would be nice to have a way to bind `v` to
296
+ the result of `add1(n)` rather than introducing a parameter on the
297
+ line below. `seql` contains some magic that allows us to do
298
+ exactly this. (It also eliminates the weird `th.en`.)
299
+
300
+ ```ruby
301
+ def add1_and_print(n)
302
+ seql do
303
+ let(:v) { add1(n) }
304
+ and_then { print_value(v) }
305
+ end
306
+ end
307
+ ```
308
+
309
+ Another benefit of `seql` and `let` is that it makes `v` visible to
310
+ _all_ subsequent `and_then` blocks, not just the immediately following
311
+ one.
312
+
313
+ But what if we want to introduce a non-CPS operation into our
314
+ sequence?
315
+
316
+ ```ruby
317
+ def add1_and_print(n)
318
+ seql do
319
+ let(:v) { add1(n) }
320
+ and_then { puts 'added!' }
321
+ and_then { print_value(v) }
322
+ end
323
+ end
324
+ ```
325
+ ```
326
+ 2.2.2 :061 > add1_and_print(2).run
327
+ added!
328
+ RuntimeError: 'and_then' block did not return a Cps object.
329
+ ```
330
+
331
+ This fails because it violates the requirement that the `and_then`
332
+ block return a `Cps` object, and `puts` does not. `lift` returns a
333
+ `Cps` object for a block of direct-style code.
334
+
335
+ ```ruby
336
+ def add1_and_print(n)
337
+ seql do
338
+ let(:v) { add1(n) }
339
+ and_then { lift { puts 'added!' } }
340
+ and_then { print_value(v) }
341
+ end
342
+ end
343
+ ```
344
+ ```
345
+ 2.2.2 :053 > add1_and_print(2).run
346
+ added!
347
+ value = 3
348
+ ```
349
+
350
+ Finally, a little clean up:
351
+
352
+ ```ruby
353
+ def add1_and_print(n)
354
+ seql do
355
+ let(:v) { add1(n) }
356
+ and_lift { puts 'added!' }
357
+ and_then { print_value(v) }
358
+ end
359
+ end
360
+ ```
361
+
362
+ ### `Mercury::Monadic`
363
+
364
+ `Mercury::Monadic` simply wraps `Mercury` so that the methods return
365
+ `Cps` objects rather than accepting an explicit continuation.
366
+
367
+ ```ruby
368
+ seql do
369
+ let(:m) { Mercury::Monadic.open }
370
+ and_then { m.start_listener(source) }
371
+ ...
372
+ end
373
+ ```
374
+
375
+ It is particularly useful when writing tests.
376
+
377
+
378
+ Design Details
379
+ --------------
380
+
381
+ #### `Mercury#republish`
382
+
383
+ This method publishes a copy of the message to the _back_ of the
384
+ queue, then acks the original message. This is a similar operation to
385
+ ack/nack/reject. It is only applicable to messages received by
386
+ workers, since listener messages cannot be acknowledged. Unlike other
387
+ acknowledgements, `#republish` takes a continuation (due to the
388
+ publish operation), so the method is located on `Mercury` rather than
389
+ `Mercury::ReceivedMessage`.
390
+
391
+ It is important that the message is republished to the AMQP _queue_
392
+ and not the _source_. Acknowledgement is a worker concern. If two
393
+ different worker pools were working off a common source, it wouldn't
394
+ make sense for pool A to get a duplicate message because pool B failed
395
+ to handle the message.
396
+
397
+ AMQP allows publishing to a particular queue by sending the message to
398
+ the [default exchange][default_exchange], specifying the queue name as
399
+ the routing key. Thus, if the message was originally sent with a
400
+ non-empty routing key, that information is lost. Some clients rely on
401
+ the routing key/tag to dictate behavior (by reading
402
+ `Mercury::ReceivedMessage#tag`). To avoid breaking such clients,
403
+ republish propagates the original tag in a header and reports this value
404
+ as the tag on the republished message.
405
+
406
+ Republishing also introduces a `Republish-Count` header and
407
+ corresponding attribute `Mercury::ReceivedMessage#republish_count`.
408
+ This value is incremented each time the message is republished.
409
+ Clients may want to check this value and act differently if it exceeds
410
+ some threshold.
411
+
412
+
203
413
  Design Decisions
204
414
  ----------------
205
415
 
@@ -216,3 +426,4 @@ is being intentionally ignored.
216
426
  [logatron]: https://github.com/indigobio/logatron
217
427
  [em_defer]: http://www.rubydoc.info/github/eventmachine/eventmachine/EventMachine.defer
218
428
  [fiber_defer]: https://github.com/indigobio/abstractivator/blob/master/lib/abstractivator/fiber_defer.rb
429
+ [default_exchange]: https://www.rabbitmq.com/tutorials/amqp-concepts.html
@@ -9,7 +9,7 @@ class Mercury
9
9
 
10
10
  def initialize(queue, msg, tag, headers, is_ackable)
11
11
  metadata = Metadata.new(tag, headers, proc{queue.ack_or_reject_message(self)}, proc{queue.nack(self)})
12
- @received_msg = ReceivedMessage.new(msg, metadata, is_ackable: is_ackable)
12
+ @received_msg = ReceivedMessage.new(msg, metadata, work_queue_name: is_ackable ? queue.worker : nil)
13
13
  @headers = headers
14
14
  @delivered = false
15
15
  end
data/lib/mercury/fake.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require 'securerandom'
2
2
  require 'delegate'
3
+ require 'active_support/core_ext/hash/keys'
4
+ require 'active_support/core_ext/object/deep_dup'
3
5
  require 'mercury/received_message'
4
6
  require 'mercury/fake/domain'
5
7
  require 'mercury/fake/metadata'
@@ -43,7 +45,15 @@ class Mercury
43
45
 
44
46
  def publish(source_name, msg, tag: '', headers: {}, &k)
45
47
  guard_public(k)
46
- queues.values.select{|q| q.binds?(source_name, tag)}.each{|q| q.enqueue(roundtrip(msg), tag, headers)}
48
+ queues.values.select{|q| q.binds?(source_name, tag)}.each{|q| q.enqueue(roundtrip(msg), tag, headers.stringify_keys)}
49
+ ret(k)
50
+ end
51
+
52
+ def republish(msg, &k)
53
+ guard_public(k)
54
+ msg.ack
55
+ queue = queues.values.detect{|q| q.worker == msg.work_queue_name}
56
+ queue.enqueue(roundtrip(msg.content), msg.tag, Mercury.increment_republish_count(msg))
47
57
  ret(k)
48
58
  end
49
59
 
@@ -58,7 +68,7 @@ class Mercury
58
68
  def start_worker_or_listener(source_name, handler, tag_filter, worker_group=nil, &k)
59
69
  guard_public(k)
60
70
  tag_filter ||= '#'
61
- q = ensure_queue(source_name, tag_filter, !!worker_group, worker_group)
71
+ q = ensure_queue(source_name, tag_filter, worker_group)
62
72
  ret(k) # it's important we show the "start" operation finishing before delivery starts (in add_subscriber)
63
73
  q.add_subscriber(Subscriber.new(handler, @parallelism))
64
74
  end
@@ -102,7 +112,8 @@ class Mercury
102
112
  ws.read(ws.write(msg))
103
113
  end
104
114
 
105
- def ensure_queue(source, tag_filter, require_ack, worker=nil)
115
+ def ensure_queue(source, tag_filter, worker)
116
+ require_ack = worker != nil
106
117
  worker ||= SecureRandom.uuid
107
118
  queues.fetch(unique_queue_name(source, tag_filter, worker)) do |k|
108
119
  queues[k] = Queue.new(source, tag_filter, worker, require_ack)
@@ -5,6 +5,9 @@ require 'mercury/received_message'
5
5
  require 'logatron/logatron'
6
6
 
7
7
  class Mercury
8
+ ORIGINAL_TAG_HEADER = 'Original-Tag'.freeze
9
+ REPUBLISH_COUNT_HEADER = 'Republish-Count'.freeze
10
+
8
11
  attr_reader :amqp, :channel, :logger
9
12
 
10
13
  def self.open(logger: Logatron, **kws, &k)
@@ -60,17 +63,34 @@ class Mercury
60
63
  # The amqp gem caches exchange objects, so it's fine to
61
64
  # redeclare the exchange every time we publish.
62
65
  with_source(source_name) do |exchange|
63
- payload = write(msg)
64
- pub_opts = Mercury.publish_opts(tag, headers)
65
- if publisher_confirms_enabled
66
- expect_publisher_confirm(k)
67
- exchange.publish(payload, **pub_opts)
68
- else
69
- exchange.publish(payload, **pub_opts, &k)
70
- end
66
+ publish_internal(exchange, msg, tag, headers, &k)
67
+ end
68
+ end
69
+
70
+ # Places a copy of the message at the back of the queue, then acks
71
+ # the original message.
72
+ def republish(msg, &k)
73
+ guard_public(k)
74
+ raise 'Only messages from a work queue can be republished' unless msg.work_queue_name
75
+ headers = Mercury.increment_republish_count(msg).merge(ORIGINAL_TAG_HEADER => msg.tag)
76
+ publish_internal(@channel.default_exchange, msg.content, msg.work_queue_name, headers) do
77
+ msg.ack
78
+ k.call
71
79
  end
72
80
  end
73
81
 
82
+ def publish_internal(exchange, msg, tag, headers, &k)
83
+ payload = write(msg)
84
+ pub_opts = Mercury.publish_opts(tag, headers)
85
+ if publisher_confirms_enabled
86
+ expect_publisher_confirm(k)
87
+ exchange.publish(payload, **pub_opts)
88
+ else
89
+ exchange.publish(payload, **pub_opts, &k)
90
+ end
91
+ end
92
+ private :publish_internal
93
+
74
94
  def self.publish_opts(tag, headers)
75
95
  { routing_key: tag, persistent: true, headers: Logatron.http_headers.merge(headers) }
76
96
  end
@@ -80,7 +100,7 @@ class Mercury
80
100
  with_source(source_name) do |exchange|
81
101
  with_listener_queue(exchange, tag_filter) do |queue|
82
102
  queue.subscribe(ack: false) do |metadata, payload|
83
- handler.call(make_received_message(payload, metadata, false))
103
+ handler.call(make_received_message(payload, metadata))
84
104
  end
85
105
  k.call
86
106
  end
@@ -92,7 +112,7 @@ class Mercury
92
112
  with_source(source_name) do |exchange|
93
113
  with_work_queue(worker_group, exchange, tag_filter) do |queue|
94
114
  queue.subscribe(ack: true) do |metadata, payload|
95
- handler.call(make_received_message(payload, metadata, true))
115
+ handler.call(make_received_message(payload, metadata, work_queue_name: worker_group))
96
116
  end
97
117
  k.call
98
118
  end
@@ -137,6 +157,8 @@ class Mercury
137
157
 
138
158
  private
139
159
 
160
+ LOGATRAON_MSG_ID_HEADER = 'X-Ascent-Log-Id'.freeze
161
+
140
162
  # In AMQP, queue consumers ack requests after handling them. Unacked messages
141
163
  # are automatically returned to the queue, guaranteeing they are eventually handled.
142
164
  # Services often ack one request while publishing related messages. Ideally, these
@@ -189,9 +211,9 @@ class Mercury
189
211
  end
190
212
  end
191
213
 
192
- def make_received_message(payload, metadata, is_ackable)
193
- msg = ReceivedMessage.new(read(payload), metadata, is_ackable: is_ackable)
194
- Logatron.msg_id = msg.headers['X-Ascent-Log-Id']
214
+ def make_received_message(payload, metadata, work_queue_name: nil)
215
+ msg = ReceivedMessage.new(read(payload), metadata, work_queue_name: work_queue_name)
216
+ Logatron.msg_id = msg.headers[LOGATRAON_MSG_ID_HEADER]
195
217
  msg
196
218
  end
197
219
 
@@ -295,6 +317,12 @@ class Mercury
295
317
  end
296
318
  end
297
319
 
320
+ # @param msg [Mercury::ReceivedMessage]
321
+ # @return [Hash] the headers with republish count incremented
322
+ def self.increment_republish_count(msg)
323
+ msg.headers.merge(REPUBLISH_COUNT_HEADER => msg.republish_count + 1)
324
+ end
325
+
298
326
  def guard_public(k, initializing: false)
299
327
  Mercury.guard_public(@amqp.nil?, k, initializing: initializing)
300
328
  end
@@ -25,6 +25,7 @@ class Mercury
25
25
  end
26
26
 
27
27
  wrap(:publish)
28
+ wrap(:republish)
28
29
  wrap(:start_listener)
29
30
  wrap(:start_worker)
30
31
  wrap(:delete_source)
@@ -1,19 +1,23 @@
1
1
  class Mercury
2
2
  class ReceivedMessage
3
- attr_reader :content, :metadata, :action_taken
3
+ attr_reader :content, :metadata, :action_taken, :work_queue_name
4
4
 
5
- def initialize(content, metadata, is_ackable: false)
5
+ def initialize(content, metadata, work_queue_name: nil)
6
6
  @content = content
7
7
  @metadata = metadata
8
- @is_ackable = is_ackable
8
+ @work_queue_name = work_queue_name
9
9
  end
10
10
 
11
11
  def tag
12
- metadata.routing_key
12
+ headers[Mercury::ORIGINAL_TAG_HEADER] || metadata.routing_key
13
13
  end
14
14
 
15
15
  def headers
16
- metadata.headers || {}
16
+ (metadata.headers || {}).dup
17
+ end
18
+
19
+ def republish_count
20
+ (metadata.headers[Mercury::REPUBLISH_COUNT_HEADER] || 0).to_i
17
21
  end
18
22
 
19
23
  def ack
@@ -33,8 +37,12 @@ class Mercury
33
37
 
34
38
  private
35
39
 
40
+ def is_ackable
41
+ @work_queue_name != nil
42
+ end
43
+
36
44
  def performing_action(action)
37
- @is_ackable or raise "This message is not #{action}able"
45
+ is_ackable or raise "This message is not #{action}able"
38
46
  if @action_taken
39
47
  raise "This message was already #{@action_taken}ed"
40
48
  end
@@ -1,3 +1,3 @@
1
1
  class Mercury
2
- VERSION = '0.4.0'
2
+ VERSION = '0.5.0'
3
3
  end
data/mercury_amqp.gemspec CHANGED
@@ -31,4 +31,5 @@ Gem::Specification.new do |spec|
31
31
  spec.add_runtime_dependency 'bunny', '~> 2.1'
32
32
  spec.add_runtime_dependency 'binding_of_caller', '~> 0.7'
33
33
  spec.add_runtime_dependency 'logatron', '~> 0'
34
+ spec.add_runtime_dependency 'activesupport', '~> 4.0'
34
35
  end
@@ -121,6 +121,34 @@ describe Mercury::Monadic do
121
121
  end
122
122
  end
123
123
 
124
+ itt 'republishes' do
125
+ test_with_mercury do |m|
126
+ msgs = []
127
+ seql do
128
+ and_then { m.start_worker(worker, source, &msgs.method(:push)) }
129
+ and_then { m.publish(source, msg, tag: 'foo', headers: {bar: 123}) }
130
+ and_then { wait_until { msgs.size == 1 } }
131
+ and_lift do
132
+ expect(msgs.last.tag).to eql 'foo'
133
+ expect(msgs.last.headers['bar']).to eql 123
134
+ expect(msgs.last.republish_count).to eql 0
135
+ end
136
+ and_then { m.republish(msgs.last) }
137
+ and_then { wait_until { msgs.size == 2 } }
138
+ and_lift do
139
+ expect(msgs.last.tag).to eql 'foo' # preserves the tag
140
+ expect(msgs.last.headers['bar']).to eql 123 # preserves the headers
141
+ expect(msgs.last.republish_count).to eql 1 # increments the republish count
142
+ end
143
+ and_then { m.republish(msgs.last) }
144
+ and_then { wait_until { msgs.size == 3 } }
145
+ and_lift do
146
+ expect(msgs.last.republish_count).to eql 2 # can republish a republished message
147
+ end
148
+ end
149
+ end
150
+ end
151
+
124
152
  it 'propagates logatron headers' do
125
153
  real_msg_id = SecureRandom.uuid
126
154
  Logatron.msg_id = real_msg_id
@@ -56,11 +56,11 @@ describe Mercury::ReceivedMessage do
56
56
  end
57
57
 
58
58
  def make_actionable
59
- Mercury::ReceivedMessage.new('hello', make_metadata, is_ackable: true)
59
+ Mercury::ReceivedMessage.new('hello', make_metadata, work_queue_name: 'foo')
60
60
  end
61
61
 
62
62
  def make_non_actionable
63
- Mercury::ReceivedMessage.new('hello', make_metadata, is_ackable: false)
63
+ Mercury::ReceivedMessage.new('hello', make_metadata, work_queue_name: nil)
64
64
  end
65
65
 
66
66
  def make_metadata
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.4.0
4
+ version: 0.5.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-04-28 00:00:00.000000000 Z
11
+ date: 2016-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -178,6 +178,20 @@ dependencies:
178
178
  - - "~>"
179
179
  - !ruby/object:Gem::Version
180
180
  version: '0'
181
+ - !ruby/object:Gem::Dependency
182
+ name: activesupport
183
+ requirement: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - "~>"
186
+ - !ruby/object:Gem::Version
187
+ version: '4.0'
188
+ type: :runtime
189
+ prerelease: false
190
+ version_requirements: !ruby/object:Gem::Requirement
191
+ requirements:
192
+ - - "~>"
193
+ - !ruby/object:Gem::Version
194
+ version: '4.0'
181
195
  description: Abstracts common patterns used with AMQP
182
196
  email:
183
197
  - wintonpc@gmail.com