mercury_amqp 0.4.0 → 0.5.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: 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